乐趣区

关于测试开发:基于Jacoco-的二次开发解决不同版本-exec-数据合并问题

概述

对于 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 次不同版本代码的探针数据,生成的覆盖率报告,如下图

退出移动版