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

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

先看一个bug:

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

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

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

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

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

定义新增代码

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

  1. 本地工作目录中还没提交到暂存区的代码。
  2. 已经提交到暂存区的代码。
  3. 上次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 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的工作流程:

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

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

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

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掉。

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

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

  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。

【思考题】

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

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/478427.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

ACL'21 | 多模态数值推理新挑战,让 AI 学解几何题

文 | 陈嘉奇编 | 小轶从小到大&#xff0c;数学都是一门令人头秃充满魅力的学科。从基本的代数、几何&#xff0c;到高数微积分&#xff0c;各类数学问题都对答题者的逻辑推理能力都有着不同程度的挑战。而逻辑推理能力一直以来都是 AI 发展的核心目标之一。学术界对于 AI 自动…

论文浅尝 - ICLR2021 | 从信息论的角度提高语言模型的鲁棒性

笔记整理 | 胡楠&#xff0c;东南大学来源&#xff1a;ICLR 2021论文下载地址&#xff1a;https://arxiv.org/pdf/2010.02329.pdf动机最近的研究表明&#xff0c;BERT和RoBERTa这种基于BERT的模型容易受到文字对抗攻击的威胁。论文旨在从信息理论的角度解决此问题并提出InfoBER…

LeetCode 74. 搜索二维矩阵(二分查找)

1. 题目 编写一个高效的算法来判断 m x n 矩阵中&#xff0c;是否存在一个目标值。该矩阵具有如下特性&#xff1a; 每行中的整数从左到右按升序排列。 每行的第一个整数大于前一行的最后一个整数。 示例 1: 输入: matrix [[1, 3, 5, 7],[10, 11, 16, 20],[23, 30, 34,…

AINLP-Archive:AINLP文章存档计划

AINLP-Archive&#xff1a;AINLP文章存档计划&#xff1a;https://mp.weixin.qq.com/s/dCzmlRLTb0aSxkq7jefc_g

美团点评旅游搜索召回策略的演进

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

从论文到PPT,一键生成!从此报告不用愁!

文 | 子龙编 | 小轶俗话说&#xff1a;“行百步者半九十”&#xff0c;论文接受固然可喜可贺&#xff0c;然而这只是万里长征第一步。一份具有影响力的工作少不了一个后期的宣传&#xff0c;做好一个PPT绝对是一个技术活。不知道小伙伴们平时怎么做PPT&#xff0c;是复制粘贴长…

资源征集 | 2021年全国知识图谱与语义计算大会开放资源征集(Resource Track)通知...

大会时间&#xff1a;2021年8月18日-21日&#xff0c;广州资源征集截止: 2021年5月20日开放的资源对促进知识图谱和语义计算相关的科学研究及企业应用十分重要。CCKS 2021将组织开放资源征集&#xff08;Resource Track&#xff09;&#xff0c;旨在支持和促进学术界和工业界的…

搜索引擎和网站中的高级搜索技巧

“ 在浩瀚的互联网中精准的找到需要的信息是一项必备的技能&#xff0c;而各大搜索引擎google,baidu,bingd等都提供有高级搜索技巧和语句可以帮助我们更快速更准确的找到我们需要的信息。” 01 — 关键字搜索 在搜索时使用关键字搜索是最基础的搜索技巧&#xff0c;简而言之…

WebView性能、体验分析与优化

在App开发中&#xff0c;内嵌WebView始终占有着一席之地。它能以较低的成本实现Android、iOS和Web的复用&#xff0c;也可以冠冕堂皇的突破苹果对热更新的封锁。 然而便利性的同时&#xff0c;WebView的性能体验却备受质疑&#xff0c;导致很多客户端中需要动态更新等页面时不得…

LeetCode 240. 搜索二维矩阵 II(二分查找 分治)

文章目录1. 题目2. 解题2.1 从左下角或者右上角开始搜索2.2 分治算法1. 题目 编写一个高效的算法来搜索 m x n 矩阵 matrix 中的一个目标值 target。该矩阵具有以下特性&#xff1a; 每行的元素从左到右升序排列。 每列的元素从上到下升序排列。 示例: 现有矩阵 matrix 如下…

NLP哪个细分方向最具社会价值?

文 | 小戏让我们来大胆设想一个场景&#xff0c;老板突然发财搞到一笔钱&#xff0c;大手一挥给你五百万&#xff0c;让你去做自然语言处理的研究&#xff0c;你该先研究哪一个细分领域&#xff1f;机器翻译好像不错&#xff0c;信息抽取也很必要&#xff0c;对话系统更是 NLP …

论文浅尝 | 通过阅读实体描述进行零样本的实体链接

笔记整理 | 赖泽升&#xff0c;东南大学本科生来源&#xff1a;ACL2019动机先前实体链接的大多数工作都着重于与通用实体数据库的链接&#xff0c;通常希望链接到专门的实体词典&#xff0c;例如法律案件&#xff0c;公司项目描述&#xff0c;小说中的字符集或术语表。但这些工…

知识图谱-数据集

原文链接&#xff1a;https://blog.csdn.net/qq_21097885/article/details/104562276 DBpedia 网址&#xff1a;https://wiki.dbpedia.org/ 简介&#xff1a; DBpedia 是一个很特殊的语义网应用范例&#xff0c;它从维基百科(Wikipedia)的词条里撷取出结构化的资料&#xff0c;…

LeetCode 29. 两数相除(位运算)

1. 题目 给定两个整数&#xff0c;被除数 dividend 和除数 divisor。将两数相除&#xff0c;要求不使用乘法、除法和 mod 运算符。 返回被除数 dividend 除以除数 divisor 得到的商。 示例 1: 输入: dividend 10, divisor 3 输出: 3示例 2: 输入: dividend 7, divisor -…

Git使用的奇技淫巧

源 | Linux公社Git 版本对比相关操作[1] 输出工作区和暂存区的不同。git diff[2] 展示暂存区和最近版本的不同git diff --cached[3] 展示暂存区、工作区和最近版本的不同git diff HEAD[4] 展示本地仓库中任意两个 commit 之间的文件变动git diff <commit-id> <commit-…

Python-接口开发入门

Python-接口开发入门&#xff1a;https://www.cnblogs.com/zhxwind/p/11202629.html

Hyperloop,让发布简洁高效

Hyperloop 是什么&#xff1f; Hyperloop 是服务于美团点评客户端的组件发版、持续集成、App 打包构建、资源调度等各个环节的发布调度系统。名称起源于美国 Elon Musk 构想的 Hyperloop 超级高铁&#xff0c;象征着现代、简洁、高效。 Hyperloop 提供了一站式的平台&#xff0…

论文浅尝 | 基于潜在类别信息的实体链接

笔记整理 | 黄一凡&#xff0c;东南大学本科生来源&#xff1a;AAAI2020链接&#xff1a;https://arxiv.org/pdf/2001.01447v1.pdf一、简介作者意识到在利用预训练模型进行实体链接时&#xff0c;往往会将类别信息忽略&#xff0c;因此会导致模型将指称链接到拥有错误类别的错误…

LeetCode 166. 分数到小数(小数除法)

1. 题目 给定两个整数&#xff0c;分别表示分数的分子 numerator 和分母 denominator&#xff0c;以字符串形式返回小数。 如果小数部分为循环小数&#xff0c;则将循环的部分括在括号内。 示例 1: 输入: numerator 1, denominator 2 输出: "0.5"示例 2: 输入: …

百度飞桨弯道超车了吗?!

事情是这样的...前不久&#xff0c;小夕注意到了一份来自权威评测机构IDC发布的《2020年下半年深度学习平台市场份额报告》&#xff1a;▲IDC:2020年中国深度学习平台市场综合份额top 5立刻惊了&#xff01;印象里百度飞桨三年前还只是一个低调、小而美的深度学习框架&#xff…