Jacoco和代码反射引起的化学反应

1,140 阅读2分钟

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 $jacocoData and 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

成功执行,不再报错。问题解决。