酷家乐质量效能 覆盖率平台开发实践

酷家乐质量效能 · 2020年05月20日 · 最后由 ghost 回复于 2020年12月12日 · 231 次阅读

获取增量覆盖率作为精准测试平台的六脉神剑之一,本文作为精准测试小专栏的第一弹推出。

背景

作为一个测试人员,保证产品的软件质量是其工作首要目标,为了这个目标,测试人员常常会通过很多手段或工具来加以保证,而覆盖率就是其中比较重要的一个环节。

覆盖率是用来度量测试完整性的一个手段,是测试技术有效性的度量。希望有个平台可以统计整个迭代的后端覆盖率数据报告, 包括接口测试, 功能测试。

测试人员可以通过测试覆盖情况来分析自身测试质量,同时达到最终提升测试质量的目的。

实现原理

网上对于 jacoco 的实现原理有非常详细的解释说明,这里就不再赘述,简单来说就是要拿到三件套——源码、class 和 jacoco.exec,那么就可以实现一个应用的代码覆盖率统计。

而覆盖率平台是基于 jacoco 二次开发,统计自动化测试和手动测试的覆盖率数据。

系统设计方案:

CI 自动触发

moon(内部持续集成平台) 每次部署完成,触发接口自动化测试,集成测试平台会向通知覆盖率平台去收集该次接口自动化测试的覆盖率数据,本次执行完的 jacoco 数据单独存放和本次执行记录挂钩。

手动触发

moon 每次部署完成,在 Webhook 对应的环境下会触发请求,通知覆盖率平台去收集该次手工测试的覆盖率数据,直至下一次部署为止,每 10 分钟更新一次数据,本次执行 jacoco 数据单独存放和本次执行记录挂钩。(注:手工测试的覆盖率收集需要先在平台上新建对应应用的 job,否则会触发收集失败)。

Jacoco 特性

首先我们来看一下 jacoco 是如何注入代码实现收集覆盖率的。

如上图所示,jacoco 在 java 代码中插入探针,每个探测指针都是一个 BOOL 变量(true 表示执行、false 表示没有执行),程序运行时通过改变指针的结果来检测代码的执行情况(不会改变原代码的行为)。

所以,只要利用 jacoco 的插桩的特性,可以准确获得测试过程中,代码的执行情况,而覆盖率平台正是利用这种特性,来收集各个应用的覆盖率。

我们设计的方案也是基于 JaCoCo 做相应改造,生成我们所需要的覆盖率模型,并通过 JaCoCo 开放的 API 实现相关功能。

全量覆盖率

全量覆盖率的实现非常简单,只要拿到上述的三件套,就可以完成一个全量代码覆盖率的收集。

可以拆分成如下几个步骤:

  1. 获取测试完成后的 exec 文件(二进制文件,里面有探针的覆盖执行信息, 记录了代码的覆盖情况)

  2. 获得本次部署的镜像,拿到插桩后的 classes

  3. 获取基线提交的代码(本次部署的 gitlab 上对应 commit 的代码)

  4. 利用 jacoco 的 api 生成报告

增量覆盖率

在平时测试过程中,如果是需要看一个应用本次发布的代码和某一次发布的代码之间的差异点是否都已经测试到了,那么我们的关注点就不在于全量的覆盖情况,而是一个增量覆盖,也就是本次的代码和上一次的代码改动量的覆盖情况。我们可以通过改造上述三件套,来实现这样的需求。

具体步骤如下:

  1. 获取测试完成后的 exec 文件(二进制文件,里面有探针的覆盖执行信息, 记录了代码的覆盖情况)

  2. 获得本次部署的镜像,拿到插桩后的 classes

  3. 获取基线提交与被测提交之间的差异代码

  4. 对差异代码进行解析,切割为更小的颗粒度,选择类为最小维度(后续为了精准测试,需要将最小颗粒度精确到方法)

  5. 改造 JaCoCo ,使它支持仅对差异代码生成覆盖率报告

docker 上获取 classes

因为每次应用部署后,都会把镜像文件 push 到镜像仓库,镜像文件里打包了插桩后的 class 文件,所以要获得 class,需要从镜像仓库中把对应的部署产生的 classes 拉取下来。

其中在 moon 上部署的应用,moon 默认在 webhook 上调用 kuafu 的接口,会把相应的镜像名等信息回传给覆盖率平台;而持续集成平台上跑的自动化测试构建的环境,也会调用 kuafu 接口回传镜像名称。因此,覆盖率平台只需要根据镜像名称,docker pull 把镜像拉去下来解析出 class 即可。

镜像文件中是打包好的应用的 jar 包,解压后遍历文件夹,找到 classes 文件夹,该文件夹下的就是所有插桩后的 classes。

这里用的是 github 上开源的一个封装好的 docker 工具——com.github.dockerjava.api,通过这个工具类可以非常方便的操作 docker。

核心代码如下:

//执行docker命令
public String execCommand(String containerId, String[] command) {
    DockerClient dockerClient = createDockerClient();
    ExecCreateCmdResponse exec = dockerClient.execCreateCmd(containerId).withCmd(command).withTty(false).withAttachStdin(true).withAttachStdout(true).withAttachStderr(true).exec();
    OutputStream outputStream = new ByteArrayOutputStream();
    String output = null;
    try {
        dockerClient.execStartCmd(exec.getId()).withDetach(false).withTty(true).exec(new ExecStartResultCallback(outputStream, System.err)).awaitCompletion();
        output = outputStream.toString();// IOUtils.toString(outputStream, Charset.defaultCharset());
    } catch (InterruptedException e) {
        log.warn("Exception executing command {} on container {}", Arrays.toString(command), containerId, e);
    }
    try {
        dockerClient.close();
    } catch (IOException e) {
        // TODO Auto-generated catch block
        e.printStackTrace();
        log.error(e.getMessage());
    }
    return output;
}

获取 exec

我们获取 exec 文件是通过 tcp 方式获取的,在部署 java 应用服务时,指定了 -javaagent 参数的 output 为 tcpserver ,并指定可用端口,所以 javaagent 参数设定如下: output=tcpserver,address=0.0.0.0,port=6300,然后将 javaagent 参数注入 JVM ,这就是为什么我们要能收集到数据,就必须在 moon 上注入这段神秘代码。

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

// Open a socket to the coverage agent:
final Socket socket = new Socket(InetAddress.getByName(ip), PORT);
try {
    final RemoteControlWriter writer = new RemoteControlWriter(socket.getOutputStream());
    final RemoteControlReader reader = new RemoteControlReader(socket.getInputStream());
    reader.setSessionInfoVisitor(localWriter);
    reader.setExecutionDataVisitor(localWriter);

    // Send a dump command and read the response:
    writer.visitDumpCommand(true, false);
    if (!reader.read()) {
        throw new IOException("Socket closed unexpectedly.");
    }
} catch (Exception e) {
    // TODO: handle exception
    log.error("socket connect error:{}", e.getMessage());
} finally {
    socket.close();
    localFile.close();
}

生成覆盖率报告

这步主要是用 JaCoCo 开放的 API 和来实现的,根据前两步获取到的 class 和源码信息,用 JaCoCo 的 api 去解析 exec 文件,核心代码如下:

/**
 * Create a new generator based for the given project.
 *
 * @param projectDirectory
 */
public ReportGenerator(final File projectDirectory) {
    this.title = projectDirectory.getName();
    this.executionDataFile = new File(projectDirectory, "jacoco-server.exec");
    this.classesDirectory = new File(projectDirectory, "bin");
    this.sourceDirectory = new File(projectDirectory, "src");
    this.reportDirectory = new File(projectDirectory, "coveragereport");
}


/**
 * Create the report.return bundle for analyse cover info
 *
 * @throws IOException
 */
public IBundleCoverage createByBundle() throws IOException {
    // Read the jacoco.exec file. Multiple data files could be merged
    // at this point
    loadExecutionData();

    // Run the structure analyzer on a single class folder to build up
    // the coverage model. The process would be similar if your classes
    // were in a jar file. Typically you would create a bundle for each
    // class folder and each jar you want in your report. If you have
    // more than one bundle you will need to add a grouping node to your
    // report
    final IBundleCoverage bundleCoverage = analyzeStructure();

    createReport(bundleCoverage);

    return bundleCoverage;
}

//单module的报告生成
private IBundleCoverage analyzeStructure() throws IOException {
    final CoverageBuilder coverageBuilder = new CoverageBuilder();
    final Analyzer analyzer = new Analyzer(
            execFileLoader.getExecutionDataStore(), coverageBuilder);

    analyzer.analyzeAll(classesDirectory);

    return coverageBuilder.getBundle(title);
}


//多module的报告生成
private ISourceFileLocator getSourceLocator(File sourceFileStart) {
    List<File> sourceFileList = getSourceFileList(sourceFileStart);
    final MultiSourceFileLocator multi = new MultiSourceFileLocator(
            4);
    for (final File f : sourceFileList) {
        logger.info("f.getAbsolutePath():" + f.getAbsolutePath());
        multi.add(new DirectorySourceFileLocator(new File(f.getAbsolutePath()), "utf-8", 4));
    }
    return multi;
}

解析差异代码

gitLab 支持比较两个 commit 版本之间的差异代码,这里我们使用 org.gitlab.api 的工具类,通过方法 compareCommits 可以得到两个版本的差异代码,其中包含了差异代码的具体内容、行号、文件名等等信息。

因为生成覆盖率报告需要类文件、源码和 jacoco 文件这三个要素,而生成的覆盖率报告依类文件而定。举个栗子:应用 J 含有 A、B、C 三个类,通过上一节我们知道需要把三个元素放在对应的文件夹下,在生成覆盖率报告的时候把 A、B、C 都放到类文件夹下,则最终生成的报告将会包含这三个类;如果只把 A 放到类文件夹下,那么最终生成的报告就只含有 A 这个类,没有 B 和 C。

那么,要获得精确到类的增量覆盖率,只需要把全量的类文件替换成差异的类文件即可。通过得到的差异代码,可以获得相应的类名,此时遍历全量类文件,找到相关类名和相对路径,拷贝到生成覆盖率报告的类文件夹下。

核心代码如下:

if (toCommitId != fromCommitId) {
    Set<String> javaModifyFileList = new HashSet<String>();
    String pattern = ".*.java";
    //获得两个commit间的差异信息
    GitlabCommitComparison gitlabCommitComparison = gitlabService.getCompare(projectId, fromCommitId,
            toCommitId);
    //遍历差异信息
    gitlabCommitComparison.getDiffs().forEach(item -> {
        if (Pattern.matches(pattern, item.getNewPath())) {
            javaModifyFileList.add(item.getNewPath());
            List<Integer> modifyLineNum = new ArrayList<Integer>();
            log.info("pc diff: {}", item.getDiff());
            //从全量的类文件夹下开始遍历
            Path classStart = Paths.get(fcClassPath);
            try (Stream<Path> stream = Files.find(classStart, maxDepth, (path, attr) -> String.valueOf(path)
                    .endsWith(StringUtils.substringBeforeLast(newFileName, ".java") + ".class"))) {
                String joined = stream.sorted().map(String::valueOf).collect(Collectors.joining("; "));
                log.info("pc Found: {}", joined);
                log.info("pcClassPath:{}", pcClassPath);
                //复制到生成覆盖率报告的类文件夹下
                FileUtils.copyFileToDirectory(new File(joined), new File(pcClassPath));
            } catch (IOException e2) {
                // TODO Auto-generated catch block
                e2.printStackTrace();
            }
        }
    });
}
//api获得git上的差异代码
@Override
public GitlabCommitComparison getCompare(final Integer projectId, final String oldCommitHash,
        final String newCommitHash) {

    final GitlabAPI api = createApi();

    Pagination pagination = new Pagination();
    pagination.setPage(1);
    pagination.setPerPage(20);

    GitlabCommitComparison gitlabCommitDiff = null;
    try {
        gitlabCommitDiff = api.compareCommits(projectId, oldCommitHash, newCommitHash, pagination);
    } catch (IOException e) {
        log.error(e.getMessage());
    }

    return gitlabCommitDiff;
}

查看数据

全量覆盖

点击全量可以查看到基于全量代码的覆盖率详情,具体通过报告内容可以分析代码覆盖情况:绿色的表示完全覆盖,红色的表示完全没覆盖,黄色的表示部分分支覆盖(点击四角形可以看到覆盖的分支数)

生产增量覆盖

生产增量统计的是本次部署,和生产环境下对应的增量代码的覆盖率情况,同时接入了精准测试平台,可以查看影响的接口,更有助于直观的分析测试质量。

点击精准测试即可转入精准测试平台,同时点击类名(方法)也可以跳转到覆盖率平台对应的该方法上。

规划

  1. 和用例平台打通,结合精准测试的调用链分析功能,将覆盖粒度精确到函数级别,可以获得每条用例覆盖到的函数和影响到的接口,通过更小的维度更精准地度量测试质量。
  2. 和流量回放平台打通,测试童鞋每次跑一些流量,获得相应的覆盖代码。当所有的流量回放完,测试童鞋可以比对每次回放覆盖到的代码,从而筛选出每次都未覆盖到的废代码(当然这块判定废代码的步骤需要测试童鞋斟酌),从而提高开发代码质量。

关注我们

酷家乐质量效能团队热衷于技术的成长和分享,几乎每个月都会举办技术分享活动(海星日),每半年举办一次技术专题竞赛分享(火星日),并将优秀内容写成技术文章。

我们尽可能保障分享到社区的内容,是我们用心编写、精心挑选的优质文章。如果您想更全面地阅读我们的文章,请您关注我们的微信公众号"酷家乐技术质量"。

如果您有兴趣了解我们的职位和团队情况,请参考最新职位招聘,并联系 caibao@qunhemail.com。感谢您的阅读!

共收到 12 条回复 时间 点赞

这个做的不错,解决了 docker 环境下覆盖率获取问题

代码比对以及覆盖率获取流程执行速度怎么样?

太厉害了,顶啊

想请教下,测试阶段实际上代码还是会有不少改动的,那此时不可能每次改动都全量回归以获得最准确的覆盖率数据。想了解酷家乐内部是怎么解决这个问题,让覆盖率分析尽可能覆盖整个测试阶段有测试过的代码的?

yca 回复

速度大概在一分钟左右,但是因为咱们的手动环境下的任务比较多,有时候 cpu 比较满的时候可能要花上五分钟左右等待队列。

陈恒捷 回复

平台提供了 merge 功能,测试同学可以选择把 srint 开始到结束的覆盖率 merge 起来,拿到的就是一个完整的覆盖率数据。

整体来看跟我们的实现大致类似,问题也应该都是类似的,加油。

如果一个版本的测试过程中,不同阶段的代码不同,不同阶段的 exec 直接合并会有问题吧,这个你们怎么解决的呢?

yca 回复

原生的 jacoco 是按 class info 来区分 exec 文件,如果测试阶段的代码一个是 A 分支,一个是 B 分支,如果这两个都是从同一个父分支上 clone 下来的话,那这俩分支 merge 是没有问题的,如果这两个源分支不一样,merge 的时候就会失败。。所以这其实应该是个代码规范问题,因为一般来说都是从 master 上拉下来几个分支,然后开发在上面开发自己的代码,测试的话就部署这些开发分支测试,这样的分支是可以 merge 的,而且因为咱们的覆盖率是按不同环境来的,一般一个环境下的 merge 是没有问题的,如果要把不同环境比如 stable 和 sit 环境的覆盖率 merge,我这里还没测试过,目测是不行的😳

精准测试的调用链分析这个主要是怎么做的?用了什么技术?能分享分享吗

能不能科普下 tcpclient 模式是怎么使用的,目前平台这块的资料很少哦

我们获取 exec 文件是通过 tcp 方式获取的,在部署 java 应用服务时,指定了 -javaagent 参数的 output 为 tcpserver ,并指定可用端口,所以 javaagent 参数设定如下: output=tcpserver,address=0.0.0.0,port=6300,

我看到文章里说用的是 tcpserver 的方式输出,但是看系统设计图,写的又是 tcpclient 的方式,这块是不是我理解偏了?

需要 登录 后方可回复, 如果你还没有账号请点击这里 注册