单元测试框架和覆盖率统计原理简析(三)

发表于:2022-4-06 09:43

字体: | 上一篇 | 下一篇 | 我要投稿

 作者:阿里技术    来源:阿里技术

  五、覆盖率统计
  代码覆盖率是衡量单元测试有效性的一个指标,覆盖率又可以分为两个大类,即 「需求覆盖率」和「代码覆盖率」。

  代码覆盖率的度量方式有以下几类:
  1. 代码覆盖率的发展历史
  Java 中比较流行的代码覆盖率工具有 EMMA, Cobertura,JaCoCo 等。其中 Emma 由于开发团队的原因已经停止更新,原团队目前专注于 JaCoCo 的开发和维护工作。有意思的是,EclEmma 和 JaCoCo 的官方网站是指向不同域名的同一个服务。
  在 Java 领域有很多方法来收集代码覆盖度。下图展示了 JaCoCo 插件采用的技术(加色展示的部分)。
  2. Jacoco 覆盖率统计过程
  Jacoco 是 Java 领域目前比较主流的覆盖率统计工具,它的统计过程可以分为打桩、测试用例执行、覆盖率统计和覆盖率数据解析并生成报告。
  (1)首先对需要统计覆盖率的 Java 代码进行插桩,植入覆盖率统计代码,有 On-The-Fly 和 Offline 两种方式。
  (2)执行测试用例,通过用例运行收集执行轨迹信息,保存在内存中。
  (3)JVM 退出前将覆盖率数据保存至磁盘(二进制)或通过网络传送出去 。
  (4)解析覆盖率文件,将代码覆盖率报告图形化展示出来,如 html、xml 等文件格式。
  3.  JaCoCo 的 offline 与 on-the-fly
  根据插入字节码的时机不同,可以将覆盖率工具的运行方式分为offline与on-the-fly两种:
  offline 模式
  offline 模式会对编译后的字节码文件进行插桩并覆盖源文件,在启动 JVM 时直接加载插桩后的字节码。
  offline 模式会对编译后字节码源文件进行修改,所以对用户的影响最大,但是对性能的影响最小。
  ·优点是不需要运行环境支持 java agent,不会与其他 agent 冲突。
  · 但需要添加 jacoco 编译字节码的 runtime 依赖。
  on-the-fly 模式
  在 JVM 加载类文件时,回调 javaagent 对字节码进行动态增强,植入覆盖率统计代码。
  on-the-fly 模式会在应用启动时对加载进JVM的字节码文件进行插桩,不会改变用户的运行流程,只需要在JVM启动时配置 -javaagent 参数,更加无感。
  · 优点是直接添加启动参数即可快速进行覆盖率统计和分析。
  · 缺点是使用 javaagent 会降低一些启动速度以及 agent 冲突问题。
  4. JaCoCo 与 Maven 集成
  JaCoCo 提供了 maven 插件方便开发在项目中集成,提供了以下基本 goals,常用的包括 prepare-agent、report、instrument 和 restore-instrumented-classes。jacoco-maven-plugin 的 goals 与 Maven 生命周期的绑定关系如下:
  validate
  initialize .................. (prepare-agent 默认所属周期,注入 javaagent 参数)
  generate-sources
  process-sources
  generate-resources
  process-resources
  compile
  process-classes ............. (instrument 默认所属周期,offline 模式下对字节码插桩)
  generate-test-sources
  process-test-sources
  generate-test-resources
  process-test-resources
  test-compile
  process-test-classes
  test ........................ (mvn test 执行的截止周期)
  prepare-package ............. (restore-instrumented-classes 默认所属周期,offline 模式下恢复原始字节码)
  package
  pre-integration-test
  integration-test
  post-integration-test
  verify ...................... (report 和 check 默认所属周期,report 用于生成覆盖率报告)
  install
  deploy

  在默认的绑定关系中,当我们执行 mvn test 的时候,restore-instrumented-classes 和 report 默认不会被运行,因此为方便 offline 模式使用,我们需要修改下插件绑定的执行 phase,保证我们运行 mvn test 时可以正常运行,生成覆盖率报告。下面是两种不同模式的配置方案。
  offline
  instrument 和 restore-instrumented-classes 需要配套使用。
  <dependencies>  
      <dependency>
        <groupId>org.jacoco</groupId>
        <artifactId>org.jacoco.agent</artifactId>
        <version>0.8.6</version>
        <classifier>runtime</classifier>
        <scope>test</scope>
      </dependency>
    </dependencies>
    <build>
      <plugins>
        <plugin>
          <groupId>org.apache.maven.plugins</groupId>
          <artifactId>maven-surefire-plugin</artifactId>
          <version>2.16</version>
          <dependencies>
            <dependency>
              <groupId>org.apache.maven.surefire</groupId>
              <artifactId>surefire-junit4</artifactId>
              <version>2.16</version>
            </dependency>
          </dependencies>
          <configuration>
            <systemPropertyVariables>
              <jacoco-agent.destfile>${project.build.directory}/coverage.exec</jacoco-agent.destfile>
            </systemPropertyVariables>
          </configuration>
        </plugin>
        <plugin>
          <groupId>org.jacoco</groupId>
          <artifactId>jacoco-maven-plugin</artifactId>
          <version>0.8.6</version>
          <executions>
            <execution>
              <id>default-instrument</id>
              <goals>
                <goal>instrument</goal>
              </goals>
            </execution>
            <execution>
              <id>report</id>
              <goals>
                <goal>report</goal>
              </goals>
              <configuration>
                <dataFile>${project.build.directory}/coverage.exec</dataFile>
              </configuration>
            </execution>
            <execution>
              <phase>test</phase>
              <id>default-restore-instrumented-classes</id>
              <goals>
                <goal>restore-instrumented-classes</goal>
              </goals>
            </execution>
          </executions>
        </plugin>
      </plugins>
    </build>

  on-the-fly
  aone 的默认测试插件默认采用这种模式。
  <plugin>
      <groupId>org.jacoco</groupId>
      <artifactId>jacoco-maven-plugin</artifactId>
      <version>0.8.6</version>
      <executions>
          <execution>
              <goals>
                  <goal>prepare-agent</goal>
              </goals>
          </execution>
          <execution>
              <id>report</id>
              <phase>prepare-package</phase>
              <goals>
                  <goal>report</goal>
              </goals>
          </execution>
      </executions>
  </plugin>

  为什么要改默认的 phase?
  当我们使用 offline 模式运行 mvn test,如果按照默认的绑定关系,可能会遇到。
  Cannot process instrumented class 某个类名. Please supply original non-instrumented classes。
  原因是JaCoCo 的 instrument 在 process-classes 阶段将编译好的代码打桩用于统计代码覆盖率,然后需要在 restore-instrumented-classes 恢复原始字节码。
  但是执行 mvn test 时只到 test,而 restore-instrumented-classes 绑定在 prepare-package 阶段,因此 mvn test 默认不会触发 restore-instrumented-classes ,第二次 mvn test 时会重复打桩,引起报错。
  如果不改默认的 phase,则需要将 mvn test 改为 mvn verify 使用。verify 会运行 intergration-test 和 package 阶段,这两个阶段针对单元测试来说,不是十分必要。
  目前 Aone 代码覆盖率主要基于 JaCoCo 的 on-the-fly 模式进行代码覆盖率采集,通过自定义测试构建过程,利用 CI 插件自动注入 jacoco-maven-plugin,无需用户自己添加。配置过程可以参考下文。
  Aone 的各类测试任务底层复用了同一套执行引擎,类似 Maven 的 phase 和 goals,在 aone 中称为阶段和插件,每个阶段可以添加多个插件,例如下图在单元测试阶段,添加了代码 checkout 插件、codeconverage-unittest-pre(自动在 pom 文件中植入 JaCoCo 的 on-the-fly 配置)、单测和覆盖率解析插件。
  aone 测试任务任务日志解析:
  (1)codecoverage-unittest-pre 自动注入 jacoco-maven-plugin。
  (11:00:12) Execute codecoverage-unittest-pre plugin
  (11:00:13) ignore submodules.
  (11:00:13) Execute plugin command
  (11:00:13) cd /root/cise/space/135295299/plugin/codecoverage-unittest-pre && ./codecoverage-unittest-pre  -d  "/root/cise/space/135295299/source/pom.xml"
  (11:00:13) Plugin unit-test code coverage pre processer begins.
  (11:00:14) 2022-03-01 11:00:14 INFO  PomUtils - pom file found:/root/cise/space/135295299/source/pom.xml
  (11:00:14) 2022-03-01 11:00:14 INFO  PomUtils - backup pom file success.

  这里的日志还有两种情况:
  1)pom 已经配置 jacoco。
  当我们出于各种情况,例如对插件版本、运行阶段或者为了解决覆盖率统计问题,自行配置了 jacoco 插件后,aone 便不会再自动注入。
  Execute codecoverage-unittest-pre plugin
  (11:00:24) ignore submodules.
  (11:00:24) Execute plugin command
  (11:00:24) cd /root/cise/space/135294989/plugin/codecoverage-unittest-pre && ./codecoverage-unittest-pre  -d  "/root/cise/space/135294989/source/buc.acl.shared.parent/pom.xml"
  (11:00:25) Plugin unit-test code coverage pre processer begins.
  (11:00:25) 2022-03-01 11:00:25 INFO  PomUtils - pom file found:/root/cise/space/135294989/source/buc.acl.shared.parent/pom.xml
  (11:00:25) 2022-03-01 11:00:25 INFO  PomUtils - backup pom file success.
  (11:00:25) 2022-03-01 11:00:25 ERROR PomUtils - pom.xml contains jacoco-maven-plugin;no need to modify pom.
  (11:00:25) 2022-03-01 11:00:25 INFO  PomUtils - contains jacoco plugin,no need to modify pom.

  2)pom 中缺少 标签提示 jacoco.exec not found。

  (16:41:13) Execute codecoverage-unittest-pre plugin
  (16:41:14) ignore submodules.
  (16:41:15) Execute plugin command
  (16:41:15) cd /root/cise/space/135189936/plugin/codecoverage-unittest-pre && ./codecoverage-unittest-pre  -d  "/root/cise/space/135189936/source/pom.xml"
  (16:41:15) Plugin unit-test code coverage pre processer begins.
  (16:41:15) 2022-02-28 16:41:15 INFO  PomUtils - pom file found:/root/cise/space/135189936/source/pom.xml
  (16:41:15) 2022-02-28 16:41:15 INFO  PomUtils - backup pom file success.
  (16:41:15) 2022-02-28 16:41:15 ERROR PomUtils - <build> element not found.

  (2)JaCoCo 插件在 prepare-agent 阶段注入 javaagent 参数。
  (11:00:18) [INFO] --- jacoco-maven-plugin:0.8.7:prepare-agent (jacoco-initialize) @  ---
  (11:00:18) [INFO] Downloading ...
  (11:00:18) [INFO] Downloaded ...
  (11:00:20) [INFO] argLine set to -javaagent:/root/.m2/repository/org/jacoco/org.jacoco.agent/0.8.7/org.jacoco.agent-0.8.7-runtime.jar=destfile=/root/cise/space/135295299/source/target/jacoco.exec

  这里可能会出现另一种情况,日志出现,但实际运行进程中,并没有 javaagent 参数,导致覆盖率无法统计。
  grep 进程排查
  执行 mvn test 过程中,日志中看到 case 已经在running中时,查看进程。
  ps -ef | grep java
 
  找到测试对应的进程,观察完整的进程命令,如果jacoco生效了会看到下图,如果没有生效,则ps -ef | grep java | grep jacoco看不到任何进程。
  测试运行时为什么会没有 JaCoCo 这个 javaagent ?
  我们通常会配置maven-surefire-plugin去跑测试。
  <plugins>
    <plugin>
      <groupId>org.apache.maven.plugins</groupId>
      <artifactId>maven-surefire-plugin</artifactId>
      <configuration>
        <argLine>"some jvm args"</argLine>
      </configuration>
    </plugin>
  </plugins>

  当 surefire 配置运行参数后,mvn test 时其他插件就无法自动添加更多的 JVM 运行参数,比如 jacoco 插件。argLine的正确写法如下:
  <argLine>${argLine} "some jvm args"</argLine>
 
  (3)覆盖率报告解析
  实验室是通过获取运行的标准输出,解析后得到运行结果。因此可以通过在脚本、插件执行过程中按照相应的格式输出内容,达到统计运行结果以及定义页面展现的目的。
  (16:56:13) INTEGRATION_COVERAGE: {"appName":"mozi-im-gateway-private","branchCovered":0,"branchRatio":0.0,"branchTotal":30,"buildId":125846455,"classCovered":0,"classRatio":0.0,"classTotal":17,"coverageRecordId":9579151,"env":"local","lineCovered":0,"lineRatio":0.0,"lineTotal":185,"methodCovered":0,"methodRatio":0.0,"methodTotal":75,"name":"buc.login.spi","nodeType":"JAR","parent":"mozi-im-gateway-private","type":"unnit-test","updated":0}
  (16:56:13) INTEGRATION_COVERAGE: {"appName":"mozi-im-gateway-private","branchCovered":0,"branchRatio":0.0,"branchTotal":38,"buildId":125846455,"classCovered":0,"classRatio":0.0,"classTotal":5,"coverageRecordId":9579151,"env":"local","lineCovered":0,"lineRatio":0.0,"lineTotal":124,"methodCovered":0,"methodRatio":0.0,"methodTotal":17,"name":"buc.sso.application","nodeType":"JAR","parent":"mozi-im-gateway-private","type":"unnit-test","updated":0}
  (16:56:13) INTEGRATION_COVERAGE: {"appName":"mozi-im-gateway-private","branchCovered":49,"branchRatio":0.0016,"branchTotal":29792,"buildId":125846455,"classCovered":20,"classRatio":0.0103,"classTotal":1934,"coverageRecordId":9579151,"env":"local","lineCovered":374,"lineRatio":0.0048,"lineTotal":77160,"methodCovered":115,"methodRatio":0.0069,"methodTotal":16618,"name":"mozi-im-gateway-private","nodeType":"APP","type":"unnit-test","updated":1,"updatedLineCovered":76,"updatedLineTotal":381,"updatedRatio":0.1995}
  (16:56:13) *************************************************************
  (16:56:13) CODE_COVERAGE_LINES: 374/77160
  (16:56:13) CODE_COVERAGE_NAME_LINES: 行
  (16:56:13) CODE_COVERAGE_BRANCHES: 49/29792
  (16:56:13) CODE_COVERAGE_NAME_BRANCHES: 分支
  (16:56:13) CODE_COVERAGE_METHODS: 115/16618
  (16:56:13) CODE_COVERAGE_NAME_METHODS: 方法
  (16:56:13) CODE_COVERAGE_CLASSES: 20/1934
  (16:56:13) CODE_COVERAGE_NAME_CLASSES: 类
  (16:56:13) CODE_COVERAGE_REPORT_LINES:http://test.aone.alibaba-inc.com/coverages/9579151
  (16:56:13) CODE_COVERAGE_REPORT_BRANCHES:http://test.aone.alibaba-inc.com/coverages/9579151
  (16:56:13) CODE_COVERAGE_REPORT_METHODS:http://test.aone.alibaba-inc.com/coverages/9579151
  (16:56:13) CODE_COVERAGE_REPORT_CLASSES:http://test.aone.alibaba-inc.com/coverages/9579151
  (16:56:13) CODE_COVERAGE_UPDATELINES: 76/381
  (16:56:13) CODE_COVERAGE_NAME_UPDATELINES: 行增量
  (16:56:13) CODE_COVERAGE_REPORT_UPDATELINES: http://test.aone.alibaba-inc.com/coverages/9579151
  (16:56:13) *************************************************************
  (16:56:13) Execute case_result_parser plugin
  (16:56:15) ignore submodules.
  (16:56:15) Execute plugin command
  (16:56:15) cd /root/cise/space/135192024/plugin/case_result_parser && ./parser  -p  "/root/cise/space/135192024/source" -d  "false" -t  "common" -u  "https://testservice.aone.alibaba-inc.com/ak/testservice/api/pipelineResult/utApiCallBack/build/607b495e-3d6d-4a8d-a18f-3cdcd226f860/stage/单元测试"
  (16:56:15) /root/cise/space/135192024/plugin/case_result_parser
  (16:56:15) [INFO] Project: /root/cise/space/135192024/source
  (16:56:15) [INFO] Image: /root/cise/space/135192024/source/images/-815718685
  (16:56:15) [INFO] Start to parse
  (16:56:16) [INFO] End parsing
  (16:56:16) [INFO] Parsed test case count:53
  (16:56:16) [INFO] Paased count: 53
  (16:56:16) [INFO] Failed count: 0
  (16:56:16) TEST_CASE_AMOUNT: {"blocked":0,"passed":53,"failed":0,"skipped":0}
  (16:56:16) [INFO] Callback url: https://testservice.aone.alibaba-inc.com/ak/testservice/api/pipelineResult/utApiCallBack/build/607b495e-3d6d-4a8d-a18f-3cdcd226f860/stage/单元测试
  (16:56:16) [INFO] Max data size: 45000000
  (16:56:16) [INFO] Current data size: 8159
  (16:56:16) log4j:WARN No appenders could be found for logger (org.apache.commons.httpclient.HttpClient).
  (16:56:16) log4j:WARN Please initialize the log4j system properly.
  (16:56:16) [INFO] Send result to server, time: 01
  (16:56:17) [INFO] Response: {"success":true,"messages":[],"result":"https://aone.alibaba-inc.com/pipeline/build/detail?buildDetailId=156703515&pipelineId=2905023","errorCode":null,"other":null,"msgCode":null,"msgInfo":null,"message":""}
  (16:56:17) TEST_REPORT: https://aone.alibaba-inc.com/pipeline/build/detail?buildDetailId=156703515&pipelineId=2905023

  如果实在定位不到问题,可以看源码了解,可以看到 jacoco 插件是如何被植入 pom 文件的。
  5.JaCoCo 插桩和覆盖率统计逻辑
  Jacoco 的插桩原理是通过在方法表的 Code 区插入与代码覆盖率相关的执行代码,Jacoco 就可以感知字节码的执行过程。简单来讲,Jacoco 会对字节码文件进行四个修改:
  ·为类增加 $jacocoData 属性;
  · 为类增加 $jacocoInit 方法;
  · 在每个方法开头创建一个 boolean 类型的数组,JaCoCo 利用这个数组来实现探针(Probe);
  · 每行代码都会有一个探针对应到此数组中,运行时修改 boolean 数组中的项来实现探针。
  在代码执行完对应 statement 后,会将数组对应探针位置修改为true。最终 Jacoco 通过各个类的探针数组数据计算代码覆盖率。
  Jacoco 插桩后的字节码文件示例如下:
  JacocoProbeTest.class
  /* synthetic */
  private static transient boolean[] $jacocoData;
  // $jacocoInit 是 jacoco 生成的代码,以下为近似逻辑
  private static /* synthetic */ boolean[] $jacocoInit() {
          if ($jacocoData != null) {
              return $jacocoData;
          }
          { 
              Object[] args = new Object[3];
              // class id
              args[0] = Long.valueOf(8060044182221863588); 
              // class name
              args[1] = "com/example/JacocoProbeTest";                     // probecount
              args[2] = Integer.valueOf(4);               
              // Jacoco 的特殊方法,会修改args[0] 的值
              new RuntimeData().equals(args);
              $jacocoData =  (boolean[])args[0];
          }
          return $jacocoData;
      }
  }
  // 覆盖率统计,执行一行代码,即将 Probe 数组对应位置置位 true
  public JacocoProbeTest() {
     boolean[] b_arr_1 = JacocoProbeTest.$jacocoInit();
     b_arr_1[0] = true;
  }
  public static void testSingleLineProbe() {
     boolean[] b_arr_1 = JacocoProbeTest.$jacocoInit();
     System.out.println("testSingleLineProbe");
     b_arr_1[1] = true;
  }
  public static void testThrowExceptionProbe() {
     boolean[] b_arr_1 = JacocoProbeTest.$jacocoInit();
     System.out.println("testThrowExceptionProbe");
     b_arr_1[2] = true;
     b_arr_1[3] = true;
     throw new IllegalArgumentException();
  }
  下面是 intellij-coverage-agent 覆盖率插桩的代码,它的埋点比起 jacoco 埋点使用了行号进行记录
  源代码:

  插桩后的代码:
  @Test
   public void testGetHiddenMenuCodes() {
     Object __class__data__ = ProjectData.loadClassData("com.alibaba.buc.acl.data.service.manage.impl.com.alibaba.buc.acl.data.service.manage.impl.ActionManageServiceImplTest");
     ProjectData.touchLine(__class__data__, 140);
     Set<String> hiddenMenuCodes = this.actionManageService.getHiddenMenuCodes(this.tenantId, this.appName);
     ProjectData.touchLine(__class__data__, 142);
     Assert.assertTrue(hiddenMenuCodes.containsAll(Arrays.asList(this.hiddenMenuCodes, "permissionManageGroup", "businessStripConfig", "divisionConfig", "stripOrgManager", "cooperationApplication", "cooperationList", "cooperationManagement")));
     ProjectData.touchLine(__class__data__, 153);
   }

  6.JaCoCo 与 PowerMock 的冲突原理分析
  下面是我在网络上找到的一段关于 JaCoCo 与 PowerMock 的冲突分析的说明。
  JaCoCo 以 Javaagent 方式启动时,会为每一个加载进 JVM 的类插桩埋点。而 PowerMock 使用的 javaassist 接收到的某个类的字节码数据(原始字节码已经被其他非 javassist 的方式增强之后的字节码)和此类对应的 class 文件的数据不一致时,javassist 就会废弃掉接收到的已经被改变的字节码数据,转而使用此类最原始的 class 文件进行增强。这就导致 JaCoCo 埋入类字节码中的监控点被覆盖,导致其无法感知到被重新加载的类的执行过程,使得类的代码覆盖率结果为 0。
  这段话有没有问题呢?真的是覆盖率统计代码被覆盖吗?
  进一步想一想,为什么在 IDEA 中运行时可以统计到代码覆盖率? 而在 Aone 却又未覆盖?
  提示 1:PowerMock 调用 javaassist 修改类后是不是会再次触发 javaagent 的修改。
  提示 2:IDEA 运行覆盖率统计时,默认采用 intellij-converage-agent。
  提示 3:jacoco 生成覆盖率报告的方式,ClassID 。
  jacoco.exec 是记录覆盖率的二进制文件,有时候会命名为 coverage.exec。
  先从 jacococli 命令行生成报告的参数做个推理。
  参数中指定了覆盖率报告、类的字节码以及输出目录,可以推测 JaCoCo 会根据原始字节码与覆盖率数组做匹配。在 jacocoagent 和 PowerMock 同时使用的情况下,jacoco.exec 中记录的 ClassID 是两次增强后的字节码计算的结果,生成覆盖率报告时,根据原始字节码计算出的 ClassID 无法匹配 jacoco.exec 中的记录,直接判断为 noMatch,因此未生成有效的覆盖率报告。
  这段计算逻辑可以在分析覆盖率报告的这个方法中看到[3],有兴趣可以下载源码分析。
  jacoco 的官网中已经解释了为什么要使用字节码生成的 ClassID 作为类标识。

  解决方案
  目前比较主流的解决方法为使用 JaCoCo 的 offline 模式先对字节码文件进行插桩。在应用启动时加载进 JVM 的字节码文件就不需要动态插桩了,可以被 Powermock 正常增强。但是这种方式需要修改字节码文件,对业务的执行与发布过程有影响,需要测试完成后进行二次部署。
  优化方向
  因为 JaCoCo 生成覆盖率报告时依赖源码和原字节码,而 intellij-coverage-agent 则不需要,原因是它的插桩代码自带行数,因此不需要对照源码和原字节码进行分析,或许可以参考修改 JaCoCo 的插桩代码,但这比起修改一个数组的插槽,会带来一些性能损耗。

  本文内容不用于商业目的,如涉及知识产权问题,请权利人联系51Testing小编(021-64471599-8017),我们将立即处理
《2023软件测试行业现状调查报告》独家发布~

关注51Testing

联系我们

快捷面板 站点地图 联系我们 广告服务 关于我们 站长统计 发展历程

法律顾问:上海兰迪律师事务所 项棋律师
版权所有 上海博为峰软件技术股份有限公司 Copyright©51testing.com 2003-2024
投诉及意见反馈:webmaster@51testing.com; 业务联系:service@51testing.com 021-64471599-8017

沪ICP备05003035号

沪公网安备 31010102002173号