五、覆盖率统计
代码覆盖率是衡量单元测试有效性的一个指标,覆盖率又可以分为两个大类,即 「需求覆盖率」和「代码覆盖率」。
代码覆盖率的度量方式有以下几类:
1. 代码覆盖率的发展历史
Java 中比较流行的代码覆盖率工具有 EMMA, Cobertura,JaCoCo 等。其中 Emma 由于开发团队的原因已经停止更新,原团队目前专注于 JaCoCo 的开发和维护工作。有意思的是,EclEmma 和 JaCoCo 的官方网站是指向不同域名的同一个服务。
在 Java 领域有很多方法来收集代码覆盖度。下图展示了 JaCoCo 插件采用的技术(加色展示的部分)。
2. Jacoco 覆盖率统计过程
Jacoco 是 Java 领域目前比较主流的覆盖率统计工具,它的统计过程可以分为打桩、测试用例执行、覆盖率统计和覆盖率数据解析并生成报告。
(1)首先对需要统计覆盖率的 Java 代码进行插桩,植入覆盖率统计代码,有 On-The-Fly 和 Offline 两种方式。
(2)执行测试用例,通过用例运行收集执行轨迹信息,保存在内存中。
(3)JVM 退出前将覆盖率数据保存至磁盘(二进制)或通过网络传送出去 。
(4)解析覆盖率文件,将代码覆盖率报告图形化展示出来,如 html、xml 等文件格式。
3. JaCoCo 的 offline 与 on-the-fly
根据插入字节码的时机不同,可以将覆盖率工具的运行方式分为offline与on-the-fly两种:
offline 模式
offline 模式会对编译后的字节码文件进行插桩并覆盖源文件,在启动 JVM 时直接加载插桩后的字节码。
offline 模式会对编译后字节码源文件进行修改,所以对用户的影响最大,但是对性能的影响最小。
·优点是不需要运行环境支持 java agent,不会与其他 agent 冲突。
· 但需要添加 jacoco 编译字节码的 runtime 依赖。
on-the-fly 模式
在 JVM 加载类文件时,回调 javaagent 对字节码进行动态增强,植入覆盖率统计代码。
on-the-fly 模式会在应用启动时对加载进JVM的字节码文件进行插桩,不会改变用户的运行流程,只需要在JVM启动时配置 -javaagent 参数,更加无感。
· 优点是直接添加启动参数即可快速进行覆盖率统计和分析。
· 缺点是使用 javaagent 会降低一些启动速度以及 agent 冲突问题。
4. JaCoCo 与 Maven 集成
JaCoCo 提供了 maven 插件方便开发在项目中集成,提供了以下基本 goals,常用的包括 prepare-agent、report、instrument 和 restore-instrumented-classes。jacoco-maven-plugin 的 goals 与 Maven 生命周期的绑定关系如下:
validate
initialize .................. (prepare-agent 默认所属周期,注入 javaagent 参数)
generate-sources
process-sources
generate-resources
process-resources
compile
process-classes ............. (instrument 默认所属周期,offline 模式下对字节码插桩)
generate-test-sources
process-test-sources
generate-test-resources
process-test-resources
test-compile
process-test-classes
test ........................ (mvn test 执行的截止周期)
prepare-package ............. (restore-instrumented-classes 默认所属周期,offline 模式下恢复原始字节码)
package
pre-integration-test
integration-test
post-integration-test
verify ...................... (report 和 check 默认所属周期,report 用于生成覆盖率报告)
install
deploy
在默认的绑定关系中,当我们执行 mvn test 的时候,restore-instrumented-classes 和 report 默认不会被运行,因此为方便 offline 模式使用,我们需要修改下插件绑定的执行 phase,保证我们运行 mvn test 时可以正常运行,生成覆盖率报告。下面是两种不同模式的配置方案。
offline
instrument 和 restore-instrumented-classes 需要配套使用。
<dependencies>
<dependency>
<groupId>org.jacoco</groupId>
<artifactId>org.jacoco.agent</artifactId>
<version>0.8.6</version>
<classifier>runtime</classifier>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>2.16</version>
<dependencies>
<dependency>
<groupId>org.apache.maven.surefire</groupId>
<artifactId>surefire-junit4</artifactId>
<version>2.16</version>
</dependency>
</dependencies>
<configuration>
<systemPropertyVariables>
<jacoco-agent.destfile>${project.build.directory}/coverage.exec</jacoco-agent.destfile>
</systemPropertyVariables>
</configuration>
</plugin>
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.6</version>
<executions>
<execution>
<id>default-instrument</id>
<goals>
<goal>instrument</goal>
</goals>
</execution>
<execution>
<id>report</id>
<goals>
<goal>report</goal>
</goals>
<configuration>
<dataFile>${project.build.directory}/coverage.exec</dataFile>
</configuration>
</execution>
<execution>
<phase>test</phase>
<id>default-restore-instrumented-classes</id>
<goals>
<goal>restore-instrumented-classes</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
on-the-fly
aone 的默认测试插件默认采用这种模式。
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.6</version>
<executions>
<execution>
<goals>
<goal>prepare-agent</goal>
</goals>
</execution>
<execution>
<id>report</id>
<phase>prepare-package</phase>
<goals>
<goal>report</goal>
</goals>
</execution>
</executions>
</plugin>
为什么要改默认的 phase?
当我们使用 offline 模式运行 mvn test,如果按照默认的绑定关系,可能会遇到。
Cannot process instrumented class 某个类名. Please supply original non-instrumented classes。
原因是JaCoCo 的 instrument 在 process-classes 阶段将编译好的代码打桩用于统计代码覆盖率,然后需要在 restore-instrumented-classes 恢复原始字节码。
但是执行 mvn test 时只到 test,而 restore-instrumented-classes 绑定在 prepare-package 阶段,因此 mvn test 默认不会触发 restore-instrumented-classes ,第二次 mvn test 时会重复打桩,引起报错。
如果不改默认的 phase,则需要将 mvn test 改为 mvn verify 使用。verify 会运行 intergration-test 和 package 阶段,这两个阶段针对单元测试来说,不是十分必要。
目前 Aone 代码覆盖率主要基于 JaCoCo 的 on-the-fly 模式进行代码覆盖率采集,通过自定义测试构建过程,利用 CI 插件自动注入 jacoco-maven-plugin,无需用户自己添加。配置过程可以参考下文。
Aone 的各类测试任务底层复用了同一套执行引擎,类似 Maven 的 phase 和 goals,在 aone 中称为阶段和插件,每个阶段可以添加多个插件,例如下图在单元测试阶段,添加了代码 checkout 插件、codeconverage-unittest-pre(自动在 pom 文件中植入 JaCoCo 的 on-the-fly 配置)、单测和覆盖率解析插件。
aone 测试任务任务日志解析:
(1)codecoverage-unittest-pre 自动注入 jacoco-maven-plugin。
(11:00:12) Execute codecoverage-unittest-pre plugin
(11:00:13) ignore submodules.
(11:00:13) Execute plugin command
(11:00:13) cd /root/cise/space/135295299/plugin/codecoverage-unittest-pre && ./codecoverage-unittest-pre -d "/root/cise/space/135295299/source/pom.xml"
(11:00:13) Plugin unit-test code coverage pre processer begins.
(11:00:14) 2022-03-01 11:00:14 INFO PomUtils - pom file found:/root/cise/space/135295299/source/pom.xml
(11:00:14) 2022-03-01 11:00:14 INFO PomUtils - backup pom file success.
这里的日志还有两种情况:
1)pom 已经配置 jacoco。
当我们出于各种情况,例如对插件版本、运行阶段或者为了解决覆盖率统计问题,自行配置了 jacoco 插件后,aone 便不会再自动注入。
Execute codecoverage-unittest-pre plugin
(11:00:24) ignore submodules.
(11:00:24) Execute plugin command
(11:00:24) cd /root/cise/space/135294989/plugin/codecoverage-unittest-pre && ./codecoverage-unittest-pre -d "/root/cise/space/135294989/source/buc.acl.shared.parent/pom.xml"
(11:00:25) Plugin unit-test code coverage pre processer begins.
(11:00:25) 2022-03-01 11:00:25 INFO PomUtils - pom file found:/root/cise/space/135294989/source/buc.acl.shared.parent/pom.xml
(11:00:25) 2022-03-01 11:00:25 INFO PomUtils - backup pom file success.
(11:00:25) 2022-03-01 11:00:25 ERROR PomUtils - pom.xml contains jacoco-maven-plugin;no need to modify pom.
(11:00:25) 2022-03-01 11:00:25 INFO PomUtils - contains jacoco plugin,no need to modify pom.
2)pom 中缺少 标签提示 jacoco.exec not found。
(16:41:13) Execute codecoverage-unittest-pre plugin
(16:41:14) ignore submodules.
(16:41:15) Execute plugin command
(16:41:15) cd /root/cise/space/135189936/plugin/codecoverage-unittest-pre && ./codecoverage-unittest-pre -d "/root/cise/space/135189936/source/pom.xml"
(16:41:15) Plugin unit-test code coverage pre processer begins.
(16:41:15) 2022-02-28 16:41:15 INFO PomUtils - pom file found:/root/cise/space/135189936/source/pom.xml
(16:41:15) 2022-02-28 16:41:15 INFO PomUtils - backup pom file success.
(16:41:15) 2022-02-28 16:41:15 ERROR PomUtils - <build> element not found.
(2)JaCoCo 插件在 prepare-agent 阶段注入 javaagent 参数。
(11:00:18) [INFO] --- jacoco-maven-plugin:0.8.7:prepare-agent (jacoco-initialize) @ ---
(11:00:18) [INFO] Downloading ...
(11:00:18) [INFO] Downloaded ...
(11:00:20) [INFO] argLine set to -javaagent:/root/.m2/repository/org/jacoco/org.jacoco.agent/0.8.7/org.jacoco.agent-0.8.7-runtime.jar=destfile=/root/cise/space/135295299/source/target/jacoco.exec
这里可能会出现另一种情况,日志出现,但实际运行进程中,并没有 javaagent 参数,导致覆盖率无法统计。
grep 进程排查
执行 mvn test 过程中,日志中看到 case 已经在running中时,查看进程。
ps -ef | grep java
找到测试对应的进程,观察完整的进程命令,如果jacoco生效了会看到下图,如果没有生效,则ps -ef | grep java | grep jacoco看不到任何进程。
测试运行时为什么会没有 JaCoCo 这个 javaagent ?
我们通常会配置maven-surefire-plugin去跑测试。
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<argLine>"some jvm args"</argLine>
</configuration>
</plugin>
</plugins>
当 surefire 配置运行参数后,mvn test 时其他插件就无法自动添加更多的 JVM 运行参数,比如 jacoco 插件。argLine的正确写法如下:
<argLine>${argLine} "some jvm args"</argLine>
(3)覆盖率报告解析
实验室是通过获取运行的标准输出,解析后得到运行结果。因此可以通过在脚本、插件执行过程中按照相应的格式输出内容,达到统计运行结果以及定义页面展现的目的。
(16:56:13) INTEGRATION_COVERAGE: {"appName":"mozi-im-gateway-private","branchCovered":0,"branchRatio":0.0,"branchTotal":30,"buildId":125846455,"classCovered":0,"classRatio":0.0,"classTotal":17,"coverageRecordId":9579151,"env":"local","lineCovered":0,"lineRatio":0.0,"lineTotal":185,"methodCovered":0,"methodRatio":0.0,"methodTotal":75,"name":"buc.login.spi","nodeType":"JAR","parent":"mozi-im-gateway-private","type":"unnit-test","updated":0}
(16:56:13) INTEGRATION_COVERAGE: {"appName":"mozi-im-gateway-private","branchCovered":0,"branchRatio":0.0,"branchTotal":38,"buildId":125846455,"classCovered":0,"classRatio":0.0,"classTotal":5,"coverageRecordId":9579151,"env":"local","lineCovered":0,"lineRatio":0.0,"lineTotal":124,"methodCovered":0,"methodRatio":0.0,"methodTotal":17,"name":"buc.sso.application","nodeType":"JAR","parent":"mozi-im-gateway-private","type":"unnit-test","updated":0}
(16:56:13) INTEGRATION_COVERAGE: {"appName":"mozi-im-gateway-private","branchCovered":49,"branchRatio":0.0016,"branchTotal":29792,"buildId":125846455,"classCovered":20,"classRatio":0.0103,"classTotal":1934,"coverageRecordId":9579151,"env":"local","lineCovered":374,"lineRatio":0.0048,"lineTotal":77160,"methodCovered":115,"methodRatio":0.0069,"methodTotal":16618,"name":"mozi-im-gateway-private","nodeType":"APP","type":"unnit-test","updated":1,"updatedLineCovered":76,"updatedLineTotal":381,"updatedRatio":0.1995}
(16:56:13) *************************************************************
(16:56:13) CODE_COVERAGE_LINES: 374/77160
(16:56:13) CODE_COVERAGE_NAME_LINES: 行
(16:56:13) CODE_COVERAGE_BRANCHES: 49/29792
(16:56:13) CODE_COVERAGE_NAME_BRANCHES: 分支
(16:56:13) CODE_COVERAGE_METHODS: 115/16618
(16:56:13) CODE_COVERAGE_NAME_METHODS: 方法
(16:56:13) CODE_COVERAGE_CLASSES: 20/1934
(16:56:13) CODE_COVERAGE_NAME_CLASSES: 类
(16:56:13) CODE_COVERAGE_REPORT_LINES:http://test.aone.alibaba-inc.com/coverages/9579151
(16:56:13) CODE_COVERAGE_REPORT_BRANCHES:http://test.aone.alibaba-inc.com/coverages/9579151
(16:56:13) CODE_COVERAGE_REPORT_METHODS:http://test.aone.alibaba-inc.com/coverages/9579151
(16:56:13) CODE_COVERAGE_REPORT_CLASSES:http://test.aone.alibaba-inc.com/coverages/9579151
(16:56:13) CODE_COVERAGE_UPDATELINES: 76/381
(16:56:13) CODE_COVERAGE_NAME_UPDATELINES: 行增量
(16:56:13) CODE_COVERAGE_REPORT_UPDATELINES: http://test.aone.alibaba-inc.com/coverages/9579151
(16:56:13) *************************************************************
(16:56:13) Execute case_result_parser plugin
(16:56:15) ignore submodules.
(16:56:15) Execute plugin command
(16:56:15) cd /root/cise/space/135192024/plugin/case_result_parser && ./parser -p "/root/cise/space/135192024/source" -d "false" -t "common" -u "https://testservice.aone.alibaba-inc.com/ak/testservice/api/pipelineResult/utApiCallBack/build/607b495e-3d6d-4a8d-a18f-3cdcd226f860/stage/单元测试"
(16:56:15) /root/cise/space/135192024/plugin/case_result_parser
(16:56:15) [INFO] Project: /root/cise/space/135192024/source
(16:56:15) [INFO] Image: /root/cise/space/135192024/source/images/-815718685
(16:56:15) [INFO] Start to parse
(16:56:16) [INFO] End parsing
(16:56:16) [INFO] Parsed test case count:53
(16:56:16) [INFO] Paased count: 53
(16:56:16) [INFO] Failed count: 0
(16:56:16) TEST_CASE_AMOUNT: {"blocked":0,"passed":53,"failed":0,"skipped":0}
(16:56:16) [INFO] Callback url: https://testservice.aone.alibaba-inc.com/ak/testservice/api/pipelineResult/utApiCallBack/build/607b495e-3d6d-4a8d-a18f-3cdcd226f860/stage/单元测试
(16:56:16) [INFO] Max data size: 45000000
(16:56:16) [INFO] Current data size: 8159
(16:56:16) log4j:WARN No appenders could be found for logger (org.apache.commons.httpclient.HttpClient).
(16:56:16) log4j:WARN Please initialize the log4j system properly.
(16:56:16) [INFO] Send result to server, time: 01
(16:56:17) [INFO] Response: {"success":true,"messages":[],"result":"https://aone.alibaba-inc.com/pipeline/build/detail?buildDetailId=156703515&pipelineId=2905023","errorCode":null,"other":null,"msgCode":null,"msgInfo":null,"message":""}
(16:56:17) TEST_REPORT: https://aone.alibaba-inc.com/pipeline/build/detail?buildDetailId=156703515&pipelineId=2905023
如果实在定位不到问题,可以看源码了解,可以看到 jacoco 插件是如何被植入 pom 文件的。
5.JaCoCo 插桩和覆盖率统计逻辑
Jacoco 的插桩原理是通过在方法表的 Code 区插入与代码覆盖率相关的执行代码,Jacoco 就可以感知字节码的执行过程。简单来讲,Jacoco 会对字节码文件进行四个修改:
·为类增加 $jacocoData 属性;
· 为类增加 $jacocoInit 方法;
· 在每个方法开头创建一个 boolean 类型的数组,JaCoCo 利用这个数组来实现探针(Probe);
· 每行代码都会有一个探针对应到此数组中,运行时修改 boolean 数组中的项来实现探针。
在代码执行完对应 statement 后,会将数组对应探针位置修改为true。最终 Jacoco 通过各个类的探针数组数据计算代码覆盖率。
Jacoco 插桩后的字节码文件示例如下:
JacocoProbeTest.class
/* synthetic */
private static transient boolean[] $jacocoData;
// $jacocoInit 是 jacoco 生成的代码,以下为近似逻辑
private static /* synthetic */ boolean[] $jacocoInit() {
if ($jacocoData != null) {
return $jacocoData;
}
{
Object[] args = new Object[3];
// class id
args[0] = Long.valueOf(8060044182221863588);
// class name
args[1] = "com/example/JacocoProbeTest"; // probecount
args[2] = Integer.valueOf(4);
// Jacoco 的特殊方法,会修改args[0] 的值
new RuntimeData().equals(args);
$jacocoData = (boolean[])args[0];
}
return $jacocoData;
}
}
// 覆盖率统计,执行一行代码,即将 Probe 数组对应位置置位 true
public JacocoProbeTest() {
boolean[] b_arr_1 = JacocoProbeTest.$jacocoInit();
b_arr_1[0] = true;
}
public static void testSingleLineProbe() {
boolean[] b_arr_1 = JacocoProbeTest.$jacocoInit();
System.out.println("testSingleLineProbe");
b_arr_1[1] = true;
}
public static void testThrowExceptionProbe() {
boolean[] b_arr_1 = JacocoProbeTest.$jacocoInit();
System.out.println("testThrowExceptionProbe");
b_arr_1[2] = true;
b_arr_1[3] = true;
throw new IllegalArgumentException();
}
下面是 intellij-coverage-agent 覆盖率插桩的代码,它的埋点比起 jacoco 埋点使用了行号进行记录。
源代码:
插桩后的代码:
@Test
public void testGetHiddenMenuCodes() {
Object __class__data__ = ProjectData.loadClassData("com.alibaba.buc.acl.data.service.manage.impl.com.alibaba.buc.acl.data.service.manage.impl.ActionManageServiceImplTest");
ProjectData.touchLine(__class__data__, 140);
Set<String> hiddenMenuCodes = this.actionManageService.getHiddenMenuCodes(this.tenantId, this.appName);
ProjectData.touchLine(__class__data__, 142);
Assert.assertTrue(hiddenMenuCodes.containsAll(Arrays.asList(this.hiddenMenuCodes, "permissionManageGroup", "businessStripConfig", "divisionConfig", "stripOrgManager", "cooperationApplication", "cooperationList", "cooperationManagement")));
ProjectData.touchLine(__class__data__, 153);
}
6.JaCoCo 与 PowerMock 的冲突原理分析
下面是我在网络上找到的一段关于 JaCoCo 与 PowerMock 的冲突分析的说明。
JaCoCo 以 Javaagent 方式启动时,会为每一个加载进 JVM 的类插桩埋点。而 PowerMock 使用的 javaassist 接收到的某个类的字节码数据(原始字节码已经被其他非 javassist 的方式增强之后的字节码)和此类对应的 class 文件的数据不一致时,javassist 就会废弃掉接收到的已经被改变的字节码数据,转而使用此类最原始的 class 文件进行增强。这就导致 JaCoCo 埋入类字节码中的监控点被覆盖,导致其无法感知到被重新加载的类的执行过程,使得类的代码覆盖率结果为 0。
这段话有没有问题呢?真的是覆盖率统计代码被覆盖吗?
进一步想一想,为什么在 IDEA 中运行时可以统计到代码覆盖率? 而在 Aone 却又未覆盖?
提示 1:PowerMock 调用 javaassist 修改类后是不是会再次触发 javaagent 的修改。
提示 2:IDEA 运行覆盖率统计时,默认采用 intellij-converage-agent。
提示 3:jacoco 生成覆盖率报告的方式,ClassID 。
jacoco.exec 是记录覆盖率的二进制文件,有时候会命名为 coverage.exec。
先从 jacococli 命令行生成报告的参数做个推理。
参数中指定了覆盖率报告、类的字节码以及输出目录,可以推测 JaCoCo 会根据原始字节码与覆盖率数组做匹配。在 jacocoagent 和 PowerMock 同时使用的情况下,jacoco.exec 中记录的 ClassID 是两次增强后的字节码计算的结果,生成覆盖率报告时,根据原始字节码计算出的 ClassID 无法匹配 jacoco.exec 中的记录,直接判断为 noMatch,因此未生成有效的覆盖率报告。
这段计算逻辑可以在分析覆盖率报告的这个方法中看到[3],有兴趣可以下载源码分析。
jacoco 的官网中已经解释了为什么要使用字节码生成的 ClassID 作为类标识。
解决方案
目前比较主流的解决方法为使用 JaCoCo 的 offline 模式先对字节码文件进行插桩。在应用启动时加载进 JVM 的字节码文件就不需要动态插桩了,可以被 Powermock 正常增强。但是这种方式需要修改字节码文件,对业务的执行与发布过程有影响,需要测试完成后进行二次部署。
优化方向
因为 JaCoCo 生成覆盖率报告时依赖源码和原字节码,而 intellij-coverage-agent 则不需要,原因是它的插桩代码自带行数,因此不需要对照源码和原字节码进行分析,或许可以参考修改 JaCoCo 的插桩代码,但这比起修改一个数组的插槽,会带来一些性能损耗。
本文内容不用于商业目的,如涉及知识产权问题,请权利人联系51Testing小编(021-64471599-8017),我们将立即处理