JaCoCo:全量代码覆盖率统计

对于测试来说,通常代码覆盖率的统计,是一个比较重要的测试指标,但其实它仅仅是一个手段,并达不到完整保障质量的目的,比如在单元测试阶段,简单测试一下代码覆盖率,可以初步测试一些简单问题,后续具体测试当中,可以根据代码覆盖率的情况,来找是否测试用例没有覆盖全的地方,所以覆盖率显然越高越好,而越到后面,覆盖率提升的难度就越大,而且就算你号称覆盖率达到了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覆盖率的数据

NewImage

这里有一些概念

1、Instructions:字节码,指令集覆盖
2、Branches:分支覆盖
3、Cxty:Cyclomatic Complexity,圈复杂度,计算在一个方法里最小路径数
4、Lines:行覆盖率,至少有一条指令执行过,这行认定被执行
5、Methods:方法覆盖率,至少有一条指令执行过,这个方法认定已执行
6、Classes:类覆盖率,class类是否被执行

继续将前面的Element的点开,可以看到就是我们被测试的类,和里面的getResult方法,很明显看到意思就是result = “A”这个分支已经覆盖了,但是result = “B”并没有覆盖

看到这里,比较好奇,JaCoCo是如何判断和统计的呢,实际上就是动态注入了探针,然后根据上面这6条规则,统计每处的覆盖情况

画一个简单的草图,来说明在不同的分支的时候,被各自的探针Probe检测到

NewImage

因为程序字节码是一条一条指令集执行,在进入分支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数组的值进行赋值,最后对数组的结果进行统计计算代码覆盖率

如此看来,统计覆盖率的时候,最关键的是,如何来添加探针

可以看下官网,不同类型如何添加探针指令

NewImage

 

有了上面的简单流程,现在新增一个关于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就不会中断

NewImage

这样才符合预期,测试用例执行失败说明有异常,可以通过日志来获取;但是用例的确已经覆盖了两边的分支

从这个例子也可以看出来,代码覆盖率只能说对于测试能起一定的基本帮助,不能完全依靠;而测试场景的不断扩展,甚至测试代码本身的完善才能让真正的测试覆盖率越来越高

 

除了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一样得到正常的覆盖率统计数据

NewImage

唯一一个有覆盖率的就是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

发表评论