今天我们来谈一谈一个程序员的必修技能,如何把测试覆盖率做到100%!
测试覆盖率是一种度量指标,指的是在运行一个测试集合时,代码被执行的比例。它的一个主要作用就是告诉我们有多少代码测试到了。其实更严格地说,测试覆盖率应该叫代码覆盖率,只不过大多数情况它都是被用在测试的场景下,所以在很多人的讨论中,并不进行严格的区分。
既然测试覆盖率是度量指标,我们就需要知道有哪些具体的指标,常见的测试覆盖率指标有下面这几种:
以函数覆盖率为例,如果我们在代码中定义了 100 个函数,运行测试之后只执行 80 个,那它的函数覆盖率就是 80/100=0.8,也就是 80%。
这几个指标基本上看一眼就知道是怎么回事,唯一稍微复杂一点就是条件覆盖率,因为它要测试的是在一个布尔表达式中每个子表达式所有真假值的情况,我们来看看下面这个代码。
if ((a || b) && c) {
...
}
就是这么一个看上去很简单的情况,因为它牵扯到 a、b、c 三个子表达式,又要把每个子表达式的真假值都要测试到,所以,就需要有 8 种情况。
在这么一个条件比较简单的情况下,其实条件覆盖率已经是很复杂了。如果条件进一步增多,复杂度会进一步提升,想要在测试里对条件进行全覆盖也不是一件容易的事。这也给了我们一个编码上的提示:尽可能减少条件。 事实上,在真实的项目中,很多条件都是不必要的复杂,可以通过提前返回将一些复杂的条件做一个拆分。
其实,测试覆盖率的指标还有一些,不过上面这些已经足够我们在日常工作中使用了。而且,具体能够使用哪个指标,还要看我们使用的工具具体支持哪些指标。
下面我就以 Jacoco 为例,讲讲如何实际地使用一个测试覆盖率工具。
JaCoCo 是 Java 社区常用的一个测试覆盖率工具,这个名字一看就是 Java Code Coverage 的缩写。开发它的团队原本是开发一个叫 EclEmma 的 Eclipse 插件,这个插件本身就是用来做测试覆盖率的。只不过,后来团队发现开源社区虽然有不少测试覆盖率的实现,但大多绑定在特定工具上,于是,他们决定启动 JaCoCo 这个项目,把它当做一个不绑定在特定工具上的独立实现,让它成为 JVM 环境中的标准技术。
我们已经知道了测试覆盖率有好多不同的指标,学习一个具体的测试覆盖率工具,主要就是把指标做一个对应,知道如何设置相应的指标。
在 JaCoCo 里,指标对应的概念是 counter。我们要在覆盖率中使用哪些指标,也就是要指定哪些不同的 counter。
每个 counter 提供了不同的配置,比如覆盖的数量(COVEREDCOUNT),没有覆盖的数量(MISSEDCOUNT)等等,但我们最关心的只有一件事:覆盖率(COVEREDRATIO)。
有了 counter,选定了配置,接下来,要确定的就是取值的范围,也就是最大值(maximum)和最小值(minimum)是多少。比如,我们这里关注的就是覆盖率的值应该是多少,一般就是配置它的最小值(minimum)是多少。
覆盖率是一个比例,所以,它的取值范围就是从 0 到 1。我们可以根据自己项目的需要来进行配置。根据上面的介绍,如果我们要求行覆盖率达到 80%,我们就可以这样配置。
counter: "LINE", value: "COVEREDRATIO", minimum: "0.8"
好,你现在已经有了对于 JaCoCo 的基本了解。但通常在项目中,我们很少会直接使用它,而是会把它与我们项目的自动化过程结合起来。
无论是 Ant,还是 Maven,抑或是 Gradle,Java 社区主流的自动化工具都提供了对于 JaCoCo 的支持,我们可以根据自己选用的工具进行配置。大部分情况下,配置一次,全团队的人就都可以使用了。
这里面的关键点在于,把测试覆盖率与提交过程联系起来。我们在实战中,提交之前要运行检查过程,测试覆盖率检查就在这个过程里。这样,就保证了它不是一个独立的存在,不仅在我们开发过程中起作用,更进一步,在持续集成的过程中也能够起到作用。
在日常开发中,真正与我们经常打交道的是测试覆盖率不通过的时候,比如,在我们的实战中,运行脚本对代码进行检查时,如果测试覆盖率不够,我们就会得到下面这样的提示。
Rule violated for package com.github.dreamhead.todo.cli.file: lines covered ratio is 0.9, but expected minimum is 1.0
这里会有哪些报错,取决于我们配置了多少个 counter。按照我通常的习惯,我会把所有的 counter 都配置上去,这样就可以发现更多的问题了。
不过,这个提示只是告诉我们测试覆盖率不够,但具体哪不够,我们还需要查看测试覆盖率的报告。一般来说,测试覆盖率的报告是我们在与工具集成的时候配置好的。JaCoCo 可以提供好多种报告类型:XML、CSV、HTML 等等。按照一般使用习惯来说,我会优选使用 HTML 的报告,这样就可以直接用浏览器打开看了。如果你有工具需要其它格式的报告,也可以配置不同的格式。
生成报告的位置也是可以配置的,我在实战项目中,把它配置在
buildDir 指的是每个模块构建生成物的目录,一般来说,就是 build 目录。所以,每次当我看到因为测试覆盖率造成构建失败,就要就可以打开这个目录下的 index.html 文件,它会给你所有这个模块测试覆盖情况的总览。
在实战项目中,我们配置的覆盖率要求是 100%,所以,我们很容易就发现没有覆盖到的地方在哪里,就是那个有红色的地方。然后我们可以一路追踪进去,找到具体类,再找到具体的方法,最终定位到具体的语句,下面就是我们在实战中定位到的问题。
找到了具体的测试覆盖不足的地方,接下来,就是想办法提高测试率。一般来说,在简单的情况里通过增加或调整几个测试,就可以把这些场景覆盖到。但也有一些不是那么容易覆盖的,比如在实战中,我们看到 Jackson API 中抛出的 IOException。
不过,具体如何解决这个问题,对不同的同学来说,会有各自的解决方案。这个地方真正容易引起争议的地方是为什么测试覆盖率要设置成 100%。
在真实的项目中,很多不愿意写测试的人巴不得这个数字越低越好,但实际上我们也很清楚,这个数字设置得很低就没有任何意义了。
先不说一个既有的项目应该设成多少,如果是一个全新的项目,测试覆盖率应该设成多少呢?我在这里已经给出了我的答案:100%。这不是我为了这个实战故意设置的值,而是我在真实的项目中就是这样要求的。估计有人看到这个数字已经有一种快要疯了的感觉,在真实的项目中,设置成 100%怎么可能达到?
很多人对测试覆盖率的反对几乎是本能的,核心原因就是测试覆盖率是一个数字。本来这是一件好事,但是,很多管理者就会倾向于把它变成一个 KPI(Key Performance Indicator,关键绩效指标)。KPI 常常是上下级博弈的地方,上级希望高一点,下级希望低一点。所以,从本质上说,很多人对测试覆盖率的反对,首先是源于对 KPI 本能的恐惧。
抛开这种本能的恐惧,我们先来分析一下,如果我们想得到更高质量的代码,测试肯定是越多越好。那多到什么程度算最多呢?答案肯定是 100%。如果把测试覆盖率设置成 100%,就没有那么多扯皮的地方了。比如,你设成了 80%,肯定有人问为啥不设置成 85%;当你设置成 85%的时候,就会有人问为啥不是 90%,而且他们的理由肯定是一样的:测试覆盖率越高越好。那我设置成 100%,肯定不会有人再问为啥不设置成更高的。
现在你知道了,我们把覆盖率设置成 100% 这应该是极限的标准了。接下来,要回答的一个问题就是,怎么把覆盖率做成 100%。
首先,我们需要明确的一点是,我们用测试覆盖的代码主要是我们自己编写的代码。为什么要强调这一点呢?因为很多时候,我们会涉及使用第三方程序库,而第三方程序库的功能不应该由我们来验证。比如 Jackson 将对象转换为 JSON 是否转得正确,其实我们是不关心的,这是 Jackson 这个程序库要来保证的。
之所以要先强调这一点,因为在很多人编写的代码中,自己编写的业务代码和第三方程序库的代码常常是混杂在一起的。我们工作的重点是, 保证自己编写的代码 100% 测试覆盖。 这意味着什么呢?
首先,让自己可控的代码有完全的测试保证,其次,如果有第三方的代码影响到测试覆盖,我们应该把第三方的代码和我们的代码隔离开。
我知道,很多人已经准备强调 100%的测试覆盖是如何困难了。其实,不知道你有没有注意,我们在实战环节中,已经完成了一次 100%的测试覆盖。你可以去看看实战环节的构建脚本,其中用到的测试覆盖率工具就是 JaCoCo,而覆盖率的要求就是 100%,也就是 1.0。问题是我们是怎么做到的呢?
我们不妨一起回想一下,在做好了整体的设计之后,我们每实现一个具体的功能,都考虑了测试的场景,测试用例和代码是同步在实现。最后通过测试覆盖率检查,找出没有覆盖到的代码。对于一些不方便测试的第三方程序库代码,我们进行了隔离,而且要求隔离是非常薄的一层。这样,就保证了我们所有编写业务代码都能够很好地得到测试覆盖。
说起来并不复杂,但你或许会说,这是因为我们只实现了基本的功能,代码复杂度比较低,如果是实现了更为复杂的功能,是不是就没办法覆盖了呢?
我们在前面的内容中说过,要想写好测试,一个关键点是要有良好的软件设计,而且代码本身要尽可能地消除坏味道。到这里你就清楚了, 其实程序员写测试不单单是写测试,同时,也是在发现自己代码中的不足,无论是设计上,还是代码本身。
所以说,即便是再复杂的功能,通过软件设计和良好的编码,也可以落实到一个一个小代码块上。这里的重点是小,代码能否写短小,这是一个程序员编码基本功的问题。
你让我给一个长达几百上千的代码去写测试,我也很难做到 100%覆盖,因为代码写得太复杂了,我们理解起来很吃力,为它写测试当然也很吃力。所以,我们会把讨论先集中在一个新项目该如何写测试上。如果一个程序员不能够在干干净净的代码库上写好代码,你就很难指望他在面对一个遗留代码库时能够写好代码。
不知道你注意到了没有,我们说在实战中达成 100%测试覆盖时,还有一个工作习惯,就是测试和代码同步写。为什么要这么做呢?因为没有人愿意补测试,无论这个代码是你写的还是别人写的。
这也就是为什么要把测试放在自动化过程中,这样,我们每完成一个任务,就要确保编写了相应的测试。而且,我前面也强调过,任务的关键是小,比如,小到半个小时就可以提交一次,这样,你写测试的负担相对来说是小的。小事相比大事更容易坚持,这是符合人性的做法。
你现在已经知道了,一个新项目想要达到 100%的测试覆盖, 首先,要有可测试的设计,要能够编写整洁的代码;其次,测试和代码同步写。
关于 100%测试覆盖率,很多人有一个误区:100%覆盖了,是不是就意味着代码没问题了?答案是否定的。即便我们有了 100%的测试覆盖,还是会有你想不到的场景出现。100%的覆盖只是保证我们已经写的代码没有场景遗漏,不会有异常场景没有处理,不会有分支条件没有考虑到,仅此而已。
100%的测试覆盖只是程序员做好了本职工作,保证了在这个环节内没有出错。而软件整体质量是一个系统性的工程,首先要保证我们尽可能多地考虑到了各种测试场景。
对程序员来说,通过把测试覆盖率设置 100%,我们就有了一个查缺补漏的机会。一旦发现有些缺漏很难补上怎么办?就像我们在实战环节中见到的那样,模拟 Jackson 的异常成本过高,我们就会采用隔离的方式,将不好测试的地方隔离开来,形成一个封装层。实际上,我们是在用软件设计的方式在解决问题。
理解了达成 100%测试覆盖的基础之后,我还必须再强调一下。第一点是前面提到的封装层,这一层一定要非常薄。很多情况下,可能就是直接的方法调用。如果有复杂的逻辑,比如在防腐层代码中有对象之间的转换,我们都可以把转换的逻辑拿出来,单独地去写测试,因为这个转换逻辑多半是可以测试的。100%的测试覆盖率我们不是说说而已,而是要坚持做到能覆盖的尽量去覆盖。
另外还有一点,隔离出来的代码怎么办呢?我们要在测试覆盖的检查中将它们排除,具体的做法就是在构建文件中,把这个文件标记为不需要测试覆盖。
在我的项目中,我会要求这里只能有那个薄薄的封装层。有些初次接触项目的人,常常会把这里理解成项目中有我不想测的代码,却还要保证 100%测试覆盖,这里就是一种妥协。绝对不是这个意思!所以,一方面,我们要在团队中强调这个纪律,另一方面,我们也要经常性地做代码评审,保证这个用来隔离封装层的地方不会遭到滥用。
100%虽然要求很高,但要想做到,首先是理念上的认同,然后,我们就可以想各种办法去做到。在实际的项目中, 很多人先从理念去否定,认为不可能做到,只要有一点困难就放弃,这其实才是 100%测试覆盖率难以达成的最主要原因。
测试覆盖率是帮我们发现在测试中没有覆盖到的代码,也就是帮助我们在测试之外查缺补漏。
测试覆盖率实际上是一组不同指标的组合,所谓覆盖率就是运行一组测试,执行到的元素和总的元素比例。大部分指标都比较好理解,只是条件覆盖率要求比较高,与其通过测试覆盖那么多的条件,不如把代码本身写简单,降低测试的难度。
以 JaCoCo 为例,我们讲解了一个测试覆盖率工具,其中的 counter 对应着测试覆盖率的指标。在实际的项目中使用测试覆盖率工具,关键是要把它与自动化的过程结合起来,让它不是独立的存在。每次提交,每次 CI 过程都要进行测试覆盖率的检查。
某种意义上讲,100%测试覆盖率不是难事,当然100%的测试覆盖并不是说代码没有问题了,而应该是程序员对自己编写代码的一种质量保证,它是一个帮助我们查缺补漏的过程。
对于无法测试到第三方代码,要用一个薄薄的隔离层将代码隔离出去,在构建脚本中将隔离层排除在外。有一点需要注意的是,排除脚本千万别被滥用了。
将测试覆盖率的检查加入到自动化过程之中, 100%的测试覆盖率是程序员编写高质量代码的保证。