一、代码覆盖率理解
代码覆盖(Code coverage)是软件测试中的一种度量,描述程序中源代码被测试的比例和程度,所得比例称为代码覆盖率。
简单来理解,就是单元测试中代码执行量与代码总量之间的比率。
Java常用的单元测试覆盖率框架有:JaCoCo、EMMA和Cobertura,本篇文章主要介绍JaCoCo的使用。
二、JaCoCo理解
JaCoCo官方文档:https://www.eclemma.org/jacoco/trunk/doc/index.html
JaCoCo应该为基于Java VM的环境中的代码覆盖率分析提供标准技术。重点是提供一个轻量级,灵活且文档齐全的库,以与各种构建和开发工具集成。
产品定义
1.功能特征:
- 指令(C0),分支(C1),行,方法,类型和循环复杂度的覆盖率分析
- 基于Java字节码,因此无需源文件也可以工作
- 通过基于 Java-agent 的即时检测进行简单集成。其他集成方案(例如自定义类加载器)也可以通过API来实现
- 与框架无关的:与基于Java VM的应用程序(如纯Java程序,OSGi框架,Web容器或EJB服务器)平滑集成
- 与所有已发布的Java类文件版本兼容
- 支持不同的JVM语言
- 几种报告格式(HTML,XML,CSV)
- 远程协议和JMX控制可在任何时间点从coverage agent请求执行数据dump
- Ant任务,用于收集和管理执行数据并创建结构化的覆盖率报告
- Maven插件可收集覆盖率信息并在Maven构建中创建报告
非功能特征:
- 简单的用法以及与现有构建脚本和工具的集成
- 良好的性能和最小的运行时开销,尤其是对于大型项目
- 轻量级实现,对外部库和系统资源的依赖性最小
- 全面的文档
- 完整记录的API(JavaDoc)以及与其他工具集成的示例
- 基于JUnit测试用例的功能全面的回归测试
2. JaCoCo集成
JaCoCo集成主要包括JaCoCo/EclEmma 项目提供的集成和第三方集成
1)JaCoCo/EclEmma 项目集成
2) 第三方集成
3. 覆盖率计数器 - Coverage Counters
JaCoCo使用一组不同的计数器来计算覆盖率指标。所有这些计数器都从Java类文件中包含的信息派生而来,这些信息基本上是Java字节码指令以及调试信息(可选地嵌入在类文件中)。即使没有可用的源代码,这种方法也可以对应用程序进行高效的即时检测和分析(instrumentation and analysis)。在大多数情况下,可以将收集到的信息映射回源代码,并可视化到行级粒度。无论如何,这种方法存在局限性。必须使用调试信息编译类文件,以计算行级覆盖率并提供源高亮显示。并非所有Java语言构造都可以直接编译为相应的字节码。在这种情况下,Java编译器会创建所谓的合成代码,有时会导致意外的代码覆盖率结果。
以下是JaCoCo统计的指标维度
1)指令 - Instructions(C0覆盖率)
JaCoCo计数的最小单位是单个Java字节代码指令。指令覆盖率提供有关已执行或遗漏(executed or missed)的代码量的信息。该度量完全独立于源格式,并且即使在类文件中没有调试信息的情况下也始终可用。
2)分支 - Branches(C1覆盖率)
JaCoCo还为所有if和switch语句计算分支覆盖率。此度量标准统计方法中此类分支的总数,并确定已执行或遗漏的分支的数量。分支覆盖始终可用,即使类文件中没有调试信息也是如此。请注意,在此计数器定义的上下文中,异常处理不视为分支。
如果尚未使用调试信息编译类文件,则可以将决策点映射到源代码行并高亮:
- 无覆盖范围:该行没有分支执行(红色菱形)
- 部分覆盖:仅执行了该行中的一部分分支(黄色菱形)
- 全面覆盖:该行中的所有分支均已执行(绿色菱形)
3)循环复杂度 - Cyclomatic Complexity
JaCoCo 还为每种非抽象方法计算循环复杂度,并汇总了类,包和组的复杂度。根据 McCabe1996 的定义,循环复杂度是可以(线性)组合生成一种方法的所有可能路径的最小路径数。因此,复杂度值可以作为完全覆盖某个软件的单元测试用例数量的指示。即使类文件中没有调试信息,也总是可以计算复杂度数字。
循环复杂度v(G)的形式定义基于方法的控制流图作为有向图的表示:
v(G)= E- N 2
其中,E是边数,N是节点数。 JaCoCo根据分支数(B)和决策点数(D)使用以下等效方程式计算方法的循环复杂度:
v(G)= B - D + 1
根据每个分支的覆盖状态,JaCoCo还可以计算每种方法的覆盖和遗漏复杂度。缺少的复杂性再次表明完全覆盖模块的测试用例的数量。请注意,由于JaCoCo不考虑异常处理,因为分支try / catch块也不会增加复杂性。
4)行
对于已使用调试信息编译的所有类文件,可以计算各个行的覆盖率信息。当已执行至少一个分配给该源代码行的指令时,该源代码行被视为已执行。
由于单行通常会编译为多字节代码指令,因此,源代码高亮显示每行包含源代码的三种不同状态:
无覆盖:该行中没有指令被执行(红色背景)
部分覆盖:仅执行了该行中的一部分指令(黄色背景)
全面覆盖:该行中的所有指令均已执行(绿色背景)
根据源格式,源代码的一行可能会引用多个方法或多个类。因此,不能简单地添加方法的行数以获得包含类的总数。单个源文件中的多个类的行也是如此。 JaCoCo根据覆盖的实际源代码行计算类和源文件的代码行覆盖率。
5)方法
每个非抽象方法都包含至少一条指令。当至少一个指令已被执行时,一种方法被视为已执行。由于JaCoCo在字节码级别上工作,因此构造函数和静态初始化程序也被视为方法。这些方法中的某些方法在Java源代码中可能没有直接的对应关系,例如隐式生成的常量的默认构造函数或初始化器。
6)类
当至少一个类的方法已执行时,该类被视为已执行。 请注意,JaCoCo将构造函数以及静态初始化程序视为方法。 由于Java接口类型可能包含静态初始化器,因此此类接口也被视为可执行类。
三、Maven设置JaCoCo插件
- 引入Maven插件
<!-- https://mvnrepository.com/artifact/org.jacoco/jacoco-maven-plugin -->
<dependency>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.7</version>
</dependency>
2. 配置该插件的执行标签<executions>
对于运行简单的单元测试,在执行标签中设置的两个目标可以正常工作。最低限度是设置准备代理(prepare-agent)和报告目标(report),配置如下:
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.6</version>
<executions>
<execution>
<id>prepare-agent</id>
<goals>
<goal>prepare-agent</goal>
</goals>
</execution>
<execution>
<id>report</id>
<phase>test</phase>
<goals>
<goal>report</goal>
</goals>
</execution>
</executions>
</plugin>
prepare-agent goal: prepare-agent 目标准备 JaCoCo 运行时代理以记录执行数据。它记录了执行的行数、回溯的行数等。默认情况下,将执行数据写入文件target/jacoco-ut.exec。
report goal: 报告目标根据 JaCoCo 运行时代理记录的执行数据创建代码覆盖率报告。由于我们已经指定了阶段属性,报告将在测试阶段编译后创建。默认从文件中读取执行数据target/jacoco-ut.exec,将代码覆盖率报告写入目录target/site/jacoco/index.html。
所有配置的Goals,详见官网https://www.eclemma.org/jacoco/trunk/doc/maven.html
其中比较常用的是 prepare-agent、report和check
report goal的详细配置如下:
<execution>
<id>report</id>
<phase>test</phase>
<goals>
<goal>report</goal>
</goals>
<configuration>
<!--定义输出的文件夹-->
<outputDirectory>target/jacoco-report</outputDirectory>
<!--执行数据的文件-->
<dataFile>${project.build.directory}/jacoco.exec</dataFile>
<!--要从报告中排除的类文件列表,支持通配符(*和?)。如果未指定则不会排除任何内容-->
<excludes>**/test/*.class</excludes>
<!--包含生成报告的文件列表,支持通配符(*和?)。如果未指定则包含所有内容-->
<includes></includes>
<!--HTML 报告页面中使用的页脚文本。-->
<footer></footer>
<!--生成报告的文件类型,HTML(默认)、XML、CSV-->
<formats>HTML</formats>
<!--生成报告的编码格式,默认UTF-8-->
<outputEncoding>UTF-8</outputEncoding>
<!--抑制执行的标签-->
<skip></skip>
<!--源文件编码-->
<sourceEncoding>UTF-8</sourceEncoding>
<!--HTML报告的标题-->
<title>${project.name}</title>
</configuration>
</execution>
check goal可以配置检查相关的设置,比如配置最低覆盖率为0.9(90%)
<execution>
<id>jacoco-check</id>
<goals>
<goal>check</goal>
</goals>
<configuration>
<rules>
<rule>
<element>PACKAGE</element>
<limits>
<limit>
<counter>LINE</counter>
<value>COVEREDRATIO</value>
<minimum>0.9</minimum>
</limit>
</limits>
</rule>
</rules>
</configuration>
</execution>
如果覆盖率未达到最低限制,则mvn test会报错
[INFO] Analyzed bundle 'test' with 1 classes
[WARNING] Rule violated for package com.**.examples.jacoco: lines covered ratio is 0.8, but expected minimum is 0.9
四、示例
创建maven项目,引入
Junit5-单元测试
jacoco-覆盖率统计
maven依赖和插件配置如下:
<dependencies>
<!-- https://mvnrepository.com/artifact/org.junit.jupiter/junit-jupiter-api -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.8.2</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<source>11</source>
<target>11</target>
<encoding>UTF8</encoding>
</configuration>
<version>3.8.1</version>
</plugin>
<plugin>
<artifactId>maven-surefire-plugin</artifactId>
<version>2.22.2</version>
</plugin>
<plugin>
<artifactId>maven-failsafe-plugin</artifactId>
<version>2.22.2</version>
</plugin>
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.7</version>
<executions>
<execution>
<goals>
<goal>prepare-agent</goal>
</goals>
</execution>
<execution>
<id>report</id>
<phase>test</phase>
<goals>
<goal>report</goal>
</goals>
<configuration>
<!--定义输出的文件夹-->
<outputDirectory>target/jacoco-report</outputDirectory>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
创建MessageBuilder类
public class MessageBuilder {
public String getMessage(String name) {
StringBuilder result = new StringBuilder();
if (name == null || name.trim().length() == 0) {
result.append("empty!");
} else {
result.append("Hello " + name);
}
return result.toString();
}
}
编写单元测试类MessageBuilderTest
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
public class MessageBuilderTest {
@Test
public void testGetMessage1() {
MessageBuilder obj = new MessageBuilder();
assertEquals("Hello test", obj.getMessage("test"));
}
}
mvn clean test 进行测试并生成覆盖率的统计
在生成目录中找到index.html文件
点击Element可以查看详细的情况
我们发现分支中只执行了else的部分
测试类中增加为空的情况
@Test
public void testGetMessage2() {
MessageBuilder obj = new MessageBuilder();
assertEquals("empty!", obj.getMessage(""));
}
再次 mvn clean test
生成的统计信息如下:
分支覆盖率只有75%,原因if语句中的为null情况未覆盖
继续添加测试用例(为null的情况)
@Test
public void testGetMessage3(){
MessageBuilder obj = new MessageBuilder();
assertEquals("empty!", obj.getMessage(null));
}
分支覆盖率终于达到了100%