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

·  阅读 2705

当业务快速发展,新业务不断出现,开发同学粗心的情况下,难免会出现少测漏测的情况,如何保证新增代码有足够的测试覆盖率?当一段正常的代码,开发却修改了,测试人员没有测试其功能,如果保证能够发现? 所以代码覆盖测试是有必要的,代码覆盖只能保证这行代码执行了,不能保证其是否正确。寻找相关工具,发现最接近的是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-原理篇

分类:
Android
收藏成功!
已添加到「」, 点击更改