当业务快速发展,新业务不断出现,开发同学粗心的情况下,难免会出现少测漏测的情况,如何保证新增代码有足够的测试覆盖率?当一段正常的代码,开发却修改了,测试人员没有测试其功能,如果保证能够发现?
所以代码覆盖测试是有必要的,代码覆盖只能保证这行代码执行了,不能保证其是否正确。寻找相关工具,发现最接近的是jacoco。jacoco 接入也比较简单,在安卓上用的offline 模式,不过jacoco 默认是全部插入探针代码,所以需要对其改造,只对增量代码插入探针。
大致流程
需求开发流程:项目管理是git,master 分支是线上分支,开发人员在开发某个需求时,会从master 拉取新分支开发,测试完成,封板上线后,会把分支合到master 上。确保master 永远是线上代码。
测试流程:开发人员在开发时,是开发包,开发完成会打测试包给测试人员,测试人员反馈问题,开发修改代码,再次打包,这一过程可能会重复多次。测试通过,告知开发打正式包。最终上线正式包。我们对 开发包称为debug 包,测试包称为beta 包,正式包称为release 包。buildType中对应三种打包方式。
buildTypes {
debug {...
}
release {...
}
beta {...
}
}
其中debug 包是开发人员直接安装运行,beta与release是通过 jenkins 连打包机打出来供测试人员下载。
所以我们在插入探针代码时,只需要对beta 包插入,然后测试人员下载,手动测试,本地生成数据,上传数据给服务器。供后续生成报告时使用,使用开关如下:
jacocoCoverageConfig {
jacocoEnable isBeta()
....
}
def isBeta() {
def taskNames = gradle.startParameter.taskNames
for (tn in taskNames) {
if (tn == "assembleBeta" || tn == "ttpPackageBeta") {
return true
}
}
return false
}
在打release 包时,调用生成报告的任务。查看本次增量代码的覆盖率报告,输出报告到apk目录,供开发人员查看,由开发人员判断这个覆盖率是否合理。当然你也可以在低于 百分之多少 时抛出异常,中断打包。
框架的整体流程如下:
首先分为三块:
1、编译时:这里说的是开关为打开的情况,编译时主要是获取两个分支的差异方法集合,然后调用jacoco提供的方法,对差异方法代码插入探针。
2、App 运行时:测试人员在运行带有探针的包,会把探针运行数据.ec 保存在本地,下次再打开app时上传上次数据。
3、生成报告:打正式包时,下载此项目版本所有的覆盖数据,和编译时一样,获取两分支差异方法集合。调用jacoco方法,生成最终的差异方法报告。
下面分别对各个流程中一些技术难点说明。
一、编译时
编译时是通过gradle TransForm实现的,TransForm可以对字节码进行修改。主要过程分为三大步,class git 管理、获取差异方法、对diff方法插入探针。
大致代码如下:
JacocoTransform.groovy
@Override
void transform(TransformInvocation transformInvocation) throws TransformException, InterruptedException, IOException {
……
if (!dirInputs.isEmpty() || !jarInputs.isEmpty()) {
if (jacocoExtension.jacocoEnable) {
//copy class到 app/classes
copy(transformInvocation, dirInputs, jarInputs, jacocoExtension.includes)
//提交classes 到git
gitPush(jacocoExtension.gitPushShell, "jacoco auto commit")
//获取差异方法集
BranchDiffTask branchDiffTask = project.tasks.findByName('generateReport')
branchDiffTask.pullDiffClasses()
}
//对diff方法插入探针
inject(transformInvocation, dirInputs, jarInputs, jacocoExtension.includes)
}
}
1.1 class git 管理
首先项目中是有java 和kotlin 源码。如果解析源码文件,需要对两种语言适配。 而无论是java 还是kotlin ,编译完都是 .class,解析class 可以通过 ASM 。jacoco 也需要ASM,所以我们需要保存源码对应的 class 文件,当然也不能全部保存,只保存自己的包名的,例如 前辍 com.ttpc 。一些第三方的源码,我们认为它是稳定的,没问题的,也就没必要对其进行覆盖测试,把编译后的class copy到项目的app 目录下,与src 同级,例:
这些 class 也是需要通过git 管理的。然后自动执行git add、commit、push 命令,提交到git 服务器。因为通过 git 可以获得两个服务器分支差异的文件名。
1.2、获取两个分支差异方法集
其中编译时与生成报告时都需要获取 “两个分支差异方法集”。其中一个分支就是当前开发分支,一个是master 分支(可配置)。差异方法定义无论是新增方法,还是修改了方法,那怕修改一行代码,都算是差异方法,那个整个方法都要覆盖到。
以dev_3 为开发当前分支,master 为稳定分支举例。当前分支通过 git name-rev --name-only HEAD
获取 。
1.2.1、获取差异文件名集
通过 git 可以获得两个分支差异的文件名。
git diff origin/dev_3 origin/master --name-only
输出如下:
通过 \n 分隔,得到差异文件名集合。通过后辍过滤非 .class 与非 包名 文件。
ok,现在得到两分支差异class文件名,但是我们需要精确到差异方法。
1.2.2、copy 两分支差异文件
接下来,切换到master 分支,把所有class copy 到一个临时目录。
再切回 当前dev_3分支,把所有class copy 到临时目录。(临时目录和项目同级,为了不影响项目)
删除那些不在 差异文件名集合 的文件,得到差异文件集。
切换分支+copy 如下:注意是强制切换,会导致工作区丢失
#!/bin/sh
gitBran=$1 # 要切换的分支
workDir=$2 #当前目录
outDir=$3 # copy 输出目录
git checkout -b $gitBran origin/$gitBran
git checkout -f $gitBran
git pull
cp -r "${workDir}/app/classes" $outDir
目录如下:
1.2.3、生成差异方法集
对两个分支目录的所有class,使用ASM读取class,访问方法,收集方法信息,关键代码如下:
public class DiffClassVisitor extends ClassVisitor {
……
@Override
public MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) {
MethodVisitor mv = super.visitMethod(access, name, desc, signature, exceptions);
final MethodInfo methodInfo = new MethodInfo();
methodInfo.className = className;
methodInfo.methodName = name;
methodInfo.desc = desc;
methodInfo.signature = signature;
methodInfo.exceptions = exceptions;
mv = new MethodVisitor(Opcodes.ASM5, mv) {
StringBuilder builder = new StringBuilder();
//访问方法一个参数
@Override
public void visitParameter(String name, int access) {
builder.append(name);
builder.append(access);
super.visitParameter(name, access);
}
//访问方法一个注解
@Override
public AnnotationVisitor visitAnnotation(String desc, boolean visible) {
builder.append(desc);
builder.append(visible);
return super.visitAnnotation(desc, visible);
}
//访问ldc指令,也就是访问常量池索引
//与方法体有关,需要参与md5
@Override
public void visitLdcInsn(Object cst) {
//资源id 每次编译都会变,所以不参与 0x7f010008
if (!(cst instanceof Integer) || !isResourceId((Integer)cst)) {
builder.append(cst.toString());
}
super.visitLdcInsn(cst);
}
……
//方法访问结束
@Override
public void visitEnd() {
String md5 = Util.MD5(builder.toString());
methodInfo.md5 = md5;
DiffAnalyzer.getInstance().addMethodInfo(methodInfo, type);
super.visitEnd();
}
}
其中MethodVisitor 有很多 visitXxx方法,都是方法的基本信息与一些指令。然后对其md5 ,得到方法的md5签名。通过classsName,methodName,desc来定位同一方法,然后比较其md5是否一致。一致则代表未修改过代码。这里要注意的是visitLdcInsn 访问常量池指令,因为每次编译时,资源id都会不一致,所以要过滤掉资源id。
当所有的class 访问结束,通过两个分支方法集,得到差异方法集。
public void diff() {
if (!currentList.isEmpty() && !branchList.isEmpty()) {
for (MethodInfo cMethodInfo : currentList) {
boolean findInBranch = false;
for (MethodInfo bMethodInfo : branchList) {
if (cMethodInfo.className.equals(bMethodInfo.className)
&& cMethodInfo.methodName.equals(bMethodInfo.methodName)
&& cMethodInfo.desc.equals(bMethodInfo.desc)) {
if (!cMethodInfo.md5.equals(bMethodInfo.md5)) {
diffList.add(cMethodInfo);
}
findInBranch = true;
break;
}
}
if (!findInBranch) {
diffList.add(cMethodInfo);
}
diffClass.add(cMethodInfo.className);
}
}
}
1.2.4、插入探针代码
调用jacoco 的instrument ,把插入探针后的字节码写入文件
ClassInjector.class
@Override
void processClass(File fileIn, File fileOut) throws IOException {
if (shouldIncludeClass(fileIn)) {
InputStream is = null;
OutputStream os = null;
try {
is = new BufferedInputStream(new FileInputStream(fileIn));
os = new BufferedOutputStream(new FileOutputStream(fileOut));
// For instrumentation and runtime we need a IRuntime instance
// to collect execution data:
// The Instrumenter creates a modified version of our test target class
// that contains additional probes for execution data recording:
final Instrumenter instr = new Instrumenter(new OfflineInstrumentationAccessGenerator());
final byte[] instrumented = instr.instrument(is, fileIn.getName());
os.write(instrumented);
} finally {
closeQuietly(os);
closeQuietly(is);
}
} else {
FileUtils.copyFile(fileIn, fileOut);
}
}
插入代码关键在ClassInstrumenter.visitMethod,通过判断是否是差异方法来选择是否插入,达到只对差异方法插入代码的目的
ClassInstrumenter.class
@Override
public MethodVisitor visitMethod(final int access, final String name,
final String desc, final String signature,
final String[] exceptions) {
if (DiffAnalyzer.getInstance().containsMethod(className, name, desc)) {
InstrSupport.assertNotInstrumented(name, className);
final MethodVisitor mv = cv.visitMethod(access, name, desc, signature,
exceptions);
if (mv == null) {
return null;
}
final MethodVisitor frameEliminator = new DuplicateFrameEliminator(mv);
final ProbeInserter probeVariableInserter = new ProbeInserter(access,
name, desc, frameEliminator, probeArrayStrategy);
return new MethodInstrumenter(probeVariableInserter,
probeVariableInserter);
} else {
return super.visitMethod(access, name, desc, signature, exceptions);
}
}
源码与插入探针后源码对比如下:
至此,运行时 工作 transForm 完成。
二、运行时
其实jacoco是否覆盖原理,就是每个类都申请了一个boolean数组,然后每一行代码前都插入 array[x]=true
,当代码执行,array[x] 也就为true,也就表明代码执行过。然后在页面关闭时,保存所有boolean 数组到本地成ec文件。下次打开app,把上一次的数据上传到服务器。因为这个工具只是用在开发测试阶段,所以服务器直接布在局域网即可。
CodeCoverageManager.class
写入数据到文件
private void writeToFile() {
if(filePath==null) return;
OutputStream out = null;
try {
out = new FileOutputStream(filePath, false);
IAgent agent = RT.getAgent();
out.write(agent.getExecutionData(false));
Log.i(TAG, " generateCoverageFile write success");
} catch (Exception e) {
e.printStackTrace();
Log.e(TAG, " generateCoverageFile Exception:" + e.toString());
} finally {
close(out);
}
}
三、生成报告
生成报告是主动触发的,在任何时候都可以生成报告,也可以在打正式包时自动调用生成报告任务。生成报告大致逻辑如下:
1、从服务器上下载 此项目此版本 所有的ec 文件,也就是在运行时上传的那些数据文件。
2、同编译时逻辑一样,获取差异方法集
3、最后调用jacoco 的生成报告方法,参数: ec文件夹,class 文件夹路径,源码路径,报告输出路径。即可生成一份html报告。
File exec = new File("/Users/wzh/gitlab/Android-Jacoco/app/build/outputs/coverage");
List<File> sourceDirs = new ArrayList<>();
sourceDirs.add(new File("/Users/wzh/gitlab/Android-Jacoco/app/src/main/java"));
List<File> classDirs = new ArrayList<>();
classDirs.add(new File("/Users/wzh/gitlab/Android-Jacoco/app/classes"));
File reportDir = new File("/Users/wzh/gitlab/Android-Jacoco/app/build/report");
ReportGenerator generator = new ReportGenerator(exec.getAbsolutePath(), classDirs, sourceDirs, reportDir);
generator.create();
在生成报告内部会对ec文件夹内的ec进行合并:
因为多次运行会有多份boolean 数组,所以需要合并取或。如果某一个类,修改了源码,导致两个包代码不一样,会导致这个类的boolean数组合并失败,这时需要把老的类boolean数组丢弃掉。
ExecutionDataStore.class
//合并数据,异常删除老数据
public void put(final ExecutionData data) throws IllegalStateException {
final Long id = Long.valueOf(data.getId());
final ExecutionData entry = entries.get(id);
if (entry == null) {
entries.put(id, data);
names.add(data.getName());
} else {
try{
entry.merge(data);
}catch (IllegalStateException e){
// e.printStackTrace();
if(entry.getSessionInfo()!=null && data.getSessionInfo()!=null){
if(entry.getSessionInfo().getDumpTimeStamp()<data.getSessionInfo().getDumpTimeStamp()){
System.out.println("old ec data ,remove "+entry);
entries.remove(id);
entries.put(id, data);
}
}
}
}
}
最终报告是个html,打开输出报告如下:
最外面可以看到整体的覆盖率,点进去可以查看某个类的覆盖率。
- 绿色:表示行覆盖充分。
- 红色:表示未覆盖的行。
- 空白色:代表方法未修改,无需覆盖。
- 黄色棱形:表示分支覆盖不全。
- 绿色棱形:表示分支覆盖完全。
四、总结
本工具基于jacoco源码,做到了两个git分支 增量方法级的代码覆盖。当然,一些问题也是有的。既然是方法级,如果只改了方法的一行代码,那么整个方法,所有的分支都需要重新覆盖到。在编译时,会自动执行一些git 命令。例如强制切换分支(为了兼容jenkins 远程打包),这会导致工作区内容的丢失。还有开发流程,可能并不适用于你公司的开发流程。(在我公司项目中,在debug与release都是关闭的,只有beta包打开)当然,大家也是可以修改的,适配出自己公司流程的代码覆盖工具。
代码覆盖只能保证这行代码执行了,不能保证其是否正确。可以用来避免一些开发可能修改了线上某个异常,未通知测试人员,测试人员不会去测相关功能。而测试结束了,覆盖率却很低,说明有部分代码修改过,却没有执行过,这是容易引起问题的。而最终的报告,只是一个告知开发的作用,需要由开发人员来判断这个覆盖率到底行不行,会不会引起问题?
五、如何接入
具体接入使用方式见AndJacoco github。
美团-Android增量代码测试覆盖率工具
JaCoCo-Source-Code
Java代码覆盖率工具JaCoCo-原理篇