Android增量程式碼測試覆蓋率工具
前言
美團點評業務快速發展,新專案新業務不斷出現,在專案開發和測試人員不足、開發同學粗心的情況下,難免會出現少測漏測的情況,如何保證新增程式碼有足夠的測試覆蓋率是我們需要思考的問題。
Bad-Case
先看一個bug:
以上程式碼可能在onDestory時反註冊一個沒有註冊的receiver而發生崩潰。如果開發同學經驗不足、自測不夠充分或者程式碼審查不夠仔細,這個bug很容易被帶到線上。
正常情況下,可以通過寫單測來保證新增程式碼的覆蓋率,在Android中可以參考《Android單元測試研究與實踐》 。但在實際開發中,由於單測部署成本高、專案排期比較緊張、需求變化頻繁、團隊成員能力不足等多種原因,單測在網際網路行業普及程度並不理想。
所以我們實現了這樣一個工具,不需要寫單測的情況下,在程式碼提交之前自動檢測新增程式碼的手工測試覆蓋率,避免新開發的功能沒有經過自測就直接進入程式碼審查環節。
整個工具主要包含下面三個方面的內容:
- 如何獲取新增程式碼。
- 如何只生成新增程式碼的覆蓋率報告。
- 如何讓整個流程自動化。
獲取新增程式碼
定義新增程式碼
美團點評一直使用Git做程式碼版本控制,開發完之後提交pull request到目標分支,審查通過後即可合併。所以對於單次提交,可將新增的程式碼定義為:
- 本地工作目錄中還沒提交到暫存區的程式碼。
- 已經提交到暫存區的程式碼。
- 上次merge以後到還沒有merge的commit中的程式碼。
如下圖所示:
得到新增程式碼的定義以後,如何得到這些檔案中真正新增的程式碼:
- 把當前檢測變化的Java檔案放到一個臨時目錄A中。
- 分別檢視第一步找到的檔案在最近一個merge的commit中的檔案,並放到臨時目錄B中。
為了充分測試修改的程式碼,這裡把方法作為最小測試單元(新增和修改的方法),即使是修改了方法中的某一行程式碼也認為這個方法發生了變化。如何準確定位到哪些方法發生了變化?我們通過抽象語法樹來實現。
抽象語法樹
所謂抽象語法樹,就是原始碼的抽象語法結構的樹狀表現形式,樹上的每一個節點代表原始碼中的一種結構。
下面通過Android Studio的JDT-View外掛來表示一個簡單的抽象語法樹結構,左邊是原始碼,右邊是解析完以後的抽象語法結構:
後續語法樹分析的實現通過Eclipse的JDT來完成。用JDT主要解決兩個問題:
- 定位哪些方法發生了變化。
- 把JDT分析出的結果轉化為合適的資料結構,方便後面做增量注入。
第一個問題比較容易解決,分別生成兩組Java檔案(上一部分結尾得到的兩組檔案A、B)的語法樹,並對方法(去掉註釋和空行)進行MD5,MD5不同的方法,便認為該方法在這次提交中發生了變化。
對於第二個問題,主要的難點在於通過JDT得到的方法定義和通過ASM(後面位元組碼注入通過ASM來實現)得到的方法定義不同,這二者最大的區別是JDT無法直接得到內部類、匿名內部類、Lambda表示式的ClassName,所以需要在語法樹分析時把方法對應的ClassName轉化成位元組碼對應的ClassName。位元組碼生成內部類和RetroLambda ClassName的規則如下:
- 匿名內部類:...$Index。
- 普通內部類、靜態內部類:...$InnerClassName。
- RetroLambda表示式:...$$Lambda$Index。
具體如何處理呢?JDT在分析Java檔案時有幾個關鍵的函式:
- visit(MethodDeclaration method):訪問普通方法的定義。
- visit(AnonymousDeclaration method):訪問匿名內部類的定義。
- endVisit(AnonymousDeclaration method):結束匿名內部類的定義。
- visit(TypeDeclaration node):訪問普通類定義。
- endVisit(TypeDeclaration node):結束普通類的定義。
- visit(LambdaExpress node):訪問Lambda表示式的定義。
同時在解析原始檔時會按照原始碼定義順序來訪問各個節點。對於以上情況,只需要按照入棧和出棧的順序來管理ClassName,就能和後面位元組碼得到的方法所匹配。
通過以上步驟,把每個方法的資訊封裝到MethodInfo中(後面注入和生成覆蓋率報告時會用到該資料):
public String className;//hash packagepublic String md5;public String methodName;public List<String> paramList = new ArrayList<>();public String methodBody;public boolean isLambda; //標識是否是Lambda表示式方法public int lambdaNumInClass; //同一個Class中此lambda表示式是第幾個. 從1開始.public int totalLambdaInClass; //同一個Class中lambda表示式的總數public String lambdaParent; //lambda表示式的父節點public boolean isLambdaInAnonymous; //標識lambda表示式是否位於內部類中public boolean isAnonymousClass; //標識是否是內部類方法
新增程式碼的覆蓋率報告
生成程式碼的覆蓋率報告,首先想到的就是JaCoCo,下面分別介紹一下JaCoCo的原理和我們所做的改造。
JaCoCo概述
JaCoCo包含了多種維度的覆蓋率計數器:指令級計數器(C0 coverage)、分支級計數器(C1 coverage)、圈複雜度、行覆蓋、方法覆蓋、類覆蓋。其覆蓋率報告的示例如下:
- 綠色:表示行覆蓋充分。
- 紅色:表示未覆蓋的行。
- 黃色稜形:表示分支覆蓋不全。
- 綠色稜形:表示分支覆蓋完全。
注入原理
JaCoCo主要通過程式碼注入的方式來實現上面覆蓋率的功能。JaCoCo支援的注入方式如下圖(圖片出自這裡)所示:
包含了幾種不同的收集覆蓋率資訊的方法,每個方法的實現都不太一樣,這裡主要關心位元組碼注入這種方式(Byte Code)。Byte Code包含Offline和On-The-Fly兩種注入方式:
- Offline:在生成最終的目標檔案之前,對Class檔案進行插樁,生成最終的目標檔案,執行目標檔案以後得到覆蓋執行結果,最終生成覆蓋率報告。
- On-The-Fly:JVM通過-javaagent指定特定的Jar來啟動Instrumentation代理程式,代理程式在ClassLoader裝載一個class前先判斷是否需要對class進行注入,對於需要注入的class進行注入。覆蓋率結果可以在JVM執行程式碼的過程中完成。
可以看到,On-The-Fly因為要修改JVM引數,所以對環境的要求比較高,為了遮蔽工具對虛擬機器環境的依賴,我們的程式碼注入主要選擇Offline這種方式。
Offline的工作流程:
- 在生成最終目標檔案之前對位元組碼進行插樁。
- 執行測試程式碼,得到執行時資料。
- 根據執行時資料、生成的class檔案、原始碼生成覆蓋率報告。
通過一張圖來形象地表示一下:
如何實現程式碼注入呢?舉個例子說明一下:
JaCoCo通過ASM在位元組碼中插入Probe指標(探測指標),每個探測指標都是一個BOOL變數(true表示執行、false表示沒有執行),程式執行時通過改變指標的結果來檢測程式碼的執行情況(不會改變原始碼的行為)。探測指標完整插入策略請參考Probe Insertion Strategy。
增量注入
介紹完JaCoCo注入原理以後,我們來看看如何做到增量注入:
JaCoCo預設的注入方式為全量注入。通過閱讀原始碼,發現注入的邏輯主要在ClassProbesAdapter中。ASM在遍歷位元組碼時,每次訪問一個方法定義,都會回撥這個類的visitMethod方法,在visitMethod方法中再呼叫ClassProbeVisitor的visitMethod方法,並最終呼叫MethodInstrumenter完成注入。部分程式碼片段如下:
@Overridepublic final MethodVisitor visitMethod(final int access, final String name, final String desc, final String signature, final String[] exceptions) { final MethodProbesVisitor methodProbes; final MethodProbesVisitor mv = cv.visitMethod(access, name, desc,
signature, exceptions); if (mv == null) {
methodProbes = EMPTY_METHOD_PROBES_VISITOR;
} else {
methodProbes = mv;
} return new MethodSanitizer(null, access, name, desc, signature,
exceptions) { @Override
public void visitEnd() { super.visitEnd();
LabelFlowAnalyzer.markLabels(this); final MethodProbesAdapter probesAdapter = new MethodProbesAdapter(
methodProbes, ClassProbesAdapter.this); if (trackFrames) { final AnalyzerAdapter analyzer = new AnalyzerAdapter(
ClassProbesAdapter.this.name, access, name, desc,
probesAdapter);
probesAdapter.setAnalyzer(analyzer); this.accept(analyzer);
} else { this.accept(probesAdapter);
}
}
};
}
看到這裡基本上已經知道如何去修改JaCoCo的原始碼了。繼承原有的ClassInstrumenter和ClassProbesAdapter,修改其中的visitMethod方法,只對變化了方法進行注入:
@Overridepublic final MethodVisitor visitMethod(final int access, final String name, final String desc, final String signature, final String[] exceptions) { if (Utils.shoudHackMethod(name,desc,signature,changedMethods,cv.getClassName())) {
...
} else { return cv.getCv().visitMethod(access, name, desc, signature, exceptions);
}
}
生成增量程式碼的覆蓋率報告
和增量注入的原理類似,通過閱讀原始碼,分別需要修改Analyzer(只對變化的類做處理):
@Overridepublic void analyzeClass(final ClassReader reader) { if (Utils.shoudHackMethod(reader.getClassName(),changedMethods)) {
...
}
}
和ReportClassProbesAdapter(只對變化的方法做處理):
@Overridepublic final MethodVisitor visitMethod(final int access, final String name, final String desc, final String signature, final String[] exceptions) { if (Utils.shoudHackMethod(name, desc, signature, changedMethods, this.className)) {
...
} else { return null;
}
}
這樣就能生成新增程式碼的覆蓋率報告。如下圖所示本次commit只修改了FoodPoiDetailActivity的onCreate和initCustomTitle這兩個方法,那麼覆蓋率只涉及這些修改了的方法:
JDT vs ASM
在上面增量注入和生成增量程式碼覆蓋率報告時都會去判斷當前方法是否應該被處理。這裡分別對比JDT和ASM解析結果中的className、methodName、paramList來判斷當前方法是否需要被注入,部分程式碼片段:
public static boolean shoudHackMethod(String methodName, String desc, String signature, HashSet<MethodInfo> changedMethods, String className) {
Map<String, List<String>> changedLambdaMethods = getChangedLambdaMethods(changedMethods);
List<String> changedLambdaMethodNames = changedLambdaMethods.get(className.replace("/", "."));
updateLambdaNum(methodName, className); int indexMethods = 0;
outer: for (; indexMethods < changedMethods.size(); indexMethods++) {
MethodInfo methodInfo = changedMethods[indexMethods] if (methodInfo.className.replace(".", "/").equals(className)) { if (methodName.startsWith('lambda$') && methodInfo.isLambda
&& changedLambdaMethodNames != null && changedLambdaMethodNames.size() > 0) { //兩者方法名相等
if (methodInfo.methodName.equals(methodName)) {
changedLambdaMethodNames.remove(methodInfo.methodName) return true;
} else if (!changedLambdaMethodNames.contains(methodName)) { //兩者方法名不等,且不包含在改變的lambda方法中,通過載入順序來判斷
int lastIndex = methodInfo.methodName.lastIndexOf('$'); if (lastIndex <= 0) { continue;
}
String tmpMethodName = methodInfo.methodName.substring(0, lastIndex); if (tmpMethodName.equals(sAsmMethodInfo.methodName)
&& (methodInfo.lambdaNumInClass == (methodInfo.totalLambdaInClass - sAsmMethodInfo.lambdaNumInClass + 1) || judgeSoleLambda(changedMethods, methodInfo, methodName, className.replace("/", ".")))) {
changedLambdaMethodNames.remove(methodInfo.methodName) return true;
}
}
} else { if (methodInfo.methodName.equals(methodName) ||
(!methodInfo.methodBody.trim().equals("{}") && methodName.equals("<init>") && methodInfo.methodName.equals(methodInfo.className.split("\.|\$")[methodInfo.className.split("\.|\$").length - 1]))) { if (signature == null) signature = desc;
TraceSignatureVisitor v = new TraceSignatureVisitor(0); new SignatureReader(signature).accept(v);
String declaration = v.getDeclaration(); int rightBrace = declaration.indexOf("("); int leftBrace = declaration.lastIndexOf(")"); if (rightBrace > 0 && leftBrace > rightBrace) { //只取形參
declaration = declaration.substring(rightBrace + 1, leftBrace);
} //勿用\[\]作為分隔符, 否則陣列形參不可區分
String paraStr = declaration.replaceAll("[(){}]", ""); if (paraStr.length() > 0) {
String[] parasArray = getAsmMethodParams(paraStr.split(","), className, methodInfo.paramList);
List<String> paramListAst = getAstMethodParams(methodInfo.paramList); if (parasArray.length == paramListAst.size()) { for (int i = 0; i < paramListAst.size(); i++) { //將< > . 作為分隔符
String[] methodInfoParamArray = paramListAst.get(i).split("<|>|\."); for (String param : methodInfoParamArray) { if (!parasArray[i].contains(param) ||
(parasArray[i].contains(param) && parasArray[i].contains("[]") && !param.endsWith("[]"))) { //同類名、同方法名、同參數長度, 引數型別不一致(或者 比較相等, 但class中是陣列, 而原始碼中不是陣列) 跳轉到 outer迴圈開始處
continue outer;
}
}
}
} else { continue;
}
} if (methodInfo.isLambda && changedLambdaMethodNames != null) {
changedLambdaMethodNames.remove(methodInfo.methodName)
} return true;
}
}
}
} return false;
}
流程的自動化
自動注入
整個工具通過Gradle外掛的形式加入到專案中,只需要簡單配置即可使用,在生成DEX之前完成增量程式碼的注入,同時為了不影響線上版本,該外掛只在Debug模式下生效。
自動獲取執行時資料
剛才講JaCoCo原理的時候提到,需要執行時資料才能生成覆蓋率報告。程式碼中通過反射執行下面的函式來獲取執行時資料,並儲存到當前執行程式碼的裝置中:
org.jacoco.agent.rt.RT.getAgent().getExecutionData(false)
由於生成報告時需要用到執行時資料,為了生成的覆蓋率報告更準確、開發同學用起來更方便,分別在如下時機把執行時資料儲存到當前裝置中:
- 每個頁面執行onDestory時。
- 程式發生崩潰時。
- 收到特定廣播(一個自定義的廣播,在執行生成覆蓋率報告的task前傳送)時。
並在生成覆蓋率報告之前把裝置中的執行時資料同步到本地開發環境中。
上面可以看到,因為獲取時機比較多,可能會得到多份執行時資料,對於這些資料,可以通過JaCoCo的mergeTask把ClassId相同的執行時資料進行merge。如下圖所示,JaCoCo會對ClassId相同的執行時資料進行merge,並對相同位置的probe指標取或:
自動部署Pre-Push指令碼
為了開發者在提交程式碼之前能夠自動生成覆蓋率報告,我們在外掛apply階段動態下發一個Pre-Push指令碼到本地專案的.git目錄。在push之前生成覆蓋率報告,同時對於覆蓋率小於一定值(預設95%,可自定義)的提交提示並報警:
整體流程圖
整個工具通過Gradle外掛的形式部署到專案中,在專案編譯階段完成新增程式碼的查詢和注入,在最終push程式碼之前獲取當前裝置的執行時資料,然後生成覆蓋率報告,並把覆蓋率低於一定值(預設是95%)的提交abort掉。
最後通過一張完整的圖來看下這個工具的工作流程:
總結
上述是我們在保障開發質量方面做的一些探索和積累。通過保障開發階段增量程式碼的自測覆蓋率,讓開發者充分檢驗開發效果,提前發現邏輯缺陷,將風險前置。保障開發質量的道路任重而道遠, 我們可以通過良好的測試覆蓋率、持續完善單測、改善程式碼框架、規範開發流程等等多種維度相輔相成、共同推進。
參考文獻