测试覆盖率 基于 Jacoco 的二次开发【解决不同版本 exec 数据合并问题】

alwans · 2021年08月10日 · 最后由 alwans 回复于 2023年02月10日 · 30812 次阅读
本帖已被设为精华帖!

概述

  对于 Jacoco 理想的使用场景:在测试阶段,能够实时统计手工测试的代码覆盖率情况

了解了 jacoco 的一些基本使用方法后,发现要满足这个使用场景,至少需要解决 2 个问题。

  • 类修改,带来的探针数据合并问题
  • 方法的修改,对探针数据造成的影响

下面讲讲具体问题以及解决思路

重点说明:以下实践使用普通 java 类测试,并且我所使用的是 JDK8。大于 JDK8 版本的插桩逻辑并不相同,如果是 JDK9 及以上版本,可能并不适用

问题一

  类修改,带来的探针数据合并问题

图 1

如图 1 所示,修改前的 Hello.java 文件,包含 3 个方法:A/B/C,修改后包含 4 个方法:A/B/C/D。
收集修改前 Hello 类的探针数据 (假设 A/B 方法已执行):dump1.exec
修改 Hello 中的 B 方法,然后重新收集探针数据 (假设 C/D 方法已执行):dump2.exec
dump1.exec 和 dump2.exec 的数据合并,想要合并后的覆盖率数据中包括:已被执行方法【A/C/D】,未被执行方法:【B】
合并 exec 数据使用 jacoco 的 merge 指令,merge 对于同一个类文件数据是否能合并的主要判断逻辑代码如下:

public void assertCompatibility(final long id, final String name,
            final int probecount) throws IllegalStateException {
 /**
 ////这里是我加的注释
同一个java文件,每次修改后对应生成的classId都是不一致的,
所以在这个地方就会被判断不通过,无法合并同一个java文件的统计数据
假设这里注释掉id的判断逻辑,继续往下执行
 */
        if (this.id != id) { 
            throw new IllegalStateException(
                    format("Different ids (%016x and %016x).",
                            Long.valueOf(this.id), Long.valueOf(id)));
        }
        if (!this.name.equals(name)) {
            throw new IllegalStateException(
                    format("Different class names %s and %s for id %016x.",
                            this.name, name, Long.valueOf(id)));
        }
/**
////还是我加的注释
如果上面的id判断逻辑注释掉,在这里面探针数组长度的时候还是会校验失败,
Hello.java文件修改后,新增了D方法,导致Hello类文件的探针数据长度是发生了变化,这里长度校验会失败;
假设没有新增D方法,同时假设数组长度刚好一致能够合并。但同时无法过滤掉修改前(dump1.exec)B方法的统计数据
所以仅仅注释掉id的判断逻辑是行不通的
*/
        if (this.probes.length != probecount) {
            throw new IllegalStateException(format(
                    "Incompatible execution data for class %s with id %016x.",
                    name, Long.valueOf(id)));
        }
    }

通过上面的代码注释,可以看出现有的 jacoco 合并逻辑无法满足在测试环境数据合并的需求。
我的解决方案是针对同一个 java 文件,按照方法作为颗粒度,切割类对应统计的探针数组,拿到各个方法的探针数据,再依次进行对应方法的数据合并。
图 2

如图 2 所示,只要切割拿到修改前后对应方法的探针数据,就能实现不同 class 版本收集的覆盖率数据合并。
关于如何切割,其实通过分析 jacoco-cli 工程中 report 指令,会发现按照方法切割很简单 (也可能是我考虑的太少....)
目前我还未完成这个合并功能,仅仅是找到了按照方法切割的思路,有兴趣的可以动手实践一下。
下面贴一下简单的示例代码图片:
org/jacoco/core/internal/flow/ClassProbesAdapter.java

org/jacoco/core/internal/flow/MethodProbesAdapter.java

我的 demo 类输出(这是我之前测试的截图,所以输出的和上面说的 Hello 文件不太一样):

问题二

  方法的修改,对探针数据造成的影响

目前我考虑到 2 种比较常见的情况。
第一种情况:
图 6

<问题描述>
如图 6 所示,ApiController 类中的 api 和 api2 方法都调用了 Services 类的 print 方法。
假设我们执行了 api 方法,在收集 (dump1.exec) 的覆盖率报告中,api 方法和 print 方法会显示已被执行。
然后修改 ApiController 类中的 api 方法,不执行任何方法,直接收集覆盖率数据 (dump2.exe)。然后合并 dump1 和 dump2,
这时候查看覆盖率报告,print 方法会显示已被执行。实际上,我认为 print 方法不应该被标记为已被执行。

<解决思路>
针对图 6 所描述的问题,利用函数调用链可以解决。api 调用了 print 方法,当 api 方法修改后,api 方法对应的覆盖率数据应被舍弃,那么 api 方法设计的整个调用链的数据都应该被舍弃

第二种情况:
图 7

<问题描述>
如图 7 所示,api 方法会执行 print 方法的 if 代码块以及” System.out.println(3);“输出语句。
api2 会执行 print 方法的 else 代码块及” System.out.println(3);“输出语句。
假设执行 api 和 api2 方法,收集覆盖率数据 (dump1.exec);
然后修改 api 方法,不执行任何方法,收集覆盖率数据 (dump2.exec);
按照函数调用链的解决思路合并 dump1 和 dump2。
那么这时候 print 方法的覆盖数据会丢失 (因为 print 方法被 api 调用,而 api 方法又被修改过)。
我认为较理想的合并结果是:api 方法被修改了所以覆盖率数据舍弃;api2 方法未修改所以覆盖率数据保留;
print 方法中 if 代码块是被 api 方法调用,所以 if 代码块的覆盖率数据舍弃。
else 代码块是被 api2 方法调用,所以 else 代码块覆盖率数据保留。
同时输出语句” System.out.println(3);“被 api 和 api2 均调用,所以覆盖率数据应保留。

<解决思路>
从上述的问题描述可以看出,仅仅是依赖函数调用链并不能达到我们想要的目的。
我们需要知道每个方法中,每一个探针包含的代码块具体被哪个方法执行过。
这句话涉及 2 个动作:调用者是谁、并且记录下来
想要的效果,如下图

总结一下,针对上述 2 种情况。我们需要实现函数调用链,并且知道每个方法的调用者是谁,
并在每个探针下面记录调用者的 URI。有了解决思路,剩下的就是实现就好了。
1.先定义一个节点类

public class ChainNode {

    private String uri;
    private ChainNode preNode; //链路上一级节点
    private ChainNode calledNode;  //调用者节点
}

2.通过 ASM 在每个方法开始和结束,记录节点信息,完成函数调用链的实现

public static void addChainNode(String uri){
        ChainNode currentNode = new ChainNode();
        currentNode.setUri(uri);
        //set headNode
        if(headNode.get() == null){
            headNode.set(currentNode);
        }
        //set preNode
        if(tailNode.get() != null){
            currentNode.setPreNode(tailNode.get());
        }
        if(calledNode.get() != null){
            currentNode.setCalledNode(calledNode.get());
        }
        calledNode.set(currentNode);
        tailNode.set(currentNode);
    }

public static void setCalledNode(String uri){
        if(uri.equals(headNode.get().getUri())){
            try{
                lock.lock();
                chainsSet.add(tailNode.get());
                headNode.set(null); //多线程情况下这个其实不用set为null
                tailNode.set(null);
                calledNode.set(null);
            }finally {
                lock.unlock();
            }
        }else{
            calledNode.set(calledNode.get().getCalledNode());
        }

    }

3.在每个探针下面添加一个 Set,用来存储调用者的 URI 信息

private void createSetInitMethod(final ClassVisitor cv,
                                     final int probeCount) {
        MethodVisitor mv = cv.visitMethod(InstrSupport.INITMETHOD_ACC,
                InstrSupport.INITSETMETHOD_NAME,
                InstrSupport.INITSETMETHOD_DESC, null, null);

        mv.visitCode();

        // [$jacocoSet_ref]
        mv.visitFieldInsn(Opcodes.GETSTATIC, className,
                InstrSupport.SET_DATA_FIELD_NAME, InstrSupport.SET_DATA_FIELD_DESC);

        // [$jacocoSet_ref, $jacocoSer_ref]
        mv.visitInsn(Opcodes.DUP);

        // [$jacocoSet_ref]
        final Label alreadyInitialized = new Label();
        mv.visitJumpInsn(Opcodes.IFNONNULL, alreadyInitialized);

        mv.visitInsn(Opcodes.POP);// []

        // [data_ref]
        mv.visitFieldInsn(Opcodes.GETSTATIC, InstrSupport.CLASS_UNKONW_ERROR,
                "$jacocoAccess", InstrSupport.OBJECT_DESC);

        // [data_ref, 3]
        mv.visitInsn(Opcodes.ICONST_3);

        // [data_ref, array_ref]
        mv.visitTypeInsn(Opcodes.ANEWARRAY, "java/lang/Object");

        // set classId
        mv.visitInsn(Opcodes.DUP);// [data_ref, array_ref, array_ref]
        mv.visitInsn(Opcodes.ICONST_0);
        mv.visitLdcInsn(Long.valueOf(classId)); 
        mv.visitMethodInsn(Opcodes.INVOKESTATIC, "java/lang/Long", "valueOf",
                "(J)Ljava/lang/Long;", false);
        mv.visitInsn(Opcodes.AASTORE); 

        // set className
        mv.visitInsn(Opcodes.DUP);
        mv.visitInsn(Opcodes.ICONST_1);
        mv.visitLdcInsn(className);
        mv.visitInsn(Opcodes.AASTORE);

        // set probeCount
        mv.visitInsn(Opcodes.DUP);
        mv.visitInsn(Opcodes.ICONST_2);
        InstrSupport.push(mv, probeCount);
        mv.visitMethodInsn(Opcodes.INVOKESTATIC, "java/lang/Integer", "valueOf",
                "(I)Ljava/lang/Integer", false);
        mv.visitInsn(Opcodes.AASTORE); // [runtimeData_ref, array_ref]

        mv.visitInsn(Opcodes.DUP_X1);

        // [array_ref, int] 
        mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL,
                InstrSupport.CLASS_RUNTIME_DATA, "generateCalledSetArray",
                "(Ljava/lang/Object;)Z", false);
        mv.visitInsn(Opcodes.POP);// [array_ref]

        // set array_ref = Set[]
        mv.visitInsn(Opcodes.ICONST_0); // [array_ref, 0]
        mv.visitInsn(Opcodes.AALOAD); // [obj_array_ref]

        //  [array_ref]
        mv.visitTypeInsn(Opcodes.CHECKCAST, "[Ljava/util/HashSet;");

        // [array_ref, array_ref]
        mv.visitInsn(Opcodes.DUP);

        // [array_ref]
        mv.visitFieldInsn(Opcodes.PUTSTATIC, className,
                InstrSupport.SET_DATA_FIELD_NAME,
                InstrSupport.SET_DATA_FIELD_DESC);

        // Return the class' probe array:
        if (withFrames) {
            mv.visitFrame(Opcodes.F_NEW, 0, FRAME_LOCALS_EMPTY, 1,
                    new Object[] { InstrSupport.SET_DATA_FIELD_DESC });
        }
        mv.visitLabel(alreadyInitialized);
        // []
        mv.visitInsn(Opcodes.ARETURN);

        mv.visitMaxs(Math.max(6, 2), 0); // Maximum local stack size is 2
        mv.visitEnd();

    }

最后看一下通过修改后的 jacoco 插桩后的 class 文件:

总结

通过上述解决思路,我认为是可以解决在测试过程中覆盖率数据的合并问题。
截止到发帖,暂时还未完全实现整个功能。在这里仅提供解决思路,如果大家感兴趣,可以一起多多尝试。
上述测试的主要是普通 class 文件,对 interface,enum,abstract 并未测试,并且 jacoco 的插桩策略和 jdk 版本有关的。
不同的 jdk 版本,jacoco 插桩的策略不同,我目前尝试基于 jdk8。

===========2021-09-07 更新=============

测试项目代码如下图:

第一次提交代码。发布应用,执行 test1,2,3 方法,第一次收集的覆盖率报告



修改 test1 方法,第二次提交代码。重新发布应用,执行 test4 方法,第二次收集的覆盖率报告



合并了上面 2 次不同版本代码的探针数据,生成的覆盖率报告,如下图

共收到 57 条回复 时间 点赞

点个赞,这个确实是解决代码变更后,覆盖率数据如何有效延续的一个很关键的策略。

我们之前比较简单粗暴,直接用 jacoco 的 merge 方法,所以导致有些 class 只是改变了一下代码,就导致整个 class 覆盖率被重置,确实是需要这样精细化管理。

陈恒捷 将本帖设为了精华贴 08月11日 19:40

复杂度挺高的 期待你具体实现,之前对修改的方法只考虑丢掉老的覆盖率数据以最新的为准,没有考虑调用链的问题

同样遇到这个问题,最近也打算研究一下😜

alwans #58 · 2021年08月26日 Author

9 月中旬应该就能完成覆盖率平台的开发工作了。然后会尽快分享出来😀

openj9 表示。。。。

alwans #56 · 2021年08月26日 Author
冯先生 回复

可以调试看看

期待楼主分享,最近团队也准备开展覆盖率统计相关的工作,但是由于测试环境目前发布比较频繁,多个覆盖率文件 merge 也存在楼主同样的问题

这个分享可以

alwans #53 · 2021年09月02日 Author
陈恒捷 回复

jacoco dump 指令为什么用 DataInputStream 而不用 ObjectInputStream。这个有了解过吗

alwans 回复

这块没研究这么细。

请教楼主个问题,例如图 1 的 Hello.java 的例子,做修改前,与修改后的方法切割,是不是修改前修改后要保留对应的 Hello.class,即如果有两个版本的相同类的不同 id 的 exec 数据,就要有对应的 2 份 class 文件?

alwans #50 · 2021年09月09日 Author
Alex 回复

是的。每次发布版本,我都会把当前版本的 java 文件和 class 文件存储在对应的目录下面。目录名字以 branc_commitId 这样命名

仅楼主可见

感谢大佬,膜拜

46楼 已删除

特地来赞一个,目前做精准化也遇到了这个问题,想着这个思路,谢谢分享,也确认了思路没错。看到一些说通过 className 来合并的帖子就来气~完全没去了解 merge 原理

除了这种方法,还可以尝试,jacoco merge + jdt 对 java 源文件进行分析,然后通过算法来进行基于代码行的覆盖率合并

alwans #43 · 2021年10月14日 Author
树叶小记 回复

可以分享下解决思路吗。目前我这种处理方式,还存在一些问题。例如函数调用链过长导致数据传输时出现栈溢出或者堆溢出 (对于这个问题,暂时也是用了非常规手段处理)。好处就是 jacoco 获取 函数调用链非常方便 (我理解是肯定比 jdt 静态分析方便且准确),对于后期来做精准测试以及流量回放的流量筛选都有很大帮助

alwans 回复

这个细节还挺多的 关键路径是这样的。1. 需要存一份最终结果数据( java 代码级别)2. 分同代码和不同代码, 同代码用 jacocomerge, 并最终放回 最终结果数据里面, 3. 如果是不同代码,先算出新代码级别的单次覆盖率数据, 再分别用 jdt 去格式化 新老 2 次 java 文件, 然后 直接对比就可以合出一整份基于新代码的完整覆盖率数据, 4. 之后就循环 1,2,3 就可以了

大佬搞定了吗

树叶小记 回复

大佬 有开源代码么

根据方法名来做探针存储还是有可能存在问题,比如方法名一样,但是入参不一样的情况。而且还有可能是,多个 classname 下重写了同一个 interface 里的方法,那方法名也会重复。这个楼主有没有什么思路

alwans #37 · 2022年01月06日 Author
6dingdong6 回复

探针是根据类来的,不是方法。而且你说的方法重载,形参不一样,所以没什么问题。关于 interface 我没理解你想表达的是哪部分

回复内容未通过审核,暂不显示

我有点疑问,问题二的第一情况,api 不再调用 print 方法,但是 api2 还是调用到,为什么 print 方法算是没被执行过呢?
即使 api 和 api2 都改成不调用 print 方法了,我觉得合并的时候 print 方法也应该算覆盖到了,因为 print 方法本身没有修改,修改的是 api 和 api2 这两个方法,这两个方法的覆盖率应该被显示未被执行

针对第二个问题,我的理解是哪个类的哪个方法被修改了,整个方法的覆盖率都清空,这样是不是就简单很多了~

@alwans 你好,我 code-diff 里面打包好的 javaagent.jar,挂到应用上启动会报错

但是我解压 javaagent.jar,发现这个文件是有的啊

alwans #32 · 2022年09月16日 Author
flystar 回复

我写的有问题,我后面优化。目前需要使用 includes 只增强你的工程代码,屏蔽掉三包类

alwans 回复

多谢,一举解决了困扰我多日的问题😂

flystar 回复

可以加你微信请教几个问题吗

alwans 回复


@alwans 楼主你好!我再使用 code-diff 生成报告时,BranchName CommitId 为空,这两个值是在哪儿设置的?

flystar 回复


@flystar 大佬!使用 code-diff 时 卡在 branchname 和 commitId 为空这一步了,请问遇到过这种情况吗?

alwans #27 · 2022年09月29日 Author
小易何 回复

jacoco 启动的时候,设置入参-javaagent:${SCRIPT_DIR}/jacocoagent.jar=includes=${JACOCO_INCLUDES},\
excludes=${JACOCO_EXCLUDES},output=tcpserver,address=0.0.0.0,port=${JACOCO_PORT},append=false,\
branchName=${BRANCH_NAME},commitId=${COMMIT_ID}

alwans 回复

感谢楼主。
这边增加参数 =includes=com.* 使用 lib 包里面的 agent 启动后遇到 spring 加载 Factory method 'dataSource' threw exception; nested exception is org.springframework.boot.autoconfigure.jdbc.DataSourceProperties$DataSourceBeanCreationException: Failed to determine a suitable driver class 数据库套件异常,但是使用官网 agent 能正常启动。楼主能帮忙分析下原因吗?还有一个奇怪的事情,这边启动服务端口为 8080 而并不是之前被测服务的端口

小易何 回复


以上是服务正常启动的结果


@alwans 楼主是不是 agent 的 jar 包没更新,启动后 dump 使用不了😂

alwans #23 · 2022年09月29日 Author
小易何 回复

等我重新上传一个 jar 包.启动失败问题是因为没有使用 includes 参数选项,这里有时间我来优化

alwans 回复

感谢楼主,jar 包最近有时间上传吗?

21楼 已删除
alwans 回复

楼主,麻烦问下,对于 栈溢出 这个问题,你这边是怎么处理的啊

alwans #19 · 2022年10月12日 Author
flystar 回复

用新包,我把调用链去除就不会栈溢出了。查看 jacoco 的 merge_1.0 分支,自己打包也可以

alwans 回复

具体的改动在哪些类里面啊?这个 jacoco 的 merge_1.0 分支在哪里,好像整个代码仓库只有一个 main 分支啊

楼主能方便加 V 聊吗?有些问题想请教你。。。。最后一步生成 report 的时候,没有 classfile 的 path

alwans 回复

楼主,这里是不是有个小 bug?
那现在就是不管调用链了吗?比如一个方法已经测过了,但是后来发版有改动,覆盖率上面就是算已经测过了,不会重置?

flystar 回复

merge1.0 在楼主的 jacoco 项目里

@alwans 我在 android demo 里运用这个二开的 jacoco 来插桩,出现了这个错误,麻烦能否帮忙看看

alwans #13 · 2022年11月17日 Author
connie chen 回复

用 merge1.0 分支,调用链相关代码去掉了

alwans 回复

如果调用链相关代码去掉,那问题二的 merge 就不能很好的解决了?

alwans #53 · 2022年11月21日 Author
connie chen 回复

暂时是这样的。现在的实现方式太粗糙了,落地会有一些问题,有时间会重写

alwans 回复

感谢回复,期待新的版本👍 👍

仅楼主可见

@alwans 楼主好!我这边目前遇到代码文件中内部类和匿名类的覆盖率合并的问题?想请教下怎么处理比较好?

1. 内部类的,

public class AnonymousClassTest {
    public class InnerClass {
        public int sum(int a, int b){
            return a + b;
        }
...

合并 sum 方法的时候因为解析的 classInfo.className 是 AnonymousClassTest, 按照目前逻辑会获取 AnonymousClassTest.class 进行分析,但是 sum 方法的插桩信息实际需要根据 AnonymousClassTest$InnerClass.class 来解析,会导致 sum 方法的覆盖率没有合并到。

想法:打算在解析 methodInfo 的时候记录下来方法真正所属的类。然后可以根据 AnonymousClassTest$InnerClass 去找 class 文件来解析合并

2. 匿名类的,

匿名类的名称并不固定,而且 classId 等信息也可能会变化,
请问能有什么方式准确拿到新旧 class 的对应关系吗?

alwans 回复

大佬,想请教下还有其他分支保留调用链处理的吗? 想学习下这块. 看最新的 merge_1.0 已经移除了.

上海story 回复

留个微信,我加你

Alex 回复

近期会重写调用链

wjxiao 回复

留个微信,想和你讨论下匿名内部类的问题

3楼 已删除
alwans 关闭了讨论 02月27日 11:17
需要 登录 后方可回复, 如果你还没有账号请点击这里 注册