大厂都有的代码增量覆盖率都是怎么实现的

6,687 阅读7分钟

一. 增量代码覆盖率的意义

单元测试覆盖率

通常我们通过编写单元测试来测试代码,为了检验单元测试的有效性,可以结合覆盖率的统计来完成。

单元测试优点

  • case可以重复利用
  • 结合覆盖率可以很好的反馈单元测试的完成度

缺点

  • 业务模块通过单元测试达到很高的覆盖率难度高
  • 维护成本较高
  • 单元测试本身也不能保证所有问题的发现

开发人员和QA的测试往往是黑盒的,如果可以量化这个测试情况,会更加实用。衡量的手段之一同样可以通过代码覆盖率的来实现。

通常,代码覆盖率(这里都指的行覆盖率)越高,测试的越充分。尽可能的去避免漏测的情况发生。

代码覆盖率的意义

  • 反映测试(包含自测)覆盖情况
  • 代码复杂度的检验

而实际的情况我们希望实行的成本尽可能降低,节省我们宝贵的开发和测试资源。希望达到的效果是代码覆盖率的指标只针对新增的改动和功能模块,这样我们就为了达到很高的覆盖率而去做全功能的回归测试。

二. 覆盖率工具-Jacoco原理介绍

Jacoco通过编译阶段去插桩探针、通过探针会记录各个场景下的指令的执行覆盖情况。

概述Jacoco怎么去放置探针,覆盖掉所有的分支场景,其设计的边界包括下面四块:SEQUENCE(指令代码之间)、非条件的代码跳转、条件跳转、退出代码块。

Jacoco的官方有篇很好的工作流程,了解更多点这里

gradle中集成了Jacoco的功能,安卓的gradle plugin会协调gradle和安卓的配置,完成覆盖率的检测。

测试覆盖率的开关配置位于build.gradle的buildType{}节点中添加 testCoverageEnabled=true

相关源码:

// ---- Code Coverage first -----
boolean isTestCoverageEnabled =
        config.getBuildType().isTestCoverageEnabled()
                && !config.getType().isForTesting()
                && !variantScope.getInstantRunBuildContext().isInInstantRunMode();
if (isTestCoverageEnabled) {
    createJacocoTransform(tasks, variantScope);
}

可以看出覆盖率跟instantRun是不兼容的,使用的时候需要关闭instantRun。

三. 前置规划的使用流程

评判工具好不好用还是需要看最终是不是真的能解决实际问题、流程是不是顺畅的。那最先需要做的应该是规划出产品的最终形态、并且评审你的产品。

接下来再设计和编码。

经过讨论我们希望达到下面目标

  1. 开发最好自测完成再提测功能到QA开发最好自测完成再提测功能到QA

  2. 对于覆盖率工具的使用难度尽可能的低对于覆盖率工具的使用难度尽可能的低

  3. 覆盖率需要作为提测指标

团队由于针对功能提测来说都是通过gitlab的merge request来完成的,如果想要达到以上目标、最好这个流程可以黑盒的集成到merge request的流程,通过reviewer的角色或者ci pipeline根据覆盖率指标来卡点merge的授权。

以上,就可以规划出来一个使用的初步流程了。

日常开发提测过程中增加的环节为:

  • 开发自测
  • 退出自测应用/功能

四. 技术方案分析与选型

4.1 方案一:增量注入

从上面原理我们了解到,Jacoco的覆盖率计算主要是通过插桩探针来实现的。如果我们可以只针对差量的方法做探针的插桩理论上覆盖率也是基于增量的。

这块的核心逻辑位于ClassProbesAdapter

public class ClassProbesAdapter extends ClassVisitor implements IProbeIdGenerator
@Override
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);
   if (mv == null) {
      // We need to visit the method in any case, otherwise probe ids
      // are not reproducible
      methodProbes = EMPTY_METHOD_PROBES_VISITOR;
   } else {
      methodProbes = mv;
   }
   return new MethodSanitizer(null, access, name, desc, signature,
         exceptions) {
      @Override
      public void visitEnd() {
         super.visitEnd();
         LabelFlowAnalyzer.markLabels(this);
         //MethodProbesAdapter 为MethodProbesVisitor的适配器,
         //asm通过ClassInstrumenter获取到的方法插桩实例为MethodInstrumenter的实例,借助它来完成方法的插桩
         final MethodProbesAdapter probesAdapter = new MethodProbesAdapter(
               methodProbes, ClassProbesAdapter.this);
         if (trackFrames) {
            final AnalyzerAdapter analyzer = new AnalyzerAdapter(
                  ClassProbesAdapter.this.name, access, name, desc,
                  probesAdapter);
            probesAdapter.setAnalyzer(analyzer);
            this.accept(analyzer);
         } else {
            this.accept(probesAdapter);
         }
      }
   };
}

Gradle transform过程中利用ASM在插桩方法的时候都会回调这个类的visitMethod方法 ,在visitMethod方法中再调用ClassProbeVisitorvisitMethod方法,

ClassProbeVisitor借助ClassInstrumenter获取MethodInstrumenter,并最终调用MethodInstrumenter完成字节码注入。

由于visitMethodfinal了,如果想要定制这块的逻辑我们可以重新继承ClassInstrumenter,覆写掉visitMethod方法。为了改造成增量的插桩,我们改造后的代码应该是这样的:

@Override
public final MethodVisitor visitMethod(final int access, final String name,
                                       final String desc, final String signature, final String[] exceptions) {
    if (JacocoUtil.getDiffMethod(name, desc, signature, changedMethods, this.className)) {
        ...
    } else {
        return null;
    }
}

只保留有差量的方法插桩。

接下来要做的事情就是怎么去分析出差量的方法。这个就要借助抽象语法树来完成了。

代码在编译器中的流程如下:

1-3生成AST的过程就是抽象语法树的过程,这个时候还未生成class,它是阶段早于asm字节码插桩的。

Jetbrains有关AST的过程介绍:www.jetbrains.org/intellij/sd…

看个案例:

ViewUtil类声明的px2Dip的方法如下:

@Deprecated
public static int px2dip(Context context, float pxValue) {
    final float scale = context.getResources().getDisplayMetrics().density;
    return (int) (pxValue / scale + 0.5f);
}

借助Android studioAST JD插件观察AST的结果

可以看到整个类和方法都被解构了,MethodDeclaration中包含了method的名称、注释、修饰、返回类型、参数等任何有关的信息。

在AST的过程中我们通过分析这些内容的变化来确定哪些方法发生了变更。就可以提供出供选择的差量方法。

相关可以用于AST的开源库还有:lombok.ast javaparser

这个方案优点是代码入侵小、逻辑较为内聚确保找差量方法的方案正确就能稳定运行。缺点是技术实现成本较高、也需要对gradlejacoco做二次改造,加入增量逻辑。

4.2 方案二:差量报告

当然我们也可以有个很偷懒的办法来完成这个功能,既然jacoco本身可以生成覆盖率报告,那么如果基于报告只把diff的内容裁剪出来就好了。

通常我们在做git diff的时候效果是这样的:

改动我们只要关注”+“的部分就能覆盖到所有改动的行。

Python核心逻辑,解析git diff的内容获取到改动的行列表:

def get_diff(self, diff_result):
    """获取diff详情"""
    diff = diff_result.split("\n")
    ret = {}
    file_name = ""
    diff_lines = []
    current_line = 0
    for line in diff:
        if line.startswith('diff --git'):
            # 进入新的block
            if file_name != "":
                ret[file_name] = diff_lines
            file_name = re.findall('b/(\S+)$', line)[0]
            diff_lines = []
            current_line = 0


        elif re.match('@@ -\d+,\d+ \+(\d+),\d+ @@', line):
            match = re.match('@@ -\d+,\d+ \+(\d+),\d+ @@', line)
            current_line = int(match.group(1)) - 1
        elif line.startswith("-"):
            continue
        elif line.startswith("+") and not line.startswith('+++'):
            current_line += 1
            diff_lines.append(current_line)
        else:
            current_line += 1
    ret[file_name] = diff_lines
    return ret

通过拿到行列表就可以跟jacoco的报告结果做比较了,出去掉无关改动的包以及类文件、把增量部分的报告重新做着色,就可以得到一份完整diff的报告。

同样我们可以在比较的过程中统计新增和覆盖到的行数量,进而统计整体的新增代码行覆盖率。

Jacoco的报告中,html节点被css_class标记为fc,或pc的行即为覆盖到的行。我们可以使用如下的判断:

if css_class.startswith("fc") or css_class.startswith("pc"):
    cover_line_count += 1

以上,就可以完成增量覆盖率的实现了。

五. 小结

  1. 增量覆盖率在实际开发生产的实践比全量覆盖率更加可行,通过与开发流程的结合,很大程度上避免了开发不自测或者自测不充分的情况。

  2. 技术实现上,以上的两种技术方案第二种实施成本较为简单,但是相对于第一种它基于jacoco报告文件来分析,jacoco样式更改或者报告格式更改会导致实现的不稳定的因素。