浅谈覆盖率

在介绍Jacoco前,就是需要了解覆盖率是什么?现在开发在做单元测试时,问得更多的是:“覆盖率要多少?”以覆盖的达到的百分比作为衡量的标准,所以开发在写单测执行时,最多关注的是测试覆盖率达到了多少,这样将测试覆盖率作为指标意义不大,在我们做测试覆盖率更重要的是关注未被测试覆盖的代码。

代码覆盖率的意义

  1. 分析未覆盖部分的代码,从而反推在前期测试设计是否充分,没有覆盖到的代码是否是测试设计的盲点,为什么没有考虑到?需求/设计不够清晰,测试设计的理解有误,工程方法应用后的造成的策略性放弃等等,之后进行补充测试用例设计。
  2. 检测出程序中的废代码,可以逆向反推在代码设计中思维混乱点,提醒设计/开发人员理清代码逻辑关系,提升代码质量。
  3. 代码覆盖率高不能说明代码质量高,但是反过来看,代码覆盖率低,代码质量不会高到哪里去,可以作为测试自我审视的重要工具之一。

代码覆盖率工具

目前基本针对于Java的覆盖率工具,最常用的是Jacoco,主要讲解的是Jacoco

Jacoco Emma Cobertura
原理 使用ASM修改字节码 可以修改Jar文件、class文件字节码文件 基于Jcoverage。基于ASM框架对class插桩
覆盖粒度 方法、类、行、分支、指令、圈 行、块、方法、类 行、分支
插桩 on-the-fly和offline on-the-fly和offline offline
缺点 不支持JDK8 关闭服务器才能获取覆盖率报告
性能 较快 较快

覆盖率工具工作流程

image-20201020140328134

  1. 对Java字节码进行插桩,On-The-Fly和Offine两种方式。
  2. 执行测试用例,收集程序执行轨迹信息,将其dump到内存。
  3. 数据处理器结合程序执行轨迹信息和代码结构信息分析生成代码覆盖率报告。
  4. 将代码覆盖率报告图形化展示出来,如html、xml等文件格式。

插桩原理

Alt text

上图包含了几种不同的收集覆盖率信息的方法,每种方法的实现方法都不一样,带颜色的Jacoco中比较有特色的地方。

Code Coverage 代码覆盖率
Runtime Profiling 运行时分析
JVMPI Java虚拟机监视程序接口
JVMTI Java虚拟机工具接口
Instrumentation 注入
Source 源码注入
Byte Code 字节码注入
Offline 离线模式
On-The-Fly 在线模式
Replace 替换方式
Inject 注入方式
Class Loader 类加载
Java Agent 代理

主流代码覆盖率工具都采用字节码插桩模式,通过钩子的方式来记录代码执行轨迹信息。其中字节码插桩又分为两种模式On-The-Fly和Offine。On-The-Fly模式优点在于无需修改源代码,可以在系统不停机的情况下,实时收集代码覆盖率信息。Offine模式优点在于系统启动不需要额外开启代理,但是只能在系统停机的情况下才能获取代码覆盖率。

On-The-Fly插桩 Java Agent

  • JVM中通过-javaagent参数指定特定的jar文件启动Instrumentation的代理程序
  • 代理程序在每装载一个class文件前判断是否已经转换修改了该文件,如果没有则需要将探针插入class文件中。
  • 代码覆盖率就可以在JVM执行代码的时候实时获取。
  • 典型代表:Jacoco

On-The-Fly插桩 Class Loader

  • 自定义classloader实现自己的类装载策略,在类加载之前将探针插入class文件中
  • 典型代表:Emma

Offine插桩

  • 在测试之前先对文件进行插桩,生成插过桩的class文件或者jar包,执行插过桩的class文件或者jar包之后,会生成覆盖率信息到文件,最后统一对覆盖率信息进行处理,并生成报告。
  • Offline插桩又分为两种:
    • Replace:修改字节码生成新的class文件
    • Inject:在原有字节码文件上进行修改
  • 典型代表:Cobertura

On-The-Fly和Offine比较

  • On-The-Fly模式更加方便的获取代码覆盖率,无需提前进行字节码插桩,可以实时获取代码覆盖率信息
  • Offline模式适用于以下场景:
    • 运行环境不支持java agent
    • 部署环境不允许设置JVM参数
    • 字节码需要被转换成其他虚拟机字节码,如Android Dalvik VM
    • 动态修改字节码过程中和其他agent冲突
    • 无法自定义用户加载类

实践应用

单元测试覆盖率

开发任务中,开发人员编写单元测试用例,为了能够引入持续集成,我们采取的是Maven+Jacoco+SonarQube来获取单元测试覆盖率,将代码覆盖率绑定到代码编译阶段,每次代码编译就能够执行单元测试同时获取代码单元测试覆盖率。

生成代码覆盖率文件以后,通过Jenkins SonarQube Scanner或者执行mvn sonar:sonar将该文件上传至Sonar 服务器,就可以解析该文件,生成图形化的界面。

集成测试覆盖率

测试人员执行集成测试测试用例时(包括手工执行和自动化执行),我们需要代码覆盖率来发现测试用例设计的遗漏,及时补充用例来覆盖未被覆盖到的代码。

被测系统,在服务启动时,都会通过javaagent的方式做On-The-Fly插桩

img

  • 被测服务器启动之后,测试人员手工执行测试用例,Jacoco Agent会实时将代码覆盖率信息传输给Jacoco Prase Server,该服务器保存了被测代码源文件以及编译后的目标文件,服务器会结合源文件、目标文件以及代码覆盖率信息生成图表化的覆盖率文件。
  • 自动化执行测试用例完成之后,获取代码覆盖率信息,通过Jenkins Jacoco插件解析,获取图表化的覆盖率文件。

获取代码覆盖率报告之后,结合git获取的本次代码变动信息,得到测试用例覆盖的变动文件的测试覆盖率统计信息。来分析是否有由于测试用例设计遗漏导致的代码没有覆盖或者是开发的无效代码导致该代码无法被覆盖,如果测试用例设计有所遗漏,可以对照的增加相应的用例;如果是无效代码可以删除。

自动化集成流程

Alt text

  1. 业务开发完成之后,开发人员做单元测试,单元测试完成之后,保证单元测试全部通过同时单元测试代码覆盖率达到一定程度(这个需要开发和测试约定,理论上越高越好),开发提测。
  2. 测试人员根据测试用例进行测试(包括手工测试和自动化测试),结合git获取本次变动代码的覆盖率信息。行覆盖率需达到100%,分支达到50%以上,这个需要具体场景具体分析。
  3. 测试通过之后,代码合并至主干,进行自动化回归。
  4. 回归测试通过之后,代码可以上线。

基于这套流程,我们可以将单元测试代码覆盖率和集成测试代码覆盖率整合到持续集成流程中,如果代码覆盖率达不到我们设置的某个值时,可以终止流程继续下去获取需要人工确认之后,继续流程。

以上整理内容来源于以下文章:

https://tech.youzan.com/code-coverage/

https://cloud.tencent.com/developer/article/1038055

Jacoco

概述

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

通常我们会将测试覆盖率分为两个部分:需求覆盖率、代码覆盖率。

  • 需求覆盖:指的是测试人员对需求的了解程度,根据需求的可测试性来拆分成各个子需求点,来编写相应的测试用例,最终建立一个需求和用例的映射关系,以用例的测试结果来验证需求的实现,可以理解为黑盒覆盖。
  • 代码覆盖:为了更加全面的覆盖,我们可能还需要理解被测程序的逻辑,需要考虑到每个函数的输入与输出,逻辑分支代码的执行情况,这个时候我们的测试执行情况就以代码覆盖率来衡量,可以理解为白盒覆盖。

以上两者完全可以相辅相成,用代码覆盖结果反向的检查需求覆盖(用例)的测试是否充分完整

工具介绍

市场上java主要代码覆盖率工具:EMMA、JaCoCo。

JaCoCo优势:

  1. JaCoCo支持分支覆盖、引入了Agent模式。
  2. EMMA官网已经不维护了,JaCoCo是其团队开发的,可以理解为一个升级版。
  3. JaCoCo社区比较活跃,官网也在不断的维护更新

jacoco官方网址

  • Jacoco是一个开源的覆盖率工具。Jacoco可以嵌入到Ant 、Maven中,并提供了EclEmma Eclipse插件,也可以使用JavaAgent技术监控Java程序。很多第三方的工具提供了对Jacoco的集成,如sonar、Jenkins等。
  • Jacoco包含了多种尺度的覆盖率计数器,包含指令级覆盖(Instructions,C0coverage),分支(Branches,C1coverage)、圈复杂度(CyclomaticComplexity)、行覆盖(Lines)、方法覆盖(non-abstract methods)、类覆盖(classes)

覆盖率计数器

  • 行覆盖率

    所有类文件均携带debug信息编译,则每行的覆盖率可计算。当至少一个指令被指定到源码行且已执行时,该源码行被认为已执行。

    • 全部未覆盖:该行中指令均未执行,红色标志
    • 部分覆盖:该行中部分指令执行,黄色标志
    • 全覆盖:该行中所有指令已执行,绿色标志
  • 类覆盖率

    当类中至少有一个方法已执行,则该类被认为已执行。Jacoco中认为构造函数和静态初始化方法也当作被执行过的方法。Java接口类型若包含静态初始化方法,这种接口也被认为是可执行的类。

  • 方法覆盖率

    每个非抽象方法至少包含一个指令。当至少一个指令被执行,该方法被认为已执行。由于Jacoco基于字节码级别的,构造函数和静态初始化方法也被当作方法计算。其中有些方法,可能无法直接对应到源码中,比如默认构造器或常量的初始化命令。

  • 分支覆盖率

    Jacoco为if和switch语句计算分支覆盖率。这个指标计算一个方法中的分支总数,并决定已执行和未执行的分支的数量。分支覆盖率在class文件中缺少debug信息时也可使用。异常处理不在分支覆盖的统计范围内。

    • 全部未覆盖:所有分支均未执行,红色标志
    • 部分覆盖:只有部分分支被执行,黄色标志
    • 全覆盖:所有分支均已执行,绿色标志
  • 指令覆盖率

    Jacoco计数的最小单元是Java字节码指令,它为执行/未执行代码提供了大量的信息。这个指标完全独立于源格式,在类文件中缺少debug信息时也可以使用。

  • 圈复杂度

    Jacoco对每个非抽象方法计算圈复杂度,总结类、包、组的复杂性。根据McCabe1996圈复杂度的定义:在(线性)组合中,计算在一个方法里面所有可能路径的最小数目。因此,复杂度的值可以作为表示单元测试用例是否有完全覆盖所有场景的一个依据。复杂度数字即使在类文件缺失调试信息的情况下也可以计算。

  • 圈复杂度 v(G) 的正式定义基于方法的控制流程图作为有向图的表示:

    V(G) = E - N + 2

    E是边界数量,N是节点数量。

  • Jacoco基于下面方程来计算复杂度:

    V(G) = B - D + 1

    B是分支数量,D是决策点数量

    基于每个分支的被覆盖情况,Jacoco也未每个方法计算覆盖和缺失的复杂度。缺失复杂度同样表示测试案例没有完全覆盖到这个模块。注意Jacoco不将异常处理作为分支,try/catch块也同样不增加复杂度。

Jacoco原理

Jacoco使用插桩的方式来记录覆盖率数据,是通过一个probe探针来注入。

插桩模式有两种:

  • on-the-fly模式

    JVM通过 -javaagent参数指定jar文件启动代理程序,代理程序在ClassLoader装载一个class前判断是否修改class文件,并将探针插入class文件,探针不改变原有方法的行为,只是记录是否已经执行。

  • offline模式

    在测试之前先对文件进行插桩,生成插过桩的class或jar包,测试插过桩的class和jar包,生成覆盖率信息到文件,最后统一处理,生成报告。

Java方法的控制流分析

官方文档控制流分析

Jacoco如何在字节码注入?引用官网的实例进行分析

  • java方法

    1
    2
    3
    4
    5
    6
    7
    8
    9
    public static void example() {
    a();
    if (cond()) {
    b();
    } else {
    c();
    }
    d();
    }
  • 编译后转换成字节码

    1
    2
    3
    4
    5
    6
    7
    8
    9
    public static example()V
    INVOKESTATIC a()V
    INVOKESTATIC cond()Z
    IFEQ L1
    INVOKESTATIC b()V
    GOTO L2
    L1: INVOKESTATIC c()V
    L2: INVOKESTATIC d()V
    RETURN

Jacoco是字节码注入方式,通过一个probe探针的方式来注入的,探针是字节指令集插入到Java方法中,程序执行后可以被记录,它不会改变原有代码的行为。下图是探针前后插入的比较;

流程分析图

上图,黄色部分是探针注入的地方。

探针插入策略

Jacoco是根据控制流Type来采用不同的探针插入策略的。

一个用Java字节码定义的Java方法的控制流图可能有以下的type,每个type连接一个源指令与目标指令,type不同探针的注入策略也会不同,如下是type的定义:

image-20201020143044583

探针可以在现有指令之间插入附加指令,他们不改变已有方法行为,只是去记录是否已经执行。可以认为探针放置在控制流图的边缘上,理论上讲,我们可以在控制流图的每个边缘插入一个探针,但这样会增加类文件大小,降低执行速度。事实上,我们每个方法只需要一些探针,具体取决于方法的控制流程。

如果已经执行了探测,我们知道已经访问了相应的边缘,从这个边缘我们可以得出其他前面的节点和边:

  • 如果访问了边,我们知道该边的源节点已经被执行。
  • 如果节点已经被执行且节点是一个边缘的目标节点,则我们知道已经访问了该边。

img

上述探针插入策略没有考虑到隐式异常,如果两个探针之间的控制流被未使用throw的语句显示创建的异常终端,则其间的所有指令都被视为未覆盖。因此,只要后续行包含至少一个方法调用,Jacoco就会在两行的指令间添加额外的探测。该方法仅使用于有debug信息的编译的类文件。且不考虑除方法调用之外的其他指令的隐式异常。

探针的实现

探针需要满足如下几点要求:

  • 记录执行

  • 识别不同的探针

  • 线程安全

  • 对应用程序无影响

  • 最小的运行时开销

JaCoCo是用一个布尔数组来实现探针,每个探针对应于该数组中的项。当以下四个字节码指令触发时探针进行输入设置为true给每个类一个boolean[]数组实例,每个探针对应该数组中的一个条目。无论何时执行,都用下面4条字节码指令将条目设置为true。

1
2
3
4
ALOAD    probearray
xPUSH probeid
ICONST_1
BASTORE

Jacoco的on-the-fly模式使用方式

javaagent官网使用方式

Jacoco的使用分为三部分:

  • 第一部分是注入并采集
  • 第二部分是导出
  • 第三部分是生成报告

三部分可以分开执行。

  1. 首先在被测程序的启动命令行中加上-javaagent选项,指定jacocoagent.jar作为代理程序。

    • Jacoco agent搜集执行信息并且在请求或者JVM退出的时候导出数据。有三种不同的导出数据模式:
      • 文件系统:JVM停止时,数据被导出到本地文件
      • TCP socket Server:监听端口连接,通过socket连接获取到执行数据。在VM退出时,可选择进行数据重置和数据导出。
      • TCP socket Client:启动时,Jacoco agent连接到一个给定的TCP端,请求时执行数据写到socket,在VM退出时,可选择进行数据重置和数据导出。
    • 命令:-javaagent:[yourpath/]jacocoagent.jar=[option1]=[value1],[option2]=[value2]

    img

  2. 导出数据,假如指定导出模式为tcpserver,那么我们需要启动一个client来请求覆盖率文件数据

    • 代码导出 Jacoco给出的example示例如下:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      45
      46
      47
      48
      49
      50
      51
      52
      53
      54
      55
      56
      57
      package org.jacoco.examples;

      import java.io.FileOutputStream;
      import java.io.IOException;
      import java.net.InetAddress;
      import java.net.Socket;

      import org.jacoco.core.data.ExecutionDataWriter;
      import org.jacoco.core.runtime.RemoteControlReader;
      import org.jacoco.core.runtime.RemoteControlWriter;

      /**
      * This example connects to a coverage agent that run in output mode
      * <code>tcpserver</code> and requests execution data. The collected data is
      * dumped to a local file.
      */
      public final class ExecutionDataClient {

      private static final String DESTFILE = "jacoco-client.exec";

      private static final String ADDRESS = "localhost";

      private static final int PORT = 6300;

      /**
      * Starts the execution data request.
      *
      * @param args
      * @throws IOException
      */
      public static void main(final String[] args) throws IOException {
      final FileOutputStream localFile = new FileOutputStream(DESTFILE);
      final ExecutionDataWriter localWriter = new ExecutionDataWriter(
      localFile);

      // Open a socket to the coverage agent:
      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);

      // Send a dump command and read the response:
      writer.visitDumpCommand(true, false);
      if (!reader.read()) {
      throw new IOException("Socket closed unexpectedly.");
      }

      socket.close();
      localFile.close();
      }

      private ExecutionDataClient() {
      }
      }
  • 命令行导出 (dump):

    1
    java -jar jacococli.jar dump [--address <address>] --destfile <path> [--help] [--port <port>] [--quiet] [--reset] [--retry <count>]

    img

  1. 根据上面导出的exec文件,生成报告

    • 代码生成报告:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      45
      46
      47
      48
      49
      50
      51
      52
      53
      54
      55
      56
      57
      58
      59
      60
      61
      62
      63
      64
      65
      66
      67
      68
      69
      70
      71
      72
      73
      74
      75
      76
      77
      78
      79
      80
      81
      82
      83
      84
      85
      86
      87
      88
      89
      90
      91
      92
      93
      94
      95
      96
      97
      98
      99
      100
      101
      102
      103
      104
      105
      106
      107
      108
      109
      110
      111
      112
      113
      114
      115
      116
      117
      118
      119
      120
      121
      122
      123
      124
      import java.io.File;
      import java.io.IOException;
      import org.jacoco.core.analysis.Analyzer;
      import org.jacoco.core.analysis.CoverageBuilder;
      import org.jacoco.core.analysis.IBundleCoverage;
      import org.jacoco.core.tools.ExecFileLoader;
      import org.jacoco.report.DirectorySourceFileLocator;
      import org.jacoco.report.FileMultiReportOutput;
      import org.jacoco.report.IReportVisitor;
      import org.jacoco.report.html.HTMLFormatter;

      /**
      * This example creates a HTML report for eclipse like projects based on a
      * single execution data store called jacoco.exec. The report contains no
      * grouping information.
      *
      * The class files under test must be compiled with debug information, otherwise
      * source highlighting will not work.
      */
      public class ReportGenerator {

      private final String title;

      private final File executionDataFile;
      private final File classesDirectory;
      private final File sourceDirectory;
      private final File reportDirectory;

      private ExecFileLoader execFileLoader;

      /**
      * 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.exec");
      this.classesDirectory = new File(projectDirectory, "bin");
      this.sourceDirectory = new File(projectDirectory, "src");
      this.reportDirectory = new File(projectDirectory, "coveragereport");
      }

      /**
      * Create the report.
      *
      * @throws IOException
      */
      public void create() 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);

      }

      private void createReport(final IBundleCoverage bundleCoverage)
      throws IOException {

      // Create a concrete report visitor based on some supplied
      // configuration. In this case we use the defaults
      final HTMLFormatter htmlFormatter = new HTMLFormatter();
      final IReportVisitor visitor = htmlFormatter
      .createVisitor(new FileMultiReportOutput(reportDirectory));

      // Initialize the report with all of the execution and session
      // information. At this point the report doesn't know about the
      // structure of the report being created
      visitor.visitInfo(execFileLoader.getSessionInfoStore().getInfos(),
      execFileLoader.getExecutionDataStore().getContents());

      // Populate the report structure with the bundle coverage information.
      // Call visitGroup if you need groups in your report.
      visitor.visitBundle(bundleCoverage,
      new DirectorySourceFileLocator(sourceDirectory, "utf-8", 4));

      // Signal end of structure information to allow report to write all
      // information out
      visitor.visitEnd();

      }

      private void loadExecutionData() throws IOException {
      execFileLoader = new ExecFileLoader();
      execFileLoader.load(executionDataFile);
      }

      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);
      }

      /**
      * Starts the report generation process
      *
      * @param args
      * Arguments to the application. This will be the location of the
      * eclipse projects that will be used to generate reports for
      * @throws IOException
      */
      public static void main(final String[] args) throws IOException {
      for (int i = 0; i < args.length; i++) {
      final ReportGenerator generator = new ReportGenerator(
      new File(args[i]));
      generator.create();
      }
      }

      }
    • 命令行生成报告:

      1
      java -jar jacococli.jar report [<execfiles> ...] --classfiles <path> [--csv <file>] [--encoding <charset>] [--help] [--html <dir>] [--name <name>] [--quiet] [--sourcefiles <path>] [--tabwith <n>] [--xml <file>]

      img

扩展

合并多个exec文件信息

1
java -jar jacococli.jar merge [<execfiles> ...] --destfile <path> [--help] [--quiet]

img

查看exec文件信息

1
java -jar jacococli.jar execinfo [<execfiles> ...] [--help] [--quiet]

img

支持的集成工具

Jacoco团队提供了如下的一些集成工具的支持: