对于测试来说,通常代码覆盖率的统计,是一个比较重要的测试指标,但其实它仅仅是一个手段,并达不到完整保障质量的目的,比如在单元测试阶段,简单测试一下代码覆盖率,可以初步测试一些简单问题,后续具体测试当中,可以根据代码覆盖率的情况,来找是否测试用例没有覆盖全的地方,所以覆盖率显然越高越好,而越到后面,覆盖率提升的难度就越大,而且就算你号称覆盖率达到了100%,也不能认为就不需要再进行测试了,毕竟这里是代码覆盖率,假如某一个功能模块使用了一段开源算法,显然如果仅仅是覆盖了算法所有语句而设计出来的简单case可能根本就测试不出来任何问题,最终还是要结合具体的功能场景来构造复杂一些的数据,来校验系统的健壮性,因此代码覆盖率高不一定能说明测试覆盖得全,但是代码覆盖率低一定测试覆盖不全
JaCoCo是针对Java的一个开源代码覆盖率计算的工具,主流用法是通过agent以On-The-Fly的方式,runtime注入和生成字节码,进而统计数据,并不会改变class文件;网上都说通过CFR之类的反编译工具能够得到JaCoCo注入后的源码文件,但目前没搞定不清楚如何输出
比如有如下测试类
public class ScoreService {
public String getResult(int score) {
String result;
if (score >= 60) {
result = "A";
} else {
result = "B";
}
return result;
}
}
下面就通过testng编写测试类
@ContextConfiguration(classes = {ScoreService.class})
public class ScoreTest extends AbstractTestNGSpringContextTests {
@Autowired
private ScoreService scoreService;
@Test(description = "测试A")
public void testA() {
Assert.assertEquals(scoreService.getResult(90), "A");
}
}
这样就完成了一个简单的单元测试,测试内容就是及格分数的输出等级A,其实从这个小例子也可以看出,假如就以testA作为这一个分支的测试case是远远不够的,比如边界值60都没有进行测试,所以说覆盖率只能说明一些问题,但并不能代表所有
接下来就看下如何通过JaCoCo来统计这段测试覆盖率
这里工程基于maven和spring,因此只需要直接添加JaCoCo的maven依赖即可,其他ant和javaagent方式网上也都可以搜到
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>${jacoco.version}</version>
<executions>
<execution>
<goals>
<goal>prepare-agent</goal>
</goals>
</execution>
<execution>
<id>generate-code-coverage-report</id>
<phase>test</phase>
<goals>
<goal>report</goal>
</goals>
</execution>
</executions>
</plugin>
酱紫,就OVER了,十分简洁,接着只需要mvn clean test,在target目录下生成了一大堆,target/site/jacoco下面一堆HTML文件
打开index.html,就可以看到JaCoCo覆盖率的数据
这里有一些概念
1、Instructions:字节码,指令集覆盖 2、Branches:分支覆盖 3、Cxty:Cyclomatic Complexity,圈复杂度,计算在一个方法里最小路径数 4、Lines:行覆盖率,至少有一条指令执行过,这行认定被执行 5、Methods:方法覆盖率,至少有一条指令执行过,这个方法认定已执行 6、Classes:类覆盖率,class类是否被执行
继续将前面的Element的点开,可以看到就是我们被测试的类,和里面的getResult方法,很明显看到意思就是result = “A”这个分支已经覆盖了,但是result = “B”并没有覆盖
看到这里,比较好奇,JaCoCo是如何判断和统计的呢,实际上就是动态注入了探针,然后根据上面这6条规则,统计每处的覆盖情况
画一个简单的草图,来说明在不同的分支的时候,被各自的探针Probe检测到
因为程序字节码是一条一条指令集执行,在进入分支result = “A”的时候,那么另一个分支else就不可能执行,因此通过goto就能跳过
可以看到最后统计branches覆盖率50%,我的理解覆盖了if判断条件和满足条件的结果,而else条件和满足条件的结果没有覆盖,因此覆盖率是50%
这里有两个探针,result = “A”执行的时候,探针获取了true的次数;result = “B”执行的时候,由于下一条指令集就是分支结构结束,不需要goto,因此这里插了一个探针,统计这边分支的覆盖情况
或者这个也不是太清晰,很多人都直接查看注入JaCoCo之后反编译的代码,我这边google反编译的结果都没成功,就直接手写了大概的反编译结果
public class ScoreService {
private static transient boolean[] $jacocoData;
public ScoreService() {
boolean[] arrbl = ScoreService.$jacocoInit();
arrbl[0] = true;
}
public String getResult(int score) {
boolean[] arrbl = ScoreService.$jacocoInit();
String result;
if (score >= 60) {
arrbl[1] = true;
result = "A";
arrbl[2] = true;
} else {
result = "B";
arrbl[3] = true;
}
arrbl[4] = true;
return result;
}
}
看大概意思就行了;这里有一个boolean[]数组,只要执行了到不同的分支,就对boolean数组的值进行赋值,最后对数组的结果进行统计计算代码覆盖率
如此看来,统计覆盖率的时候,最关键的是,如何来添加探针
可以看下官网,不同类型如何添加探针指令
有了上面的简单流程,现在新增一个关于B这个分支的测试用例
@Test(description = "测试B")
public void testB() {
Assert.assertEquals(scoreService.getResult(75), "B");
}
这里抛出了个异常
[ERROR] Tests run: 2, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 1.486 s <<< FAILURE! - in com.lihuia.jacoco.service.ScoreTest
[ERROR] testB(com.lihuia.jacoco.service.ScoreTest) Time elapsed: 0.005 s <<< FAILURE!
java.lang.AssertionError: expected [B] but found [A]
at com.lihuia.jacoco.service.ScoreTest.testB(ScoreTest.java:26)
[SpringContextShutdownHook] DEBUG org.springframework.context.support.GenericApplicationContext - Closing org.springframework.context.support.GenericApplicationContext@7a3c99f1, started on Mon Jul 26 18:27:22 CST 2021
[INFO]
[INFO] Results:
[INFO]
[ERROR] Failures:
[ERROR] ScoreTest>AbstractTestNGSpringContextTests.run:184->testB:26 expected [B] but found [A]
[INFO]
[ERROR] Tests run: 2, Failures: 1, Errors: 0, Skipped: 0
显然是指testB这个testcase执行不通过,原因是断言失败,并没有生成JaCoCo报告很奇怪,按理说应该是没覆盖
接着,换一种想法,设计一个异常用例,让它走到else这个分支,但是断言失败
@Test(description = "测试B")
public void testB() {
Assert.assertEquals(scoreService.getResult(55), "C");
}
可是结果依旧是直接ERROR,未生成覆盖率报告
[ERROR] Tests run: 2, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 1.549 s <<< FAILURE! - in com.lihuia.jacoco.service.ScoreTest
[ERROR] testB(com.lihuia.jacoco.service.ScoreTest) Time elapsed: 0.023 s <<< FAILURE!
java.lang.AssertionError: expected [C] but found [B]
at com.lihuia.jacoco.service.ScoreTest.testB(ScoreTest.java:26)
[SpringContextShutdownHook] DEBUG org.springframework.context.support.GenericApplicationContext - Closing org.springframework.context.support.GenericApplicationContext@3ec082a1, started on Mon Jul 26 18:30:29 CST 2021
[INFO]
[INFO] Results:
[INFO]
[ERROR] Failures:
[ERROR] ScoreTest>AbstractTestNGSpringContextTests.run:184->testB:26 expected [C] but found [B]
[INFO]
[ERROR] Tests run: 2, Failures: 1, Errors: 0, Skipped: 0
我本来还以为,传入的参数55满足else条件,被测方法返回B,然后和期望的C不一样断言失败,但是这个被测方法的else分支已经覆盖了,因此覆盖率报告应该会生成,可惜没有,原因是mvn clean test失败了,就直接退出了
查了一下,可以加上-Dmaven.test.failure.ignore=true就不会中断
这样才符合预期,测试用例执行失败说明有异常,可以通过日志来获取;但是用例的确已经覆盖了两边的分支
从这个例子也可以看出来,代码覆盖率只能说对于测试能起一定的基本帮助,不能完全依靠;而测试场景的不断扩展,甚至测试代码本身的完善才能让真正的测试覆盖率越来越高
除了maven插件方式之外,还可以直接通过jacocoagent的方式启动
假如是IDE启动,VM参数如下:
-javaagent:/Users/lihui/Tool/jacoco/jacoco-0.8.7/lib/jacocoagent.jar=includes=*,output=tcpserver,port=8888,address=127.0.0.1,append=true
同时也可以直接命令行启动,相当于是启动一个server,这样就可以通过jacococli直接实时获取覆盖率,要注意的一点,命令行中间一串参数要加上引号
$ java "-javaagent:/Users/lihui/Tool/jacoco/jacoco-0.8.7/lib/jacocoagent.jar=includes=*,output=tcpserver,port=8888,address=127.0.0.1,append=true" -jar target/jacoco-1.0.jar
这里的参数
-javaagent:启动服务的时候,jacoco运行时通过agent注入探针到指定包路径 output:以tcpserver方式启动应用 port:启动tcpserver的端口 address:tcpserver对外暴露的访问地址
接着就通过jacococli.jar获取dump数据
$ java -jar /Users/lihui/Tool/jacoco/jacoco-0.8.7/lib/jacococli.jar dump --address 127.0.0.1 --port 8888 --destfile ./jacoco.exec [INFO] Connecting to /127.0.0.1:8888. [INFO] Writing execution data to /Users/lihui/data/./jacoco.exec.
最后生成覆盖率报告
$ java -jar /Users/lihui/Tool/jacoco/jacoco-0.8.7/lib/jacococli.jar report ./jacoco.exec --classfiles /Users/lihui/work/jacoco/target/classes/com --sourcefiles /Users/lihui/work/jacoco/src/main/java --html report --xml report.xml [INFO] Loading execution data file /Users/lihui/data/./jacoco.exec. [INFO] Analyzing 3 classes.
其中要注意的就是—classfiles是target下面classes路径下的com包名;但是我这样生成的覆盖率报告,并没有和上面maven一样得到正常的覆盖率统计数据
唯一一个有覆盖率的就是springboot启动的时候,调用了main方法和JacocoApplication()
此时,如果我主动调用其中一个方法做一下测试,比如com.lihuia.jacoco.data.DataService#sum,然后再拉一次jacoco的dump数据,生成报告
$ java -jar /Users/lihui/Tool/jacoco/jacoco-0.8.7/lib/jacococli.jar dump --address 127.0.0.1 --port 8888 --destfile ./jacoco.exec [INFO] Connecting to /127.0.0.1:8888. [INFO] Writing execution data to /Users/lihui/data/./jacoco.exec. $ java -jar /Users/lihui/Tool/jacoco/jacoco-0.8.7/lib/jacococli.jar report ./jacoco.exec --classfiles /Users/lihui/work/jacoco/target/classes/com --sourcefiles /Users/lihui/work/jacoco/src/main/java --html report --xml report.xml [INFO] Loading execution data file /Users/lihui/data/./jacoco.exec. [INFO] Analyzing 3 classes.
按理说,覆盖率会由于我调用了一次sum方法,而提升百分比,可是这里没有任何变化,暂时不清楚什么原因
想深入研究JaCoCo统计原理的,可以查看下面两个链接:
官方文档:https://www.jacoco.org/jacoco/trunk/doc/flow.html
JaCoCo计算代码覆盖率原理:https://segmentfault.com/a/1190000022259363