Android增量代码测试覆盖率工具

前言

美团点评业务快速发展,新项目新业务不断出现,在项目开发和测试人员不足、开发同学粗心的情况下,难免会出现少测漏测的情况,如何保证新增代码有足够的测试覆盖率是我们需要思考的问题。

Bad-Case

先看一个bug:

bad_case.png

以上代码可能在onDestory时反注册一个没有注册的receiver而发生崩溃。如果开发同学经验不足、自测不够充分或者代码审查不够仔细,这个bug很容易被带到线上。

正常情况下,可以通过写单测来保证新增代码的覆盖率,在Android中可以参考《Android单元测试研究与实践》 。但在实际开发中,由于单测部署成本高、项目排期比较紧张、需求变化频繁、团队成员能力不足等多种原因,单测在互联网行业普及程度并不理想。

所以我们实现了这样一个工具,不需要写单测的情况下,在代码提交之前自动检测新增代码的手工测试覆盖率,避免新开发的功能没有经过自测就直接进入代码审查环节。

整个工具主要包含下面三个方面的内容:

  • 如何获取新增代码。
  • 如何只生成新增代码的覆盖率报告。
  • 如何让整个流程自动化。

获取新增代码

定义新增代码

美团点评一直使用Git做代码版本控制,开发完之后提交pull request到目标分支,审查通过后即可合并。所以对于单次提交,可将新增的代码定义为:

  1. 本地工作目录中还没提交到暂存区的代码。
  2. 已经提交到暂存区的代码。
  3. 上次merge以后到还没有merge的commit中的代码。

如下图所示:

diff_java.png

得到新增代码的定义以后,如何得到这些文件中真正新增的代码:

  • 把当前检测变化的Java文件放到一个临时目录A中。
  • 分别查看第一步找到的文件在最近一个merge的commit中的文件,并放到临时目录B中。

为了充分测试修改的代码,这里把方法作为最小测试单元(新增和修改的方法),即使是修改了方法中的某一行代码也认为这个方法发生了变化。如何准确定位到哪些方法发生了变化?我们通过抽象语法树来实现。

抽象语法树

所谓抽象语法树,就是源代码的抽象语法结构的树状表现形式,树上的每一个节点代表源代码中的一种结构。

下面通过Android Studio的JDT-View插件来表示一个简单的抽象语法树结构,左边是源码,右边是解析完以后的抽象语法结构:

ast.png

后续语法树分析的实现通过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_coverage.png

  • 绿色:表示行覆盖充分。
  • 红色:表示未覆盖的行。
  • 黄色棱形:表示分支覆盖不全。
  • 绿色棱形:表示分支覆盖完全。

注入原理

JaCoCo主要通过代码注入的方式来实现上面覆盖率的功能。JaCoCo支持的注入方式如下图(图片出自这里)所示:

hook.png

包含了几种不同的收集覆盖率信息的方法,每个方法的实现都不太一样,这里主要关心字节码注入这种方式(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的工作流程:

  1. 在生成最终目标文件之前对字节码进行插桩。
  2. 运行测试代码,得到运行时数据。
  3. 根据运行时数据、生成的class文件、源码生成覆盖率报告。

通过一张图来形象地表示一下:

jacoco_impl.png

如何实现代码注入呢?举个例子说明一下:

jacoco_asm_hook.png

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) {
      @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方法,只对变化了方法进行注入:

@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这两个方法,那么覆盖率只涉及这些修改了的方法:

jacoco_coverage1.png

jacoco_coverage2.png

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指针取或:

merge.png

自动部署Pre-Push脚本

为了开发者在提交代码之前能够自动生成覆盖率报告,我们在插件apply阶段动态下发一个Pre-Push脚本到本地项目的.git目录。在push之前生成覆盖率报告,同时对于覆盖率小于一定值(默认95%,可自定义)的提交提示并报警:

pre-push.png

整体流程图

整个工具通过Gradle插件的形式部署到项目中,在项目编译阶段完成新增代码的查找和注入,在最终push代码之前获取当前设备的运行时数据,然后生成覆盖率报告,并把覆盖率低于一定值(默认是95%)的提交abort掉。

最后通过一张完整的图来看下这个工具的工作流程:

framework.png

总结

上述是我们在保障开发质量方面做的一些探索和积累。通过保障开发阶段增量代码的自测覆盖率,让开发者充分检验开发效果,提前发现逻辑缺陷,将风险前置。保障开发质量的道路任重而道远, 我们可以通过良好的测试覆盖率、持续完善单测、改善代码框架、规范开发流程等等多种维度相辅相成、共同推进。

参考文献:

  1. JaCoCo-Source-Code
  2. Java代码覆盖率工具JaCoCo-原理篇

作者介绍

本文三位作者均来自美团点评的到店餐饮技术部信息与交易技术中心。

武智,Android高级开发工程师,2013年7月校招加入美团点评,目前负责维护大众点评App的美食频道。

莹莹,2015年校招加入美团点评,主要参与大众点评美食频道的日常开发工作,专注于通过工具自动化地提高开发效率和质量。

周佳,2016年校招加入美团点评,主要参与大众点评美食频道的日常开发工作。

到店餐饮技术部交易与信息技术中心,负责美团点评美食用户端业务,服务于数以亿计用户,通过更好的榜单、真实的评价和完善的信息为用户提供更好的决策支持,致力于提升用户体验;同时承载所有餐饮商户端线上流量,为餐饮商户提供多种营销工具,提升餐饮商户营销效率,最终达到让国人“Eat Better、Live Better”的美好愿景!我们的团队包含且不限于Android、iOS、FE、Java、PHP等技术方向,已完备覆盖前后端技术栈。只要你来,就能点亮全栈开发技能树。诚挚欢迎投递简历至chenhongbing#meituan.com。

【思考题】

本文为大家介绍的工具基本上可以解决新增代码没有覆盖导致的问题。但开发过程中还会有一些因为数据、状态错误导致的问题,对于这类问题,通过什么工具可以及时的发现并解决?日常测试过程中用到测试数据是否被有效的利⽤和积累,是否能利用大数据相关的技术完善新时代的测试体系?

下一章:美团点评旅游搜索召回策略的演进

本文内容与6月22日第22期美团点评技术沙龙“美团点评AI实践”主题演讲一致,欢迎大家去现场和作者交流。 关注“美团点评技术团队”微信公众号,第一时间获取沙龙最新信息,还可以查阅往期沙龙PPT/视频。 背 ...