Skip to content

JaCoCo

本文介绍代码覆盖率和JaCoCo工具。

1. 代码覆盖率

代码覆盖率(Code Coverage) 是软件测试中的一个核心指标,用于衡量在运行测试用例时,代码中有多少比例被实际执行到了。简单来说,它告诉你“你的测试到底检查了多少代码”。

代码覆盖率分为以下几类:

1.1 行覆盖率

行覆盖率:被执行的代码行数占总行数的比例。

例如,代码总行数为10行,测试用例执行了8行代码,那行覆盖率为80%。

1.2 分支覆盖率

分支覆盖率:程序中每个判定条件(如 if 语句)的“真”和“假”分支是否都至少执行了一次。

例如:if (a > 0) { ... } else { ... }

只有if分支和else分支都执行了,覆盖率才到100%。

1.3 条件覆盖率

条件覆盖率:判定语句中的每个子条件(如 if (A && B) 中的 A)其取值(真/假)是否都出现过。

例如:if (A && B),要满足条件覆盖率,只需要满足以下条件:

txt
A = True 一次
A = False 一次
B = True 一次
B = False 一次

可以用以下测试:

ABResult
TFF
FTF

条件覆盖已经 100% 了。

但是,结果始终为False,if分支始终没有执行,条件覆盖并不能保证判定逻辑被真正测试到。

1.4 修正条件/判定覆盖

修正条件/判定覆盖 :Modified Condition/Decision Coverage,简称为MCDC。

普通的“分支覆盖”或“条件覆盖”在面对复杂的逻辑判断时(比如 if (A && B || C)),往往无法排除“巧合”。

  • 分支覆盖:只管整个括号是 True 还是 False。
  • 条件覆盖:只管 A, B, C 分别取过 True/False。

MCDC 的核心逻辑是: 必须证明每一个子条件(A, B, C)都能独立地影响整个大判定的结果。

要达到 MCDC 覆盖,必须满足以下四个条件:

  1. 每一个判定(整个大表达式)的所有可能结果(True/False)至少出现一次。

  2. 判定中每一个子条件的所有可能结果(True/False)至少出现一次。

  3. 每一个入口/出口点(函数开始和结束)至少执行一次。

    对于 Java 方法来说,入口点通常只有一个,即方法的第一行指令,通常我们调用方法method(),就算覆盖了入口点。

    但是,一个方法的出口点可能有多个,在逻辑上可能从不同的位置“结束”并返回给调用者,常见的出口点包括:

    • 正常的 return 语句:如果函数里有多个 return,每一个都必须被执行到。

    • 异常抛出 (Throwing Exceptions):如果函数可能抛出异常(无论是显式的 throw 还是隐式的运行时异常),这也是一个出口点。

    • 方法体结束:对于 void 方法,执行完最后一行代码也是一个出口点。

    只有所有出口都跑到了,才算满足了“每一个出口点至少执行一次”。

  4. 关键点:每一个子条件都必须证明能够独立影响判定的结果。

    什么是“独立影响”? 在保持其他子条件不变的情况下,仅仅改变子条件 A 的值,整个大判定的结果也会随之改变。

例如:if (A && B),要满足MCDC,可以使用以下测试用例:

ABResult
TFF
FTF
TTT

为什么不用A=F,B=F这个测试用例呢,因为无法证明子条件的独立性。

假设要证明 A 是影响大判定的独立因素。按照科学实验的逻辑,我们需要:

  1. 保持其他条件(B)不变。
  2. 改变 A 的值。
  3. 观察大判定的结果是否也跟着改变。

结果仍然为False,因此无法证明A是大判定的独立因素。

1.5 路径覆盖

路径覆盖:路径覆盖(Path Coverage) 是软件测试覆盖率里最严格的一种之一,它的定义是:测试用例需要覆盖程序中所有可能的执行路径。

什么是路径?假设有以下代码:

java
if (A) {
    do1();
} else {
    do2();
}

if (B) {
    do3();
}
txt
    start
      |
    if A
   /    \
do1     do2
   \    /
    if B
   /    \
do3    end

所有可能路径:

  1. A = T, B = T → do1 → do3
  2. A = T, B = F → do1
  3. A = F, B = T → do2 → do3
  4. A = F, B = F → do2

所以一共有 4 条路径。如果测试用例把这 4 条路径都走了一遍:就达到了路径覆盖 100%。

路径覆盖在实际项目中几乎无法做到,因为存在 路径爆炸(Path Explosion)

例如:

java
if (A)
if (B)
if (C)
if (D)

程序的所有路径数为24=8。当程序中有n个判断时,路径数为2n。当程序中有10个判断时,路径数为1024,完整路径覆盖几乎不可能实现。

并且,循环会导致无限路径:

java
while (i < n) {
    doSomething();
}

路径:

txt
执行0次
执行1次
执行2次
执行3次
...
无限

因此,实际测试中通常使用:Basis Path Coverage(基本路径覆盖)

Basis Path Coverage(基本路径覆盖)由 McCabe 圈复杂度(Cyclomatic Complexity) 决定最少需要测试的路径数量。

公式:

V(G) = E − N + 2

或者更常用:

V(G) = 判定节点数 + 1

判定节点包括:

  • if
  • else if
  • for
  • while
  • do while
  • case
  • catch
  • &&
  • ||

例如:

if (A)
if (B)
if (C)

有 3 个 if:

需要至少 4 条路径测试

这就是工程上常说的:一个方法至少要有 N+1 个测试用例(N = if 数量)

例如:

java
public void foo() {
    if (a > 0) {
        do1();
    }
}

有1个if,V(G) = 1 + 1 = 2,说明需要至少 2 个测试用例

java
if (a > 0) {
    ...
} else {
    ...
}

if (b > 0) {
    ...
}

2 个if,V(G) = 2 + 1 = 3,说明需要至少 3 个测试用例

java
if (a > 0 && b > 0) {
    ...
}

有1个if ,1个&& ,V(G) = 2 + 1 = 3,说明需要至少 3 个测试用例

java
public int sum(int n) {
    int s = 0;

    for (int i = 0; i < n; i++) {
        s += i;
    }

    return s;
}

有一个for,V(G) = 1 + 1 = 2,说明需要至少 2 个测试用例:进入循环和不进入循环。

但是,从循环测试策略的角度,通常需要测试 0 次、1 次、多次循环,因此一般设计 3 个测试用例。

2. JaCoCo

2.1 介绍

JaCoCo,全称为Java Code Coverage,是 Java 生态里最常用的代码覆盖率工具,它用于统计在测试后代码的覆盖率。

JaCoCo 使用的是 字节码插桩(Bytecode Instrumentation) 技术。大致流程:

编译 Java 代码

JaCoCo 修改 .class 字节码(插入探针 probe)

运行测试

记录哪些代码被执行

生成覆盖率报告

所以它不是通过源码分析,而是通过 修改 class 文件记录执行情况

在JaCoCo报告中,有以下覆盖率指标:

  • 指令覆盖率(Instructions,C0覆盖率):JaCoCo 衡量的是 Java 字节码指令 的执行情况。

    • 为什么不直接算源码? 因为源码的写法非常灵活。一行代码可能包含多个指令(例如一行内有三元运算符、链式调用或复杂的逻辑组合)。
  • 分支覆盖率(Branches,C1覆盖率):JaCoCo 会为所有 if 和 switch 语句计算分支覆盖率。JaCoCo会计算方法中的总分支数以及执行过或未执行过的分支数,注意,在JaCoCo中认为异常处理不算分支:

    java
    try {
        foo();
    } catch (Exception e) {
        ...
    }

    很多人以为:

    txt
    try 成功
    try 抛异常

    是有两个分支,但是在JaCoCo中,异常不算分支。

    在 JaCoCo HTML 报告里,会看到判断点代码旁边有 菱形(diamond)

    • 红色菱形:没有任何分支被执行;
    • 黄色菱形:只执行了部分分支;
    • 绿色菱形:所有分支都执行了;

    在JaCoCo中,&& 和 || 也会产生分支:

    java
    if (a > 0 && b > 0) {
        ...
    }

    JaCoCo认为有3个分支:

    分支说明
    a <= 0false
    a > 0 && b <= 0false
    a > 0 && b > 0true
  • 行覆盖率(Lines):Java 源代码中的一行可能会被翻译为多行字节码,在JaCoCo报告中,一行的背景色也表示了行覆盖率情况:

    • 红色背景:这一行没有指令被执行过;
    • 黄色背景:这一行部分指令被执行过;
    • 绿色背景:这一行所有指令都被执行过;

JaCoCo也会计算圈复杂度(Cyclomatic Complexity)。

2.2 基本使用

在pom.xml文件中引入以下插件:

xml
<build>
    <plugins>
        <plugin>
            <groupId>org.jacoco</groupId>
            <artifactId>jacoco-maven-plugin</artifactId>
            <version>0.8.14</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>

    </plugins>
</build>

查看最新的jacoco-maven-plugin版本:https://central.sonatype.com/artifact/org.jacoco/jacoco-maven-plugin

使用jacoco-maven-plugin插件,有以下要求:

  • 如果想要在报告中包含行号信息,或者想要源代码高亮,测试目标类文件必须带有调试信息进行编译。

    在编译时(javac),如果加上 -g 参数,编译器会在 .class 文件中插入一些元数据(LineNumberTable)。

    Maven 的编译插件 maven-compiler-plugin 默认就是开启调试信息的,所以通常不需要额外配置。

  • 使用 maven-surefire-plugin 或 maven-failsafe-plugin 时,绝不能将 forkCount 设置为 0,这会阻止 javaagent 的加载,导致无法记录覆盖率。

    jacoco的运行原理是在字节码中插桩,实际就是通过代理启动JVM(形如 -javaagent:jacocoagent.jar=...),这个 Agent 必须在 JVM 启动的那一刻加载进去。

    如果将forkCount设置为0,表示由maven主进程运行测试,由于maven主进程已经运行了,jacoco无法设置代理,所以也就无法记录代码覆盖率。

execution 是什么意思?

在 Maven 中,plugin(插件)是功能的载体,而 execution(执行任务)是具体的指令。

  • 插件(Plugin):像是一个工具箱(比如 JaCoCo 里的“统计工具”)。
  • 目标(Goal):工具箱里的具体工具(比如 prepare-agent 准备代理、report 生成报告)。
  • 执行(Execution):规定了在什么时候(phase)、用哪个工具(goal)去做事。

以上jacoco插件,定义了两个动作:

  • prepare-agent:JaCoCo 需要在测试运行之前,修改 Java 虚拟机的启动参数(插桩)。因为 prepare-agent 几乎总是需要在最开始执行,JaCoCo 的开发者为了方便大家,直接在插件代码里写死了默认绑定到 initialize阶段。

  • report:与 prepare-agent 不同,report 的目标是“生成可视化报告”。生成报告的前提是必须先有测试数据(即 jacoco.exec 文件)。这个文件是在单元测试跑完之后才产生的。

    如果绑定到 test 阶段,当运行 mvn test 时,测试一结束,报告紧接着就出来了。

    在更严谨的生产配置中,很多人会将其绑定到 verify 阶段。因为 verifytest 之后,专门用于对测试结果进行各种检查和报告生成。

通过以上配置,当我们运行mvn test之后,就会生成jacoco报告,位置在target/site/jacoco/index.html

2.3 基本案例

首先准备一个工具类,用来判断一个字符串是否为回文字符串:

java
public class Palindrome {
    public static boolean isPalindrome(String inputString) {
        try {
            if (inputString.isEmpty()) {
                return true;
            } else {
                char firstChar = inputString.charAt(0);
                char lastChar = inputString.charAt(inputString.length() - 1);
                String mid = inputString.substring(1, inputString.length() - 1);
                return (firstChar == lastChar) && isPalindrome(mid);
            }
        }catch (Exception e){
            return false;
        }
    }
}

然后编写单元测试:

java
public class PalindromeTest {

    @Test
    void test_empty(){
        assertThat(Palindrome.isPalindrome("")).isEqualTo(true);
    }
}

当运行测试后mvn test,查看jacoco报告:

image-20260403160400938

分析报告:

  • 在第3行被红色背景色填充,说明该类的构造函数从没有被执行过;
  • 第6行,左侧有黄色菱形,表示执行了部分分支,鼠标放在菱形符号上,提示“1 of 2 branches missed.”,表示有一个分支未被覆盖(即else分支);第6行也被黄色背景色填充,说明该行的字节码被部分执行;
  • 第7行被绿色背景色填充,说明该行所有的字节码都被执行了,即该行被完全执行了;
  • 第9-12行被红色背景色填充,说明这些行完全没有被执行;
  • 第12行左侧有红色菱形,说明该行分支完全没有被执行过;
  • 第14-15行没有被执行过;

接下来,补充以下测试案例:

java
@Test
void test_is_palindrome(){
    assertThat(Palindrome.isPalindrome("noon")).isEqualTo(true);
}

image-20260403162227833

再补充以下测试案例:

java
@Test
void test_is_not_palindrome(){
    assertThat(Palindrome.isPalindrome("none")).isEqualTo(false);
}

@Test
void test_is_not_palindrome_2(){
    assertThat(Palindrome.isPalindrome("noen")).isEqualTo(false);
}

@Test
void test_is_null(){
    assertThat(Palindrome.isPalindrome(null)).isEqualTo(false);
}

image-20260403162554635

至此,整个方法isPalindrome()才算测试完成。

image-20260403162930947

2.4 check目标

jacoco-maven-plugin插件也提供check目标,用于检查代码覆盖率是否达到了要求。

示例配置如下:

xml
<execution>
    <id>check-coverage</id>
    <goals>
        <goal>check</goal>
    </goals>
    <phase>verify</phase>
    <configuration>
        <rules>
            <rule>
                <element>BUNDLE</element>
                <limits>
                    <limit>
                        <counter>LINE</counter>
                        <value>COVEREDRATIO</value>
                        <minimum>0.80</minimum>
                    </limit>
                    <limit>
                        <counter>BRANCH</counter>
                        <value>COVEREDRATIO</value>
                        <minimum>0.70</minimum>
                    </limit>
                </limits>
            </rule>
        </rules>
    </configuration>
</execution>
  • <goal>:表示执行check目标;
  • <phase>:默认就是绑定到verify阶段;
  • <configuration>:用来配置check目标,核心是<rule>

<rule>中,主要是<element><limit>

  • <element>:定义了检查维度,可选值如下:

    • BUNDLE:默认值,将项目内所有的类、方法合并在一起计算一个总的百分比。只要项目总覆盖率达标即可。
    • PACKAGE:每一个包的覆盖率都必须达到设定的阈值。
    • CLASS:每一个类都必须满足覆盖率要求。如果有一个类没达标,构建就会失败。
    • SOURCEFILE:源文件级,即每一个 .java 源文件,一个源文件可能包含多个类(例如内部类或非 public 类)。按物理文件统计,如果一个文件的综合覆盖率达标,则通过。
    • METHDO:方法级,这是最严格的校验,它要求每一个方法(包括构造函数、Setter/Getter 等)都必须达到覆盖率标准。
  • <limit>:定义了具体的检查规则,其中关键的两个值为<counter><value>

    • <counter>:检查维度,可选值如下:

      • INSTRUCTION:默认值,指令级;

      • LINE:行级,只要这一行中至少有一条指令被执行,该行就计为“已覆盖”;

        JaCoCo 统计的 LINE 计数器并不是物理文本行数(Text Lines),而是包含可执行指令的代码行数(Executable Lines)

      • BRANCH:分支级,统计所有的 ifswitch 语句中,有多少个路径被走到了;

      • COMPLEXITY:圈复杂度;

      • METHOD:方法级,只要方法中至少有一条指令被执行,该方法就计为“已覆盖”;

      • CLASS:类级,只要类中至少有一个方法被执行,该类就计为“已覆盖”;

    • <value>:检查什么,即针对<counter>检查什么,可选值如下:

      • COVEREDRATIO:覆盖比例,默认值,即覆盖数占总数的百分比(取值范围 0.01.0),通常与<minimum>0.80</minimum>配合,表示覆盖比例不得小于80%;
      • MISSEDRATIO:未覆盖比例,未覆盖数占总数的百分比(取值范围 0.01.0),与覆盖比例相反,通常与<maximum>0.20</maximum>配合使用,表示未覆盖比例不得大雨20%;
      • COVEREDCOUNT:覆盖数,实际被测试执行到的绝对数量,假设<counter>为LINE,那100行执行了80行,那COVEREDCOUNT就是80;
      • MISSEDCOUNT:未覆盖数,与覆盖数相反;
      • TOTALCOUNT:总数,该<counter>在选定范围内的总量;

接下来说说不同配置的含义:

xml
<rule>
    <element>BUNDLE</element>
    <limits>
        <limit>
            <counter>LINE</counter>
            <value>COVEREDRATIO</value>
            <minimum>0.80</minimum>
        </limit>
        <limit>
            <counter>BRANCH</counter>
            <value>COVEREDRATIO</value>
            <minimum>0.70</minimum>
        </limit>
    </limits>
</rule>

整个项目的行覆盖率要达到80%,分支覆盖率要达到70%,才能通过测试。

xml
<rule>
  <element>BUNDLE</element>
  <limits>
    <limit>
      <counter>INSTRUCTION</counter>
      <value>COVEREDRATIO</value>
      <minimum>0.80</minimum>
    </limit>
    <limit>
      <counter>CLASS</counter>
      <value>MISSEDCOUNT</value>
      <maximum>0</maximum>
    </limit>
  </limits>
</rule>

整个项目的字节码覆盖率要达到80%,并且没有类漏掉,才能通过测试。

xml
<rule>
    <element>SOURCEFILE</element>
    <limits>
        <limit>
            <counter>LINE</counter>
            <value>TOTALCOUNT</value>
            <maximum>2000</maximum>
        </limit>
    </limits>
</rule>

限制每个.java源文件的行数不能超过2000行才能通过测试,避免在一个文件中写过多代码。

如果覆盖率没有达标,在执行mvn verify时,会报错:Coverage checks have not been met. See log for details.

针对具体某条规则,我们还可以设置<includes><excludes>

  • <includes>:包含的类列表,可以使用通配符*?,如果没有指定,则所有的类都会纳入统计;
  • <excludes>:排除的类列表,可以使用通配符*?,如果没有指定,则不排除类;

例如:

xml
<rules>
  <rule>
    <element>CLASS</element>
    <excludes>
      <exclude>*Test</exclude>
    </excludes>
    <limits>
      <limit>
        <counter>LINE</counter>
        <value>COVEREDRATIO</value>
        <minimum>50%</minimum>
      </limit>
    </limits>
  </rule>
</rules>

表示项目中每个类(排除以Test结尾的类)的行覆盖率至少要达到50%。

2.5 report-aggregate目标

jacoco-maven-pluginreport-aggregate 目标是一个专门用于 多模块项目(Multi-module Project) 的功能。它的核心作用是将分布在不同子模块中的单元测试或集成测试覆盖率数据汇总,生成一份覆盖全项目的 聚合报告

例如,假设现在项目app中有以下模块:

  • app-util:提供工具类,无单元测试;
  • app-test:依赖app-util,专门用于测试的模块;

如果不使用report-aggregate,那么在app-test生成的jacoco覆盖率报告中,是看不到app-util模块中的覆盖率的。

因此,我们可以在app-test的jacoco插件设置中,添加report-aggregate目标:

Details
xml
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.example</groupId>
        <artifactId>app</artifactId>
        <version>1.0-SNAPSHOT</version>
    </parent>

    <artifactId>app-test</artifactId>
    <packaging>jar</packaging>

    <name>app-test</name>
    <url>http://maven.apache.org</url>

    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.example</groupId>
            <artifactId>app-util</artifactId>
            <version>1.0-SNAPSHOT</version>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-surefire-plugin</artifactId>
                <version>3.5.5</version>
            </plugin>

            <plugin>
                <groupId>org.jacoco</groupId>
                <artifactId>jacoco-maven-plugin</artifactId>
                <version>0.8.14</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>
                    <execution>
                        <id>my-report-aggregate</id>
                        <phase>verify</phase>
                        <goals>
                            <goal>report-aggregate</goal>
                        </goals>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </build>

</project>

父项目中运行mvn clean verify,之后,就可以在app-test模块的target/site/jacoco-aggregate/index.html中查看app-util的覆盖率:

image-20260403200654095

注意,如果单独在app-test模块中运行mvn clean verify,是不会有app-util的代码覆盖率报告的。

因为JaCoCo 报告需要三样东西:

  1. exec 覆盖率数据;
  2. 被测模块的 class 文件;
  3. 被测模块的 source 文件;

单独在 app-test 目录执行:

cd app-test
mvn verify

此时 Maven 只构建:

app-test

不会构建:

app-util

此时:

  • app-util 已经 compile
  • app-util 有 target/classes
  • app-test 测试时加载了 app-util 的 class
  • JaCoCo report 可以找到 util 的 class 和 source
  • 所以覆盖率能统计

如果想 只构建 app-test,但同时统计 app-util 覆盖率,应该这样运行:

mvn -pl app-test -am verify

参数解释:

参数含义
-pl app-test只构建 app-test
-am同时构建 app-test 依赖的模块(app-util)

构建顺序会变成:

app-util → app-test

这样 JaCoCo 也能统计 util 覆盖率了。

参考资料

[1] jacoco: https://www.jacoco.org/jacoco/trunk/doc/index.html

[2] counters: https://www.jacoco.org/jacoco/trunk/doc/counters.html

[3] jacoco-maven-plugin:https://www.jacoco.org/jacoco/trunk/doc/maven.html

[4] check goal:https://www.jacoco.org/jacoco/trunk/doc/check-mojo.html

[5] report-aggregate goal: https://www.jacoco.org/jacoco/trunk/doc/report-aggregate-mojo.html