增量代码覆盖率工具

## 背景
目前有赞共享技术团队测试介入的微服务应用有几百个,大部分底层应用的单测覆盖率在 70% 以上,同时测试组提供的多纬度集成测试自动化的覆盖率也在 70% 以上。有赞的业务发展非常快,当存量代码较多时,新项目功能测试的整体覆盖率偏低是正常现象,另外开发提测时,并不能依据已有的全量覆盖率来判断对新增代码的自测完成度,基于这个背景,我们研发了增量代码覆盖率工具,作为项目质量的参考纬度之一,支持统计功能测试、单测和集成测试,并集成到了 DevOps 平台。
## 方案设计
有赞的 JAVA 代码覆盖率工具用的是 JaCoCo ,它是一个开源的覆盖率工具,支持 JVM ,使用方法非常灵活,很多第三方的工具提供了对 JaCoCo 的集成,如 sonar、Jenkins 等。

关于 JaCoCo 的注入原理以及注入方式,在官方网站上写的非常详细了,网上翻译修改的资料也非常多,不做过多赘述。经过对比,我们在统计功能测试覆盖率以及集成测试覆盖率时,选择的是 On-the-fly 模式。原因是 On-the-fly 方式无须入侵应用启动脚本,只需在 JVM 中通过 -javaagent 参数指定 jar 文件启动 Instrumentation 的代理程序,代理程序在通过 Class Loader 装载一个 class 前判断是否需要注入 class 文件,将统计代码插入 class ,测试覆盖率分析就可以在 JVM 执行测试的过程中完成。


(图片来源 官网

我们设计的方案也是基于 JaCoCo 做相应改造,生成我们所需要的覆盖率模型,并通过 JaCoCo 开放的 API 实现相关功能。这里面主要需要解决的点在获取增量代码并解析生成覆盖率上。可以拆分成如下几个步骤:
1. 获取测试完成后的 exec 文件(二进制文件,里面有探针的覆盖执行信息);
2. 获取基线提交与被测提交之间的差异代码;
3. 对差异代码进行解析,切割为更小的颗粒度,我们选择方法作为最小纬度;
4. 改造 JaCoCo ,使它支持仅对差异代码生成覆盖率报告;

整体的流程如上图,下面针对整个流程,分别说下我们是怎么做的。

### 对 JaCoCo 的改造
在讲具体实现步骤之前,先谈下我们对 JaCoCo 做的改造思路。 JaCoCo 的注入逻辑用的是 ASM 库,对于没有接触过字节码注入技术的测试同学来说,改造注入逻辑需要花费较多时间,而对该工具从调研到完成的预期时间,只有不到 10 人日,所以我们用了一个比较快速简单的方式:前面生成全量覆盖率数据的流程不变,只对解析 exec 文件生成报告做改造,生成我们所需要的覆盖率模型。

JaCoCo 对 exec 的解析主要是在 Analyzer 类的 analyzeClass(final byte[] source) 方法。这里面会调用 createAnalyzingVisitor 方法,生成一个用于解析的 ASM 类访问器,继续跟代码,发现对方法级别的探针计算逻辑是在 ClassProbesAdapter 类的 visitMethod 方法里面。所以我们只需要改造 visitMethod 方法,使它只对提取出的每个类的新增或变更方法做解析,非指定类和方法不做处理。

改造后的核心代码片段如下:

### 获取 exec
我们在部署 qa 项目 java 应用服务时,指定了 -javaagent 参数的 output 为 tcpserver ,并指定可用端口。官方对 output 的参数说明见下图,默认是 file ,目前有赞的集成测试覆盖率用的是这种方式,所以必须要将 JVM 停掉以后才能将信息 dump 到指定文件。


(图片截自 JaCoCo 官网)

我们获取 exec 文件是通过 tcp 方式获取的,所以 javaagent 参数设定如下:
output=tcpserver,address=0.0.0.0,port=XXXX ,然后将 javaagent 参数注入 JVM ,这部分由运维团队配合支持,完成了持续交付项目下的 java 应用自动注入 JVM 。

以上步骤完成以后,在我们工具内就可以通过 JaCoCo 开放出来的 API 进行 exec 文件获取,部分代码片段如下:

public void dumpData(String localRepoDir, List<IcovRequest> icovRequestList) throws IOException {
        icovRequestList.forEach(req -> req.validate());
        icovRequestList.parallelStream().map(icovRequest -> {
            String destFileDir = ...;
            String address = icovRequest.getAddress();
            try {
                final FileOutputStream localFile = new FileOutputStream(destFileDir + "/" + DEST_FILE_NAME);
                final ExecutionDataWriter localWriter = new ExecutionDataWriter(localFile);
                final Socket socket = new Socket(InetAddress.getByName(address), PORT);
                final RemoteControlWriter writer = new RemoteControlWriter(socket.getOutputStream());
                final RemoteControlReader reader = new RemoteControlReader(socket.getInputStream());
                reader.setSessionInfoVisitor(localWriter);
                reader.setExecutionDataVisitor(localWriter);
                writer.visitDumpCommand(true, false);
                if (!reader.read()) {
                    throw new IOException("Socket closed unexpectedly.");
                }
                ...
            } ...
            return null;
        }).count();
    }

### 获取差异代码并切割到方法粒度
这部分会涉及到较多的 Git 操作,我们是用 JGit 实现的。JGit 是一个用 Java 写成的功能比较健全的 Git 的实现,它在 Java 社区中被广泛使用。在这一步的主要流程是获取基线提交与被测提交之间的差异代码,然后过滤一些需要排除的文件(比如非 Java 文件、测试文件等等),对剩余文件进行解析,将变更代码解析到方法纬度,部分代码片段如下:

private List<AnalyzeRequest> findDiffClasses(IcovRequest request) throws GitAPIException, IOException {
        String gitAppName = DiffService.extractAppNameFrom(request.getRepoURL());
        String gitDir = workDirFor(localRepoDir,request) + File.separator + gitAppName;
DiffService.cloneBranch(request.getRepoURL(),gitDir,branchName);
        String masterCommit = DiffService.getCommitId(gitDir);
        List<DiffEntry> diffs = diffService.diffList(request.getRepoURL(),gitDir,request.getNowCommit(),masterCommit);
        List<AnalyzeRequest> diffClasses = new ArrayList<>();
        String classPath;
        for (DiffEntry diff : diffs) {
            if(diff.getChangeType() == DiffEntry.ChangeType.DELETE){
                continue;
            }
            AnalyzeRequest analyzeRequest = new AnalyzeRequest();
            if(diff.getChangeType() == DiffEntry.ChangeType.ADD){
                ...
            }else {
                HashSet<String> changedMethods = MethodDiff.methodDiffInClass(oldPath, newPath);
                analyzeRequest.setMethodnames(changedMethods);
            }
            classPath = gitDir + File.separator + diff.getNewPath().replace("src/main/java","target/classes").replace(".java",".class");
            analyzeRequest.setClassesPath(classPath);
            diffClasses.add(analyzeRequest);
        }
        return diffClasses;
    }

###生成覆盖率报告
这步是用 JaCoCo 开放的 API 和改造后的 JaCoCo 来实现的,根据前两步获取到的 class 和差异方法信息,用改造后的 JaCoCo 去解析 exec 文件,使它按照我们的覆盖率模型,只生成增量代码部分的覆盖率报告。 生成报告的大致流程如图:

生成报告和获取报告的触发时点是不同的,生成报告涉及较多的 Git 和 IO 操作,处理时间会比较长,跟 DevOps 的交互上是通过异步方式进行处理。而获取报告是通过批量查询数据库信息来获取所需的报告信息。所以生成报告接口需要保存覆盖率报告以及行覆盖率信息并入库,将覆盖率报告地址在 tengine 里面配置后,DevOps 平台即可实现访问,部分代码片段如下:

private IBundleCoverage analyzeStructure(List<AnalyzeRequest> analyzeRequests,String sourceDirectory) throws IOException {
        final CoverageBuilder coverageBuilder = new CoverageBuilder();
        for (AnalyzeRequest analyzeRequest:analyzeRequests) {
            final Analyzer analyzer = new Analyzer(
                    execFileLoader.getExecutionDataStore(), coverageBuilder, analyzeRequest.getMethodnames());
            File f = new File(analyzeRequest.getClassesPath());
            InputStream in = new FileInputStream(f);
            analyzer.analyzeClass(in, sourceDirectory);
        }
        for (final IClassCoverage cc : coverageBuilder.getClasses()) {
            totalCoveredCount = totalCoveredCount + cc.getLineCounter().getCoveredCount();
            totalCount = totalCount + cc.getLineCounter().getTotalCount();
        }
        coveredRatio = totalCoveredCount*100/totalCount;
        if(reportResDAO.getInfoByPrjName(prjName).size() >= 1)
  reportResDAO.updateTotalCov(prjName,appName,coveredRatio,new Date());
        else{
            ...
            reportResDAO.insertReportInfo(reportResDO);
        }
        return coverageBuilder.getBundle(title);
    }

## 效果
最终效果如下图,在图中是某个 service 的实现类,实际上在最新的代码中有 14 个方法,但是只会对变更或新增的 4 个方法进行覆盖率统计与显示:

另外在覆盖率报告中显示的覆盖率数据也只是对变更的方法进行统计,不会按照全量代码进行覆盖率计算。对于没有进行测试覆盖的类,覆盖率显示为 0:

## 与 DevOps 工具集成
目前我们的增量覆盖率工具已经集成到运维的 DevOps 平台,所有接入持续交付的项目在测试完成后,触发生成提测分支的增量代码覆盖率、展示报告,整个流程全自动化。与 DevOps 平台的整体交互大致如下图:

OPS 即有赞的 DevOps 平台,icov 是我们增量代码覆盖率工具提供的服务。 icov 通过 tcp 方式从服务器端获取 exec 文件, OPS 触发 icov 生成报告,并从 icov 获取报告。

生成报告的触发时点是在 qa 环境功能测试完成以后,由于每个项目下有多个应用,所以开放给 DevOps 平台的接口全部为批量异步接口,另外我们的工具提供了多维度的接口封装,可支持其他平台接入,后续会将工具插件化,测试博客也会持续更新。

增量代码覆盖率只能作为一个参考纬度,反推功能测试、单元测试或者集成测试是否存在遗漏,并进行补充,也可以作为开发自测完成度的一个参考,谨慎作为评估指标。
####ps:
有赞测试组在持续招人中,大量岗位空缺,只要你来,就能帮你点亮全栈开发技能树,有意向换工作的同学可以发简历到 winta@youzan.com