软件测试TesterHome测试之家

基于 Jacoco 的二次开发【解决不同版本 exec 数据合并

2022-01-19  本文已影响0人  TesterHome

概述


对于 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 对于同一个类文件数据是否能合并的主要判断逻辑代码如下:

publicvoidassertCompatibility(finallongid,finalStringname,finalintprobecount)throwsIllegalStateException{/**

////这里是我加的注释

同一个java文件,每次修改后对应生成的classId都是不一致的,

所以在这个地方就会被判断不通过,无法合并同一个java文件的统计数据

假设这里注释掉id的判断逻辑,继续往下执行

*/if(this.id!=id){thrownewIllegalStateException(format("Different ids (%016x and %016x).",Long.valueOf(this.id),Long.valueOf(id)));}if(!this.name.equals(name)){thrownewIllegalStateException(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){thrownewIllegalStateException(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.先定义一个节点类

publicclassChainNode{privateStringuri;privateChainNodepreNode;//链路上一级节点privateChainNodecalledNode;//调用者节点}

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

publicstaticvoidaddChainNode(Stringuri){ChainNodecurrentNode=newChainNode();currentNode.setUri(uri);//set headNodeif(headNode.get()==null){headNode.set(currentNode);}//set preNodeif(tailNode.get()!=null){currentNode.setPreNode(tailNode.get());}if(calledNode.get()!=null){currentNode.setCalledNode(calledNode.get());}calledNode.set(currentNode);tailNode.set(currentNode);}publicstaticvoidsetCalledNode(Stringuri){if(uri.equals(headNode.get().getUri())){try{lock.lock();chainsSet.add(tailNode.get());headNode.set(null);//多线程情况下这个其实不用set为nulltailNode.set(null);calledNode.set(null);}finally{lock.unlock();}}else{calledNode.set(calledNode.get().getCalledNode());}}

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

privatevoidcreateSetInitMethod(finalClassVisitorcv,finalintprobeCount){MethodVisitormv=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]finalLabelalreadyInitialized=newLabel();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 classIdmv.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 classNamemv.visitInsn(Opcodes.DUP);mv.visitInsn(Opcodes.ICONST_1);mv.visitLdcInsn(className);mv.visitInsn(Opcodes.AASTORE);// set probeCountmv.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,newObject[]{InstrSupport.SET_DATA_FIELD_DESC});}mv.visitLabel(alreadyInitialized);// []mv.visitInsn(Opcodes.ARETURN);mv.visitMaxs(Math.max(6,2),0);// Maximum local stack size is 2mv.visitEnd();}

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

总结


通过上述解决思路,我认为是可以解决在测试过程中覆盖率数据的合并问题。

截止到发帖,暂时还未完全实现整个功能。在这里仅提供解决思路,如果大家感兴趣,可以一起多多尝试。

上述测试的主要是普通 class 文件,对 interface,enum,abstract 并未测试,并且 jacoco 的插桩策略和 jdk 版本有关的。

不同的 jdk 版本,jacoco 插桩的策略不同,我目前尝试基于 jdk8。

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


测试项目代码如下图:

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

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

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

alwans首发于TesterHome社区「测试覆盖率」节点

上一篇下一篇

猜你喜欢

热点阅读