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

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

概述

  对于 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 条回复 时间 点赞
alwans 关闭了讨论 02月27日 11:17
61楼 已删除
alwans #60 · 2023年02月10日 Author
wjxiao 回复

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

alwans #59 · 2023年02月08日 Author
Alex 回复

近期会重写调用链

alwans #58 · 2023年02月08日 Author
上海story 回复

留个微信,我加你

alwans 回复

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

@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 回复

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

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

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

alwans 回复

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

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

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

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

flystar 回复

merge1.0 在楼主的 jacoco 项目里

alwans 回复

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

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

alwans 回复

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

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

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

alwans 回复

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

43楼 已删除
alwans 回复

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

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

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


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

小易何 回复


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

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 #37 · 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}

flystar 回复


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

alwans 回复


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

flystar 回复

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

alwans 回复

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

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

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

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

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

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

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

回复内容未通过审核,暂不显示
alwans #27 · 2022年01月06日 Author
6dingdong6 回复

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

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

树叶小记 回复

大佬 有开源代码么

大佬搞定了吗

alwans 回复

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

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

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

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

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

18楼 已删除

感谢大佬,膜拜

仅楼主可见
alwans #14 · 2021年09月09日 Author
Alex 回复

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

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

alwans 回复

这块没研究这么细。

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

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

这个分享可以

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

冯先生 回复

可以调试看看

openj9 表示。。。。

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

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

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

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

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

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

需要 登录 后方可回复, 如果你还没有账号请点击这里 注册