cover_image

Java实时代码覆盖率平台的思考与落地实践

吴珣 同程旅行技术中心
2021年10月29日 08:55

图片

标题:Java实时代码覆盖率平台的思考与落地实践

作者:吴珣,研发中心资深测试开发工程师




背景

一、行业篇:

    代码覆盖率是一种度量,最早用于衡量研发自测的全面程度。早在互联网风潮兴起之前,各大外企厂商就已经开启了对于代码覆盖率的实践。例如,2010年Visual Studio Code Coverage功能、开源的Emma、jscover等工具就已有了广泛的应用,这个时候底层技术实现及理论基本都已成熟,只是国内对代码质量嗅觉及重视度并不高。2013年后,随着互联网、移动互联网的飞速发展,国内企业逐渐对软件质量越来越重视,投入也逐步加大。


    最近几年,各互联网头部、腰部大厂都加大了质量度量体系相关的建设投入,出现了更多致力于测试效能的团队。在代码覆盖率层面涌现了大量的多语言、多端、实时、智能精准的自研、二开产品和方案。在测试技术社区和各类质效大会上,也能看到更多的公司如阿里、爱奇艺、有赞等分享自己在这些方向的探索和实践经验。


二、司内篇:

     视角回到公司内部,已知在各业务线团队中,有部分业务团队将代码覆盖率作为了上线的指标,同时在覆盖率数采集的手段也是非常多样化,多为手工、自动化手段并存。但在使用维度,其实时性、操作成本、多端采集、精准分析和定制化等方向上有非常大程度的不足。


     另外,回归研发中心内部,在覆盖率方面,也没有形成统一的流程、门禁及解读标准,没有提供便捷易用的工具平台支撑。因此,综合以上情况,基础架构QA团队打算借助开源工具Jacoco的基础能力,结合公司内部需求及流程,开发一款能够对项目质量、QA同学、研发同学有实际帮助的实时染色平台。



图片关于平台设计的思考

一、什么是好的(测试)工具?

      引用"What Is Good Testing in 2021 线上研讨会"的论点和结论,其核心不是工具的好坏,而是它们在哪些方面可以延伸使用人员的能力,扩展他们的能力。比如,可以帮助测试人拥有更高的效率、更好的记忆力、更棒的结果、更强的分析能力,可以让人能通过工具达成之前期待而不达的目标。


二、QA 同学在覆盖率方面有这样的诉求

  • 想知道当前自动化测试有效果吗,效果有多大

  • 测试结束临上线时,想知道后端的覆盖率有多高

  • 报告指标能易懂,能直观出是否达标

  • 生成覆盖率报告的操作最好能一键生成

  • Stage环境、QA、本地环境最好都能支持

  • 想学习后端代码,希望能通过平台走读学习代码

  • 通过覆盖率找到哪些功能和场景没有测试完全

  • 希望能有统一格式的报告,能作为测试结果的一部分贴在邮件中

  • 希望能针对核心功能的代码进行覆盖率统计


三、DEV 同学在覆盖率方面有这样的需要

  • 单元测试的覆盖率最好可以支持

  • 希望可支持只统计变更代码的覆盖率

  • 希望涉及变更类的其他相关类能识别出来

  • 帮助定位bug


四、能在哪些方面做提升和赋能

      以上的诉求和需要,是通过用户使用反馈及功能延展自然汇聚而来,目前在平台也基本上做了实现。但在以上需求中,最看重和赋能的是这几个方面:


① 消除人工生成报告过程的繁琐操作及权限限制,去做到一次配置,一键生成的效果

② 简化&优化Jacoco统计指标展示,使报告指标贴合大众理解

③ 提供增量报告功能,可以针对当前迭代变动的代码范围生成单独的覆盖率报告

④ 一键生成多关注维度的报告:全量报告、核心功能报告、增量报告、深度增量报告

⑤ 提供格式统一的覆盖率汇总报告,方便作为测试结果数据,方便放置到测试报告中

⑥ 报告即时存储,永不过期,方便随时可以分享和查阅,并保留后续数据分析功能的空间

⑦ 提供代码走读方案,让初学者能够学代码,开发者能辅助找Bug


图片
架构和流程设计

下方图示罗列了平台大致的系统分层与每层涉及的主要框架组件

图片 

 

平台报告生成相关的处理流程大致如下图所示

图片



图片实现成果和实现细节


    这个部分内容较长,将对最核心的几个功能做展开讲解,希望能对做类似方向的同学提供一些思路和技术实现的参考。


一、简化&优化Jacoco报告统计指标,使报告指标贴合大众理解

    报告最初使用原始指标时,大多数同学需要借助网络搜索或者咨询才能理解指标及数值含义。有的研发同学对报告原生的Missed统计口径表示不喜欢,建议使用Covered方式统计。另外,大家反馈对圈复杂度、指令覆盖度指标关注度不高。因此这里做了几处改动,改动结果参见下方对比:


原生指标:

图片


优化后指标:

图片


关键代码对比:

Point A -> 修改org.jacoco.report.html.HTMLFormatter.createTable()方法,增删改展示列名与取值,修改展示类型为BarColumn

图片



Point B -> 修改 org.jacoco.report.internal.html.table.BarColumn.footer(..)方法,将Missed统计口径改为Covered

图片


二、增量报告和深度增量

      增量报告的意义在于:当多次Commit中有N个类发生变更时,平台可以只针对这N个类生成增量覆盖率报告,用于评估迭代改动的代码是否都已测试完全。操作生成报告前,需先为应用设置代码分支、commit区间,之后再生成报告即可查看。

图片

 

      深度增量是增量报告的升级版本,假如选择的Commit区间,有3个类中的10个方法被修改,通过调用链分析,可能会有20个类使用了这十个变更的方法。那么,深度增量报告将会对3+20个类进行覆盖报告生成。但,由于当前的调用链方案有较大缺陷,将导致部分链路丢失且性能稍差,目前正在开发基于图数据库Neo4j + AST 的静态扫描方案,因此增量功能暂关闭开放。


     针对增量报告,代码实现主要分为Diff识别及非Diff类的报告排除生成两个部分。


 Point A: 其中Diff识别和业界方案基本一致,首先获取到变更列表

Git git = gitAdapter.getGit();Repository repo =  gitAdapter.getRepository();Ref localBranchRef = repo.exactRef(REF_HEADS + branchName);//设置commit idObjectId oldCommit = repo.resolve(from+"^{tree}");ObjectId newCommit = repo.resolve(to+"^{tree}");// 初始化jgit 对比treeObjectReader reader = repo.newObjectReader();CanonicalTreeParser oldTreeIter = new CanonicalTreeParser();oldTreeIter.reset(reader, oldCommit);CanonicalTreeParser newTreeIter = new CanonicalTreeParser();newTreeIter.reset(reader, newCommit);//  对比差异List<DiffEntry> diffs = git.diff().setOldTree(oldTreeIter).setNewTree(newTreeIter).setShowNameAndStatusOnly(true).call();


Point B: 根据Diff信息中行的变更位置,寻找其所属的方法,并将变更方法加入到过滤设置的实体类中

for (MethodDeclaration method : clazz.getMethods()) {Block methodBlock = method.getBody();if (methodBlock != null) {ASTNode node = methodBlock.getParent();int start = compilationUnit.getLineNumber(node.getStartPosition());int end = compilationUnit.getLineNumber(node.getStartPosition() + node.getLength());//如果变更行在当前方法的行起止范围,则判定为变更方法for (int position : change.getChangeLine()) {if (start <= position && position <= end) {Map methodInfo = new HashMap();methodInfo.put("methodName", method.getName().getFullyQualifiedName());methodInfo.put("params", method.parameters().toString());methodInfo.put("return", method.getReturnType2().toString());methodChangeList.add(methodInfo);}}}filters=(LyCoreFunctionFilter)combineFilers(filters,clazz,methodChangeList);}


Point C: 此处涉及的非Diff类排除,与后续的核心功能过滤方案是一致的,可参考后续的实现细节。


三、提供数据统计图表和格式化报告下载功能

    提供数据统计图表的意义,在于可以不用打开详细报告,就能直观获取主要的覆盖率数据,结合下载功能可方便的将格式一致的汇总数据作为测试结果依据,与测试报告同时发出。


     平台在设计提供统计数据时,兼顾了实时报告与历史报告的统计数据展示。因此需要对实时生成的报告进行数据统计并做持久化存储处理。


Step A: 为了报告中可以看到数据统计所属的项目、应用、环境、时间等信息,报告生成需要从ReportGenerator.create()方法开始改造。增加应用、环境、报告id等信息,并返回项目整体的覆盖率数据(类型 ICoverageNode)。

ReportGenerator generator = new ReportGenerator();generator.setInfos(" ",reportRelatedPaths.get(2),reportRelatedPaths.get(1),reportRelatedPaths.get(0),reportRelatedPaths.get(3));LyCoreFunctionFilter filter = (LyCoreFunctionFilter)lyCoreFunctionFilter.clone();filter.setType("full");ICoverageNode summaryData = generator.create(projectName,info.getApp_name(),info.getEnv_name(),info.getCretea_time_human(),filter);return convertResultData(filter.getType(),summaryData,info.getCretea_time_human(),info.getCreate_time(),url);


Step B: 为了在各层报告汇总增加下方汇总图表,需要对jacoco-report工程ReportPage.render()、ReportPage.body()方法进行改造,以增加Highcharts引入及做数据、Chart初始化

图片


    改造后的render()、body()方法关键代码如下:

public ICoverageNode render(String projectName, String appName, String envName,String timeStr) throws IOException {final HTMLElement html = new HTMLElement(folder.createFile(getFileName()), context.getOutputEncoding());html.attr("lang", context.getLocale().getLanguage());head(html.head());// 计算正在渲染的对象String location = analysisLocation(getFileName(), folder.getPath());// 为每个报告页添加highcharts jshtml.script("https://code.highcharts.com.cn/highcharts/highcharts.js");html.script("https://code.highcharts.com.cn/highcharts/modules/exporting.js");ICoverageNode summary = body(html.body(), projectName, appName, envName,getFileName().contains(".java"), location, timeStr);html.close();return summary;}
private ICoverageNode body(final HTMLElement body, String projectName,String appName, String envName, boolean isSourcePage,String location, String timeStr) throws IOException {body.attr("onload", getOnload());final HTMLElement navigation = body.div(Styles.BREADCRUMB);navigation.attr("id", "breadcrumb");breadcrumb(navigation, folder);// 添加汇总数据图表ICoverageNode summary = null;if (!isSourcePage) {// 创建汇总区域final HTMLElement graphDiv = body.div("graph");// 获取汇总数据Object summaryData = getSummaryNode(body);if (summaryData instanceof ICoverageNode) {summary = (ICoverageNode) summaryData;}// 创建汇总区域中标题、统计对象、数据等if (summary != null) {createTitleArea(graphDiv, summary, projectName, appName,envName, location, timeStr);}// 创建汇总区域图表if (summary != null) {summary(graphDiv, summary, projectName, appName, envName);graphDiv.close();} }// body.h1().text(getLinkLabel());取消链接Labelfinal HTMLElement table = body.div("table");content(table);// footer(body);取消底部Footerreturn summary;}


 补充说明:其中项目的汇总数据,是通过 getSummaryNode(body) 方法获取,随后逐层传递回ReportGenerator.create()方法,并进行持久化存储到Mysql中。


四、提供全局排除及核心功能覆盖设置功能

     全局排除功能,是为了排除项目中的非业务代码或外部包等无用代码,免除计入覆盖率统计与展示。而核心功能则是通过对项目代码进行分析,确定业务的核心功能涉及的类和方法有哪些,录入设置后便可只针对这些核心类方法进行覆盖率统计。设置界面如下图:

图片


     下面介绍下关键代码实现,这里核心增强代码在于jacoco-core工程analysis.Analyzer.analyzeAll()、analysis.Analyzer.createAnalyzingVisitor() 及 jacoco-report的internal.flow.ClassProbesAdapter.visitMethod() 等三个方法中。其中analyzeAll、createAnalyzingVisitor 控制了类和方法会不会计入覆盖率计算,visitMethod控制了方法会不会在方法列表展示及代码加亮。下方为增强后的关键代码:

public int analyzeAll(final File file, LyCoreFunctionFilter lyCoreFunctionFilter) throws IOException {int count = 0;try {if (file.isDirectory()) {for (final File f : file.listFiles()) {count += analyzeAll(f, lyCoreFunctionFilter);}} else {//如果class符合项目、核心功能包含排除设定则计为统计项if (classNeedToAnalysis(file.getPath(), lyCoreFunctionFilter)) {final InputStream in = new FileInputStream(file);try {count += analyzeAll(in, file.getPath(), lyCoreFunctionFilter);} finally {in.close();}}}}
private ClassVisitor createAnalyzingVisitor(final long classid,final String className,LyCoreFunctionFilter lyCoreFunctionFilter) {final ExecutionData data = executionData.get(classid);final boolean[] probes;final boolean noMatch;if (data == null) {probes = null;noMatch = executionData.contains(className);} else {probes = data.getProbes();noMatch = false;}// 如启用代码走读模式且类有访问数据,则正常visit,否则visitor返回空if(lyCoreFunctionFilter.isCodeWalkthrough()){if(computeClassHasVisit(probes)){final ClassCoverageImpl coverage = new ClassCoverageImpl(className,classid, noMatch);final ClassAnalyzer analyzer = new ClassAnalyzer(coverage, probes,stringPool, lyCoreFunctionFilter) {@Overridepublic void visitEnd() {super.visitEnd();coverageVisitor.visitCoverage(coverage);}};return new ClassProbesAdapter(analyzer, false);....}
private boolean computeClassHasVisit(boolean[] probes) {for(int i=0;i<probes.length;i++){if(probes[i]) return true;}return false;}
@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);// 查询方法名是否在项目排除、核心功能排除范围内boolean flag = judgeMethodIdExclude(cv,name);if (mv == null || !flag) {// We need to visit the method in any case, otherwise probe ids// are not reproduciblemethodProbes = EMPTY_METHOD_PROBES_VISITOR;} else {methodProbes = mv;}if(flag) {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);...}
// 判断方法是否应被统计private boolean judgeMethodIdExclude(ClassProbesVisitor cv,String visitMethodName){boolean flag = false;// 排除构造函数统计和展示if(visitMethodName.equals("<init>")){return false;}String name = this.name.replace("/",".");LyCoreFunctionFilter lyCoreFunctionFilter = cv.getLyCoreFunctionFilter();List<String> coreMethodExcludes = lyCoreFunctionFilter.getCoreMethodExcludes();List<String> coreMethodIncludes = lyCoreFunctionFilter.getCoreMethodIncludes();if(coreMethodIncludes.size()>0 ) {for (String rule : coreMethodIncludes) {String[] arr = rule.split("\\.");String methodName = arr[arr.length - 1];String clzName = rule.replace("." + arr[arr.length - 1], "");if (name.equals(clzName) ) {if(methodName.equals(visitMethodName)) {flag = true;break;}}}...

图片一些有趣的功能


    在工具平台使用过程中,除了常规的服务端覆盖率生成场景,还遇到了多个有趣且必要的场景。如Exec及 Diff的Debug功能、单元测试的核心功能覆盖率获取支持、需获取目标范围代码在pom依赖中、初学者在本地调试走读熟悉代码等场景


一、实时/历史报告的覆盖率数据及CodeDiff查看

    Exec的Debug是由于刚接入的应用及长时间不用的应用,从服务端获取数据可能出现异常情况,这个功能是为了Debug展示统计数据获取结果。Diff的Debug功能是由于,有时需要核对增量报告中的覆盖数据和范围是否正确,这里提供了Diff对比功能,就不用去Git的Web页面去进行对比了。另外历史覆盖力报告,也提供了Debug查看,方便查看历史报告对比的是哪个分支产生的Diff、及Diff都是什么。

图片


  下方是关键代码实现:

核心代码:

   Point A: 首先,覆盖率数据是通过jacococli.jar 解析和返回exec内容,再通过接口返回给前端进行展示。

图片


   Point B: 进行展示diff对比的关键,在于获取某个类在commit前后的源码,拿到源码后就可以在前端通过对比工具进行对比和加亮展示了。此处获取源码的方案在网络上没有公开方案,笔者也是经历了多次尝试才可用的。

图片

 

     Point C: 需要提及的是,上个步骤获取的源码前后内容,也需要做持久化入库处理否则历史报告就不能获取某一报告的Diff的元数据了。


二、为开发者的单元测试,提供全量、核心、增量功能的覆盖率报告方案

     为了单测项目也能使用到平台的功能,需要让普通项目和SDK项目都能便捷的转化为Web项目,同时保留单测能力,并通过Jacoco On-the-fly模式将覆盖数据接入。经过调研和尝试我们提供了一个工具类,操作上通过加入SpringBoot的Pom依赖并设置和运行这个工具类,来达到运行基于Junit、TestNG的单测。在做了转化改造后,在单测前、中、后都可随时获取覆盖率数据和生成报告。下方是工具类的关键代码:

@SpringBootApplicationpublic class CodeCoverageSupportApplication{public static void main(String[] args) throws InterruptedException {SpringApplication.run(CodeCoverageSupportApplication.class, args);//<pacakageName,methodName>List<String> scanedClasses= scanPackageClasses("fullPackageName");runDefinedClasses(scanedClasses);}private static void runDefinedClasses(Map<String, String> toBeRunClassList) {try {for (String className: toBeRunClassList) {Class<?> clazz = Class.forName(className);//运行TestNG注解的用例类TestNG testng = new TestNG();testng.setTestClasses(new Class[]{clazz});testng.run();//运行Junit注解的用例类JUnitCore.runClasses(clazz);}} catch (Exception e) {e.printStackTrace();}}}


三、为项目中的Pom依赖,提供全量、核心、增量功能的覆盖率报告方案

    这个需求也是用户遇到的实际场景,有时项目的部分代码、部分服务被打成jar包通过pom引入工程。这对于平台获取源码及生成报告产生了难度,后续的解决方案是,在平台中进行配置,输入pom标签,并指定Jar、源码Jar的全名。然后平台服务去单独生成一个新的pom文件,并通过mvn命令将依赖下载,再根据已输入全名的Jar包解压出目标代码来生成报告。操作界面与关键代码如下:

图片


private void downloadPom(Environment env) {try {String dependencyString = env.getPom_dependency();String byteFileName = env.getPom_byte_name();String sourceFileName = env.getPom_source_name();String pomFileBasePath = pomBasePath +  "/env_" + env.getId();createOrClearFolder(pomFileBasePath);createPomFile(pomFileBasePath,dependencyString);downloadMavenDependency(pomFileBasePath);unzipJars(pomFileBasePath,byteFileName,sourceFileName);}catch (Exception e){e.printStackTrace();}}
private void unzipJars(String pomFileBasePath,String byteFileName,String sourceFileName) throws IOException {shell.execute("cd " + pomFileBasePath + "/target/dependency;unzip " + byteFileName + " -d " + byteFileName.split(".jar")[0]);shell.execute("cd " + pomFileBasePath + "/target/dependency;unzip " + sourceFileName + " -d " + sourceFileName.split(".jar")[0]);}
private void downloadMavenDependency(String pomFileBasePath) throws IOException {shell.execute("source ~/.bash_profile;source /etc/profile;cd " + pomFileBasePath + ";" + "mvn dependency:sources;mvn dependency:copy-dependencies;mvn dependency:copy-dependencies -Dclassifier=sources");}
private void createPomFile(String pomFileBasePath,String dependencyString) throws IOException {shell.execute("echo '<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" +"<project xmlns=\"http://maven.apache.org/POM/4.0.0\"\n" +" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\"\n" +" xsi:schemaLocation=\"http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd\">\n" +" <modelVersion>4.0.0</modelVersion>\n" +"\n" +" <groupId>coloring-service-pom-downloader</groupId>\n" +" <artifactId>pom-downloader</artifactId>\n" +" <version>1.0</version>\n" +" <packaging>jar</packaging>\n" +" <name>pom-downloader</name>\n" +"\n" +" <dependencies>\n" +dependencyString +"\n</dependencies>\n" +"</project>' > " + pomFileBasePath + "/pom.xml");}


四、为初学者提提供本地走读代码的方案

    由于接口测试自动化的高效与高产出比,越来越受重视和普及。QA人员也有对代码深入学习的需求,但是受知识和技能限制,在初学时不容易看懂代码。这个功能是为初学者提供的走读功能。帮助用户方便的查看每个页面点击或接口调用操作后,服务端都运行了哪些代码行。


    在实现方案上,核心需提供的是环境隔离或者用户隔离,要保证不同用户的操作不互相干扰。其次是能看到每个接口调用或者UI操作对应的服务端运行了哪些代码。爱奇艺提供的方案是在Agent采集覆盖率时,区分了不同的IP来达到用户隔离,这样的成本偏大,我们采用了平台基础提供的多环境支持来实现,如下图:

图片


    用户可以在本地启动后端服务,然后选择自己的本地环境。每次分析一个UI/ 接口操作的代码执行之前,先点击黄色“重置覆盖数据”,再进行操作,操作完成后点击手动刷新报告,就可以看到受到操作触发运行的代码了(见下图)。

图片


图片这样的覆盖率数据好吗?
是否满足上线标准?


      上面介绍了平台的主要目标和实现思路,当用户业务已成接入平台,并成功获取到了想要范围的覆盖率数据,这时如何评估覆盖率的好坏,以及是否符合了上线标准呢?


      测试覆盖率的通过标准制定是个业内难题,每个项目甚至在不同的开发/运营阶段/迭代情况下,对于通过标准的阈值设置都是浮动的。除了上述的因素,高覆盖率也意味着更多的测试时间或更高的自动化测试覆盖,导致设置通过标准时,还需要考虑ROI。


      下表是依据网络上的公开数据,列举了两家头部企业在覆盖率指标上的实践设置:



标准分级

全量行覆盖

核心功能行覆盖

谷歌标准

可接受 Acceptable

60%

90%

推荐 Commendable

75%

99%

榜样 Exemplary

90%


阿里标准

最低标准

70%

100%

     目前在基础架构QA团队,针对基础组件类的框架或SDK,目前设置的上线标准为核心行覆盖率80%,全量行覆盖率60%,下方为项目报告截图:

图片

图片






写在最后


① 提供覆盖率数据,直接目标是为了能寻找漏测、找到代码死角,反补测试用例、自动化用例。最终目标是提升迭代及回归测试质量,为项目上线提供数据支撑,提供信心支持。同时为想更深入学习业务代码的同学再提供一条代码走读的路径。


② 代码覆盖率不是银弹,100%的覆盖率不能证明程序没有Bug,但是覆盖率不足能说明程序有风险。同时也要为团队设置合理的通过标准,避免成为覆盖率的奴隶,不要做成为了覆盖率而覆盖率。


③ 平台尚在开发维护阶段,存在一些Bug及功能缺失,如调用链分析还不足够精准、微服务、多环境及不同类型的报告聚合尚不支持,部分类覆盖率数据缺失等问题还需要持续解决。毕竟罗马不是一天建成的,但如果业务有需要,平台还会继续做完善。




参考&辅助文献:

谷歌测试最佳实践指南:http://testing.googleblog.com/2020/08/code-coverage-best-practices.html

阿里巴巴Java开发手册嵩山版:http://navo.top/ZbyAfa

What Is Good Testing in 2021 线上研讨会:https://www.youtube.com/watch?v=3ji693Z4tbU

ASM 与 ClassReader.visit() 介绍帖:http://www.360doc.cn/article/13328254_792734411.html

Visitor 模式介绍贴访问者模式(Visitor模式)详解:http://c.biancheng.net/view/1397.html



图片

分享,点赞,在看,安排一下?

继续滑动看下一个
同程旅行技术中心
向上滑动看下一个