标题: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 id
ObjectId oldCommit = repo.resolve(from+"^{tree}");
ObjectId newCommit = repo.resolve(to+"^{tree}");
// 初始化jgit 对比tree
ObjectReader 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 js
html.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());取消链接Label
final HTMLElement table = body.div("table");
content(table);
// footer(body);取消底部Footer
return 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) {
public 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;
}
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);
// 查询方法名是否在项目排除、核心功能排除范围内
boolean flag = judgeMethodIdExclude(cv,name);
if (mv == null || !flag) {
// We need to visit the method in any case, otherwise probe ids
// are not reproducible
methodProbes = EMPTY_METHOD_PROBES_VISITOR;
} else {
methodProbes = mv;
}
if(flag) {
return new MethodSanitizer(null, access, name, desc, signature,exceptions) {
public 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的单测。在做了转化改造后,在单测前、中、后都可随时获取覆盖率数据和生成报告。下方是工具类的关键代码:
public 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
分享,点赞,在看,安排一下?