概述
對(duì)于 Jacoco 理想的使用場(chǎng)景:在測(cè)試階段,能夠?qū)崟r(shí)統(tǒng)計(jì)手工測(cè)試的代碼覆蓋率情況
了解了 jacoco 的一些基本使用方法后,發(fā)現(xiàn)要滿足這個(gè)使用場(chǎng)景,至少需要解決 2 個(gè)問(wèn)題。
類(lèi)修改萍倡,帶來(lái)的探針數(shù)據(jù)合并問(wèn)題
方法的修改,對(duì)探針數(shù)據(jù)造成的影響
下面講講具體問(wèn)題以及解決思路
重點(diǎn)說(shuō)明:以下實(shí)踐使用普通 java 類(lèi)測(cè)試辟汰,并且我所使用的是 JDK8列敲。大于 JDK8 版本的插樁邏輯并不相同阱佛,如果是 JDK9 及以上版本,可能并不適用
問(wèn)題一
類(lèi)修改戴而,帶來(lái)的探針數(shù)據(jù)合并問(wèn)題
圖 1
如圖 1 所示凑术,修改前的 Hello.java 文件,包含 3 個(gè)方法:A/B/C所意,修改后包含 4 個(gè)方法:A/B/C/D淮逊。
收集修改前 Hello 類(lèi)的探針數(shù)據(jù) (假設(shè) A/B 方法已執(zhí)行):dump1.exec
修改 Hello 中的 B 方法,然后重新收集探針數(shù)據(jù) (假設(shè) C/D 方法已執(zhí)行):dump2.exec
dump1.exec 和 dump2.exec 的數(shù)據(jù)合并扶踊,想要合并后的覆蓋率數(shù)據(jù)中包括:已被執(zhí)行方法【A/C/D】泄鹏,未被執(zhí)行方法:【B】
合并 exec 數(shù)據(jù)使用 jacoco 的 merge 指令,merge 對(duì)于同一個(gè)類(lèi)文件數(shù)據(jù)是否能合并的主要判斷邏輯代碼如下:
publicvoidassertCompatibility(finallongid,finalStringname,finalintprobecount)throwsIllegalStateException{/**
////這里是我加的注釋
同一個(gè)java文件秧耗,每次修改后對(duì)應(yīng)生成的classId都是不一致的备籽,
所以在這個(gè)地方就會(huì)被判斷不通過(guò),無(wú)法合并同一個(gè)java文件的統(tǒng)計(jì)數(shù)據(jù)
假設(shè)這里注釋掉id的判斷邏輯分井,繼續(xù)往下執(zhí)行
*/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判斷邏輯注釋掉车猬,在這里面探針數(shù)組長(zhǎng)度的時(shí)候還是會(huì)校驗(yàn)失敗,
Hello.java文件修改后尺锚,新增了D方法诈唬,導(dǎo)致Hello類(lèi)文件的探針數(shù)據(jù)長(zhǎng)度是發(fā)生了變化,這里長(zhǎng)度校驗(yàn)會(huì)失斔豸铩;
假設(shè)沒(méi)有新增D方法赡矢,同時(shí)假設(shè)數(shù)組長(zhǎng)度剛好一致能夠合并杭朱。但同時(shí)無(wú)法過(guò)濾掉修改前(dump1.exec)B方法的統(tǒng)計(jì)數(shù)據(jù)
所以?xún)H僅注釋掉id的判斷邏輯是行不通的
*/if(this.probes.length!=probecount){thrownewIllegalStateException(format("Incompatible execution data for class %s with id %016x.",name,Long.valueOf(id)));}}
通過(guò)上面的代碼注釋?zhuān)梢钥闯霈F(xiàn)有的 jacoco 合并邏輯無(wú)法滿足在測(cè)試環(huán)境數(shù)據(jù)合并的需求。
我的解決方案是針對(duì)同一個(gè) java 文件吹散,按照方法作為顆粒度弧械,切割類(lèi)對(duì)應(yīng)統(tǒng)計(jì)的探針數(shù)組,拿到各個(gè)方法的探針數(shù)據(jù)空民,再依次進(jìn)行對(duì)應(yīng)方法的數(shù)據(jù)合并刃唐。
圖 2
如圖 2 所示,只要切割拿到修改前后對(duì)應(yīng)方法的探針數(shù)據(jù)界轩,就能實(shí)現(xiàn)不同 class 版本收集的覆蓋率數(shù)據(jù)合并画饥。
關(guān)于如何切割,其實(shí)通過(guò)分析 jacoco-cli 工程中 report 指令浊猾,會(huì)發(fā)現(xiàn)按照方法切割很簡(jiǎn)單 (也可能是我考慮的太少....)
目前我還未完成這個(gè)合并功能抖甘,僅僅是找到了按照方法切割的思路,有興趣的可以動(dòng)手實(shí)踐一下葫慎。
下面貼一下簡(jiǎn)單的示例代碼圖片:
org/jacoco/core/internal/flow/ClassProbesAdapter.java
org/jacoco/core/internal/flow/MethodProbesAdapter.java
我的 demo 類(lèi)輸出(這是我之前測(cè)試的截圖衔彻,所以輸出的和上面說(shuō)的 Hello 文件不太一樣):
問(wèn)題二
方法的修改薇宠,對(duì)探針數(shù)據(jù)造成的影響
目前我考慮到 2 種比較常見(jiàn)的情況。
第一種情況:
圖 6
<問(wèn)題描述>
如圖 6 所示艰额,ApiController 類(lèi)中的 api 和 api2 方法都調(diào)用了 Services 類(lèi)的 print 方法澄港。
假設(shè)我們執(zhí)行了 api 方法,在收集 (dump1.exec) 的覆蓋率報(bào)告中柄沮,api 方法和 print 方法會(huì)顯示已被執(zhí)行回梧。
然后修改 ApiController 類(lèi)中的 api 方法,不執(zhí)行任何方法铡溪,直接收集覆蓋率數(shù)據(jù) (dump2.exe)漂辐。然后合并 dump1 和 dump2,
這時(shí)候查看覆蓋率報(bào)告棕硫,print 方法會(huì)顯示已被執(zhí)行髓涯。實(shí)際上,我認(rèn)為 print 方法不應(yīng)該被標(biāo)記為已被執(zhí)行哈扮。
<解決思路>
針對(duì)圖 6 所描述的問(wèn)題纬纪,利用函數(shù)調(diào)用鏈可以解決。api 調(diào)用了 print 方法滑肉,當(dāng) api 方法修改后包各,api 方法對(duì)應(yīng)的覆蓋率數(shù)據(jù)應(yīng)被舍棄,那么 api 方法設(shè)計(jì)的整個(gè)調(diào)用鏈的數(shù)據(jù)都應(yīng)該被舍棄
第二種情況:
圖 7
<問(wèn)題描述>
如圖 7 所示靶庙,api 方法會(huì)執(zhí)行 print 方法的 if 代碼塊以及” System.out.println(3);“輸出語(yǔ)句问畅。
api2 會(huì)執(zhí)行 print 方法的 else 代碼塊及” System.out.println(3);“輸出語(yǔ)句。
假設(shè)執(zhí)行 api 和 api2 方法六荒,收集覆蓋率數(shù)據(jù) (dump1.exec);
然后修改 api 方法护姆,不執(zhí)行任何方法,收集覆蓋率數(shù)據(jù) (dump2.exec);
按照函數(shù)調(diào)用鏈的解決思路合并 dump1 和 dump2掏击。
那么這時(shí)候 print 方法的覆蓋數(shù)據(jù)會(huì)丟失 (因?yàn)?print 方法被 api 調(diào)用卵皂,而 api 方法又被修改過(guò))。
我認(rèn)為較理想的合并結(jié)果是:api 方法被修改了所以覆蓋率數(shù)據(jù)舍棄砚亭;api2 方法未修改所以覆蓋率數(shù)據(jù)保留灯变;
print 方法中 if 代碼塊是被 api 方法調(diào)用,所以 if 代碼塊的覆蓋率數(shù)據(jù)舍棄捅膘。
else 代碼塊是被 api2 方法調(diào)用添祸,所以 else 代碼塊覆蓋率數(shù)據(jù)保留。
同時(shí)輸出語(yǔ)句” System.out.println(3);“被 api 和 api2 均調(diào)用篓跛,所以覆蓋率數(shù)據(jù)應(yīng)保留膝捞。
<解決思路>
從上述的問(wèn)題描述可以看出,僅僅是依賴(lài)函數(shù)調(diào)用鏈并不能達(dá)到我們想要的目的。
我們需要知道每個(gè)方法中蔬咬,每一個(gè)探針包含的代碼塊具體被哪個(gè)方法執(zhí)行過(guò)鲤遥。
這句話涉及 2 個(gè)動(dòng)作:調(diào)用者是誰(shuí)、并且記錄下來(lái)
想要的效果林艘,如下圖
總結(jié)一下盖奈,針對(duì)上述 2 種情況。我們需要實(shí)現(xiàn)函數(shù)調(diào)用鏈狐援,并且知道每個(gè)方法的調(diào)用者是誰(shuí)钢坦,
并在每個(gè)探針下面記錄調(diào)用者的 URI。有了解決思路啥酱,剩下的就是實(shí)現(xiàn)就好了爹凹。
1.先定義一個(gè)節(jié)點(diǎn)類(lèi)
publicclassChainNode{privateStringuri;privateChainNodepreNode;//鏈路上一級(jí)節(jié)點(diǎn)privateChainNodecalledNode;//調(diào)用者節(jié)點(diǎn)}
2.通過(guò) ASM 在每個(gè)方法開(kāi)始和結(jié)束,記錄節(jié)點(diǎn)信息镶殷,完成函數(shù)調(diào)用鏈的實(shí)現(xiàn)
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);//多線程情況下這個(gè)其實(shí)不用set為nulltailNode.set(null);calledNode.set(null);}finally{lock.unlock();}}else{calledNode.set(calledNode.get().getCalledNode());}}
3.在每個(gè)探針下面添加一個(gè) Set禾酱,用來(lái)存儲(chǔ)調(diào)用者的 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();}
最后看一下通過(guò)修改后的 jacoco 插樁后的 class 文件:
總結(jié)
通過(guò)上述解決思路绘趋,我認(rèn)為是可以解決在測(cè)試過(guò)程中覆蓋率數(shù)據(jù)的合并問(wèn)題颤陶。
截止到發(fā)帖,暫時(shí)還未完全實(shí)現(xiàn)整個(gè)功能陷遮。在這里僅提供解決思路滓走,如果大家感興趣,可以一起多多嘗試帽馋。
上述測(cè)試的主要是普通 class 文件搅方,對(duì) interface,enum,abstract 并未測(cè)試,并且 jacoco 的插樁策略和 jdk 版本有關(guān)的绽族。
不同的 jdk 版本腰懂,jacoco 插樁的策略不同,我目前嘗試基于 jdk8项秉。
===========2021-09-07 更新=============
測(cè)試項(xiàng)目代碼如下圖:
第一次提交代碼。發(fā)布應(yīng)用慷彤,執(zhí)行 test1娄蔼,2,3 方法底哗,第一次收集的覆蓋率報(bào)告
修改 test1 方法岁诉,第二次提交代碼。重新發(fā)布應(yīng)用跋选,執(zhí)行 test4 方法涕癣,第二次收集的覆蓋率報(bào)告
合并了上面 2 次不同版本代碼的探針數(shù)據(jù),生成的覆蓋率報(bào)告前标,如下圖
由alwans首發(fā)于TesterHome社區(qū)「測(cè)試覆蓋率」節(jié)點(diǎn)