Android增量代码测试覆盖率工具
美團業務快速發展,新項目新業務不斷出現,在項目開發和測試人員不足、開發同學粗心的情況下,難免會出現少測漏測的情況,如何保證新增代碼有足夠的測試覆蓋率是我們需要思考的問題。
先看一個bug:
以上代碼可能在onDestory時反注冊一個沒有注冊的receiver而發生崩潰。如果開發同學經驗不足、自測不夠充分或者代碼審查不夠仔細,這個bug很容易被帶到線上。
正常情況下,可以通過寫單測來保證新增代碼的覆蓋率,在Android中可以參考《Android單元測試研究與實踐》 。但在實際開發中,由于單測部署成本高、項目排期比較緊張、需求變化頻繁、團隊成員能力不足等多種原因,單測在互聯網行業普及程度并不理想。
所以我們實現了這樣一個工具,不需要寫單測的情況下,在代碼提交之前自動檢測新增代碼的手工測試覆蓋率,避免新開發的功能沒有經過自測就直接進入代碼審查環節。
整個工具主要包含下面三個方面的內容:
- 如何獲取新增代碼。
- 如何只生成新增代碼的覆蓋率報告。
- 如何讓整個流程自動化。
定義新增代碼
美團一直使用Git做代碼版本控制,開發完之后提交pull request到目標分支,審查通過后即可合并。所以對于單次提交,可將新增的代碼定義為:
如下圖所示:
得到新增代碼的定義以后,如何得到這些文件中真正新增的代碼:
- 把當前檢測變化的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 package public 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的工作流程:
通過一張圖來形象地表示一下:
如何實現代碼注入呢?舉個例子說明一下:
JaCoCo通過ASM在字節碼中插入Probe指針(探測指針),每個探測指針都是一個BOOL變量(true表示執行、false表示沒有執行),程序運行時通過改變指針的結果來檢測代碼的執行情況(不會改變原代碼的行為)。探測指針完整插入策略請參考Probe Insertion Strategy。
增量注入
介紹完JaCoCo注入原理以后,我們來看看如何做到增量注入:
JaCoCo默認的注入方式為全量注入。通過閱讀源碼,發現注入的邏輯主要在ClassProbesAdapter中。ASM在遍歷字節碼時,每次訪問一個方法定義,都會回調這個類的visitMethod方法 ,在visitMethod方法中再調用ClassProbeVisitor的visitMethod方法,并最終調用MethodInstrumenter完成注入。部分代碼片段如下:
@Override public 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) {@Overridepublic 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方法,只對變化了方法進行注入:
@Override public 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(只對變化的類做處理):
@Override public void analyzeClass(final ClassReader reader) {if (Utils.shoudHackMethod(reader.getClassName(),changedMethods)) {...} }和ReportClassProbesAdapter(只對變化的方法做處理):
@Override public 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掉。
最后通過一張完整的圖來看下這個工具的工作流程:
上述是我們在保障開發質量方面做的一些探索和積累。通過保障開發階段增量代碼的自測覆蓋率,讓開發者充分檢驗開發效果,提前發現邏輯缺陷,將風險前置。保障開發質量的道路任重而道遠, 我們可以通過良好的測試覆蓋率、持續完善單測、改善代碼框架、規范開發流程等等多種維度相輔相成、共同推進。
本文三位作者均來自美團的到店餐飲技術部信息與交易技術中心。
武智,Android高級開發工程師,2013年7月校招加入美團,目前負責維護大眾點評App的美食頻道。
瑩瑩,2015年校招加入美團,主要參與大眾點評美食頻道的日常開發工作,專注于通過工具自動化地提高開發效率和質量。
周佳,2016年校招加入美團,主要參與大眾點評美食頻道的日常開發工作。
到店餐飲技術部交易與信息技術中心,負責美團美食用戶端業務,服務于數以億計用戶,通過更好的榜單、真實的評價和完善的信息為用戶提供更好的決策支持,致力于提升用戶體驗;同時承載所有餐飲商戶端線上流量,為餐飲商戶提供多種營銷工具,提升餐飲商戶營銷效率,最終達到讓國人“Eat Better、Live Better”的美好愿景!我們的團隊包含且不限于Android、iOS、FE、Java、PHP等技術方向,已完備覆蓋前后端技術棧。只要你來,就能點亮全棧開發技能樹。誠摯歡迎投遞簡歷至chenhongbing#meituan.com。
【思考題】
本文為大家介紹的工具基本上可以解決新增代碼沒有覆蓋導致的問題。但開發過程中還會有一些因為數據、狀態錯誤導致的問題,對于這類問題,通過什么工具可以及時的發現并解決?日常測試過程中用到測試數據是否被有效的利?和積累,是否能利用大數據相關的技術完善新時代的測試體系?
總結
以上是生活随笔為你收集整理的Android增量代码测试覆盖率工具的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 2019 最全阿里天猫Java 3面真题
- 下一篇: 美团Android自动化之旅—生成渠道包