跳转至

18 单测与重构:怎样提高代码的质量?

你好,我是李云。这一讲我们来聊一聊代码质量。

我先问你个问题,与同事配合完成工作任务时,你会希望对方的工作结果是可靠、有质量的吗?你估计会说:“这还用问吗,一定是希望那样啦,不然大家协作起来特别低效,我的工作进展也一定会受制于对方。”

那我再问你一个问题,对方对你的工作结果也是这样希望的,那你是如何保证自己的工作质量的呢?换句话说,对于咱编程来说,你是如何保证代码质量的呢?面对这个问题,你还像上面那个问题那样,信心十足地说,“我有自己的质量保证方法”吗?依据我在职场的观察,提到代码质量保证这个话题,很多工程师都是心虚的。

你可能会说了,我在工作中非常重视自己所写代码的质量,写代码时小心又谨慎,自己也会反复地测试,确保没问题。那我又要进一步问你了。

  1. 你如何度量小心和谨慎的程度呢?要知道,没法度量的话就意味着这不是一个能实操的方法,那对于实践的指导意义就不大。
  2. 你如何证明反复测试能最终带来高质量的代码呢?

如果你对代码质量保证这事有点力不从心,那我得告诉你,很早就有人思考过这个问题,而且软件行业有标准的做法来保证代码质量,那就是单元测试。你一听到“单元测试”四个字,可能会说,“哦,我在工作中是做单元测试的”,很好,那我马上会问,“你又是如何度量单元测试效果的呢?”如果你不能脱口而出,说“我是通过单元测试覆盖率来确认效果的”,那我会认为,大概率你并没有完全掌握单元测试,你的实践是不到位的,对代码质量的保证也是不充分的。

度量你的代码质量

单元测试是指,除了编写软件产品代码,还得写额外的代码来验证软件产品代码的质量。单元测试代码是一种由开发工程师来完成的白盒测试方法。千万不要因为“单元测试”这个名字中包含了“测试”两个字,就以为那是测试工程师的事,这么想可就太外行了。

通常单元测试需要采用一定的单元测试框架,比如C语言的cmocka,C++语言的GoogleTest,Java语言的JUnit,Go语言的Testify,等等。咱在这门课程中并没打算教大家如何写单元测试代码,你需要通过其他的学习资料去掌握。

不过,考虑到很多人听说过单元测试这个概念,但并不清楚如何度量单元测试的效果,所以我觉得很有必要让大家对单元测试覆盖率(Unit Test Coverage)有直观的认识,单元测试覆盖率的另一种叫法是代码覆盖率(Code Coverage)。

下图是embedded开源项目的单元测试覆盖率报告。其中列出了整个项目的行、函数和分支覆盖率(比如分别是61.9%、69.5%和50.8%),以及每个目录的行、函数和分支覆盖率。估计你也猜到了,这三个指标越高代表单元测试的效率就越好,说明软件产品代码的质量就越有保证。

现实场景中,点上图左边的目录可以查看目录中每个文件的覆盖率情况。比如,下图就是某个文件的覆盖率报告的片段。

注意图中包含了四列,从左到右分别是代码的行号、分支覆盖情况、代码被执行的次数和原始代码。需要特别注意图中红色的内容,那代表分支或代码并没有被测试到。换句话说,我们在做单元测试时,需要通过查看这个报告来设计单元测试的用例。如果没有这样的覆盖率报告,那咱设计测试用例就完全是盲人摸象,会让人心里非常不踏实,对质量保证的效果自然就没底。

单元测试的好处

前面我说,“很多工程师对代码质量保证这个话题是心虚的”,原因就是他们不会认真落实单元测试工作。这些人语言上认可代码质量很重要,行动上却不落实单元测试,说起来无外乎没时间、成本高这些原因。他们没有意识到,对工作质量的信心是影响生活质量的。你想啊,开发任务是完成了,但如果没有以单元测试做保证,心里还是会提心吊胆的,指不定因为存在软件缺陷而影响了整个项目的进展,那时的压力多大啊!

程序员如果不做单元测试,就好比我们造房子时,不是在房子建造的过程中确保一砖一瓦的质量,而是等到造完后再来验证质量。这样做事的风险和浪费能不大吗?

那些不拥抱、不喜欢单元测试的工程师,最大的卡点是没有基于实践来体验到单元测试的好处,只看到了学习掌握时的成本。这与那些不会骑自行车的人,总在那担心安全,怕花时间,或者练了一段时间但还没有到娴熟的程度,觉得麻烦而放弃,是一回事。

那单元测试有哪些好处呢?我总结了有这么四点。

第一,将质量保证工作前置从而提升开发质量和效率。保证开发质量的前提是,确保单元测试的代码覆盖率达到较高的百分比(比如行覆盖达到95%、分支覆盖达到90%),背后意味着设计了足够的单元测试用例。在这种情形下,单元测试能很好地缓解“测试只能证明失败、不能证明成功”的现象。

对了,实施单元测试时有两种顺序。第一种顺序是,写完软件产品代码后,先与其他模块联调,联调好了再写单元测试代码。第二种顺序是,写完软件产品代码后,接下来写单元测试代码,单元测试完成后再与其他模块联调。不知你在工作中采用的是其中的哪种顺序呢?如果你采用的是第一种顺序,那可就亏大了!

单元测试只要验证被测模块的功能,所以做单元测试时需要调试的功能就很少,也很集中,换句话说效率更高。所以,产品代码写好后,第一时间做单元测试更好,这样后面的联调会很顺利。如果先做联调,那联调会涉及其他模块的功能,就有多个模块存在问题的可能,这就让联调工作更费时。等到联调好了再做单元测试,那自然能发现的问题就少,于是给我们带来“单元测试无用”的认知偏差。本来引入单元测试是为了提高工作效率,最终却得到了相反的效果。

第二,能很好地检验被测软件的设计质量。单元测试是聚焦于单独的被测模块的,为此被测代码在实现阶段需要做好与其他模块的充分解耦,为了方便单元测试而适当地预留一定的API。当被测代码的软件设计工作不到位时,落实单元测试就会变得困难。你看,落实单元测试其实是倒逼我们重视软件设计质量。

第三,单元测试代码能很好地当作,被测软件产品代码的使用说明书。通过查看单元测试代码,就能很直观地理解如何使用被测代码所提供的API。

第四,能提升被测代码在未来的可变性。代码变更是软件生命周期中不可避免的事,有了单元测试,那么每次变更时,通过单元测试的回归就能很好地避免引入新的问题。针对这一点,我们得聊聊提高代码质量的另一个维度,那就是如何通过单元测试来保证软件的设计质量。还记得在17讲所聊到的,软件设计质量决定了整个软件产品的质量吗?

以重构改善软件设计

单元测试是保证编码质量的方法,还记得17讲中提出的“软件开发 =k x 软件设计 + 编码”这个公式吗?那如何提高软件开发活动中的软件设计质量呢?注意,这里用的词是“提高”而不是“保证”,因为软件设计只有合适或更好,没有所谓的正确。

马丁·福勒通过《重构:改善既有代码的设计》这本书很好地回答了这个问题。是的,软件行业响当当的“重构”这个概念,正是这本书所塑造的。

在咱的日常工作中,另一个与“重构”相近的词是“重写”,那两者的本质区别是什么呢?

这两个概念的相同之处是都得对代码进行改变,以致日常工作中容易被混用,但其实表达的意思有很大不同,背后所涉及的正是两词的本质区别。这个区别是:重构是基于现有的单元测试,确保改变代码时不会引入新的问题。换句话说,重构相比重写能很好地控制风险。日常工作中很多人说的重构,其实更准确的表达是重写或优化,因为没有单元测试做风险防范。

对于代码来说,我们怕的不是它不完善,而是改不动。还记得16讲中我们聊到的,好程序应满足易读与易改两大特征吗?单元测试的存在,正是为了保证代码改得动!这也是为什么全球知名的大型开源软件项目,无一例外地会践行单元测试,保证现有代码质量的同时,还让代码在未来具有可变性。确保代码的未来可变性,是建立在实施单元测试之上的、软件行业非常重要的一种工程能力

知道了重构,对咱的能力提升还有其他的帮助吗?在15讲中聊如何提高编程能力时指出得多读多练,如果你的代码落实了单元测试,那几个月后发现代码写得差时,就能通过重构去改善代码的质量。换句话说,落实单元测试能创造更多练的机会。

总结时刻

结束前让我们来做个总结。这一讲我们共同探讨了如何提高代码的质量。作为一名专业的工程师,应当承担起确保自己所写代码的质量这一责任。这一责任表面看来是一种负担,实际上却是专业做事的需要。一旦代码质量有了保证,我们的工作就不会因为“救火”而变得手忙脚乱,也能更安心地生活。

作为专业人士一定得知道,单元测试覆盖率是度量单元测试效果的方法。通过量化让无形的代码质量变得具体,让我们在工作中能有的放矢地编写单元测试用例。

此外,单元测试工作应安排在被测试代码与其他模块的联调之前,这样才能享受到单元测试所带来的开发效率提升。

软件设计是不可能一步到位的,需要随着业务的发展、产品的迭代而不断演进。我们并不担心软件设计得不适当,怕的是想优化时一改就出问题,基于单元测试的重构就是应对优化软件设计风险的一种好方法。

以我对单元测试的实践和对行业普遍忽视单元测试的观察,无论如何强调单元测试的重要性都不为过。

最后,我想请你思考,“测试只能证明失败、不能证明成功”这话,背后的深层次原因是什么?期待你留言与我分享交流。

我们下一讲见。

精选留言(2)
  • pyhhou 👍(1) 💬(1)

    我理解的是,成功与否很多时候不是由程序本身决定的,比如实际中出现的一些 case,如果设计和程序中均没有考虑到,当然测试也就没法发现。测试的目的是让程序按照设计和预想的那样落地,因为程序是人写的,人会犯错,测试是一个保护机制,同时帮助我们更好地认清自己写的代码中的漏洞,快速定位问题,让开发迭代更高效。但它没法无中生有,做到完美。 其实在我看来 “证明成功” 本身就是一个伪命题,如果说一个技术能证明成功,那么这门技术就应该是行业的金科玉律,只要掌握这门技术,做出来的软件就会是完美的。但我们都知道,在软件开发中,没有银弹,更不可能完美。 最后想问问老师怎么看 TDD 的,我们有必要花大量的时间来学习吗?我尝试过,但是先写测试有点不适应,感觉不到这门技术的优点

    2024-04-25

  • edward 👍(1) 💬(1)

    我的理解是测试失败的情况通常是明显和可以量化的,但软件的成功还有不可测的方面,比如软件是否确实对用户产生了价值等。此外,老师是否可以推荐一些实践性强的指导测试的资料,谢谢。

    2024-04-17