Jacoco和代码反射引起的化学反应
在写单元测试的时候,在IDEA里面单元测试跑得好好的,但是一旦到了CI,就开始报错,各种异常,一直没有跑出相应的指标,令人百思不得其解。
无奈,只能在本地环境使用mvn clean test复现。
首先我们在IDEA中执行Run with Test:
Tests passed:25 of 25 tests - 10sec 885ms
25个Case都是成功
然后我们使用maven去执行mvn clean test会出现以下错误:
[ERROR] AccessTokenControllerUnitTest.testGetNewToken:72 » ArrayIndexOutOfBounds 1
[ERROR] FundPerformanceControllerUnitTest.testGetAumHistory:108 » MockDataRuntime za.g...
[ERROR] FundPerformanceControllerUnitTest.testGetFundDividend:136 » MockDataRuntime za...
[ERROR] FundPerformanceControllerUnitTest.testGetFundHistoricalYield:178 » MockDataRuntime
[ERROR] FundPerformanceControllerUnitTest.testGetFundYearReturn:164 » MockDataRuntime ...
[ERROR] FundPerformanceControllerUnitTest.testGetHistoryNav:80 » MockDataRuntime java....
[ERROR] FundPerformanceControllerUnitTest.testGetQuarterReturn:150 » MockDataRuntime z...
[ERROR] Tests run: 9, Failures: 0, Errors: 4, Skipped: 0, Time elapsed: 1.694 s <<< FAILURE! - in xxx.xxx.controller.FundPerformanceControllerUnitTest
[ERROR] testGetHistoryNavIndex(xxx.xxx.controller.FundPerformanceControllerUnitTest) Time elapsed: 0.005 s <<< ERROR!
xxx.xxx.xxx.mock.data.exception.MockDataRuntimeException: java.lang.ArrayIndexOutOfBoundsException: 15
at xxx.xxx.controller.FundPerformanceControllerUnitTest.testGetHistoryNavIndex(FundPerformanceControllerUnitTest.java:94)
Caused by: java.lang.ArrayIndexOutOfBoundsException: 15
at xxx.xxx.controller.FundPerformanceControllerUnitTest.testGetHistoryNavIndex(FundPerformanceControllerUnitTest.java:94)
[ERROR] testGetCategoryPerformance(xxx.xxx.controller.FundPerformanceControllerUnitTest) Time elapsed: 0.003 s <<< ERROR!
xxx.xxx.xxx.mock.data.exception.MockDataRuntimeException: xxx.xxx.xxx.mock.data.exception.MockDataRuntimeException: java.lang
观察发现,我们发生错误的都是测试类,都有使用一个MockDataCreator的模拟数据创建工具。这个工具是用来快速Mock工具的自研工具。但是作为一个工具类,其实里面并没有任何依赖,本身只是一个静态工具类。
反复观察里面的错误,发现大部分都是java.lang.ArrayIndexOutOfBoundsException,但是对于Mock数据来说,并没有出现数组对象,如何来的java.lang.ArrayIndexOutOfBoundsException。这是一个问题。
最后通过编译生成的class类发现了端倪。这里需要了解一个背景,Jacoco的原理是怎么工作和统计的?Jacoco会在编译阶段,通过agent的方式,动态生成字节码,并且在类中插入探针。然后靠方法执行的探针来统计执行的行数。
了解了探针,我们再来看看在IDEA中,它是如何执行的。当你点击了Run as Test,IDEA执行的命令其实是:
java.exe -Djacoco-agent.destfile=xxx -javaagent:xxxidea_rt.jar=28529 ...
我们可以通过上面的命令发现,在IDEA中,它执行的方式是通过Java agent的方式,也就是jacoco中的on-the-fly(在线模式)。而我们在maven中执行,mvn clean test的时候,是通过maven plugin的方式触发的。知道两者差别就可以细看,我们在maven中是否有特殊的配置。查看pom文件可得:
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>${jacoco.version}</version>
<executions>
<execution>
<id>default-instrument</id>
<goals>
<goal>instrument</goal>
</goals>
</execution>
<execution>
<id>default-restore-instrumented-classes</id>
<goals>
<goal>restore-instrumented-classes</goal>
</goals>
</execution>
<execution>
<id>report</id>
<phase>prepare-package</phase>
<goals>
<goal>report</goal>
</goals>
<configuration>
<dataFile>${project.build.directory}/coverage.exec</dataFile>
</configuration>
</execution>
</executions>
</plugin>
上面的文件可以获知,我们在maven中所指定的Jacoco是offline,也就是离线模式。
Jacoco提供on-the-fly和offline两种模式来适用于各种环境,但是两者最大的差别就是:
-
On-the-fly是在JVM执行字节码之前动态对其进行修改
-
Offline在Java程序字节码文件(.class文件)生成之前进行修改
这么理解可能不太清楚,举个详细的例子:
在On-the-fly模式下,编译之后的类长下面这样子:
public class MSYearReturnDetail implements Serializable {
private static final long serialVersionUID = 213056617614403832L;
private String year;
private String value;
public MSYearReturnDetail() {
}
public String getYear() {
return this.year;
}
}
而在Offline模式下,同一个类会变成下面的样子:
public class MSYearReturnDetail implements Serializable {
private static final long serialVersionUID = 213056617614403832L;
private String year;
private String value;
public MSYearReturnDetail() {
boolean[] var1 = $jacocoInit();
super();
var1[0] = true;
}
public String getYear() {
boolean[] var1 = $jacocoInit();
var1[1] = true;
return this.year;
}
}
你会发现,多了一个叫做$jacocoInit()的函数,这就是刚才上文所提到的探针。
探针是Jacoco在每个类中植入一个静态对象:$jacocoData和一个静态方法: $jacocoInit()
按道理说,offline模式是会单独生成一个测试编译的输出目录。在编译后执行单元测试不应该报MockDataCreator的错误才对。
通过查询资料,发现在官方的github中有这么一个issue:
ArrayIndexOutOfBoundsException by runnig unit tests with jacoco plugin #799
里面有开发者所提示的F&Q:
My code uses reflection. Why does it fail when I execute it with JaCoCo?
To collect execution data JaCoCo instruments the classes under test which adds two members to the classes: A private static field
$jacocoDataand a private static method$jacocoInit(). Both members are marked as synthetic.Please change your code to ignore synthetic members. This is a good practice anyways as also the Java compiler creates synthetic members in certain situation.
啥意思呢,就是当你的类使用了反射,你应该在反射的时候忽略掉$jacocoData
那就破案了,在MockDataCreator里面,是通过反射来获取所有字段,并且判断字段类型来生成对应的mock数据。这里面就用到了:getDeclaredFields()
List<Field> resultList = new ArrayList<>();
for(Field field : allFieldList) {
String fieldName = field.getName();
// 跳过序列化字段
if(SERIAL_VERSION_UID.equals(fieldName)) {
continue;
}
field.setAccessible(true);
resultList.add(field);
}
return resultList;
这里面之前只忽略了serialVersionUID,通过官方文档的指引,再次忽略jacocoData才是正解。
编译,打包,重新执行,现在重新执行 mvn clean test,此时出现的是:
[INFO] Results:
[INFO]
[INFO] Tests run: 25, Failures: 0, Errors: 0, Skipped: 0
成功执行,不再报错。问题解决。