《Java 编程实战》2.3 质量门禁:JaCoCo 覆盖率与 CI 流水线

把测试从「跑过」变成「可验收」:用 JaCoCo 的 LINE 与 BRANCH 计数读懂报告,按模块设置 60% 行覆盖率门槛,在 quality profile 里串起编译、测试、Checkstyle、SpotBugs 和覆盖率,最后落到一条 CI 命令。

本节目标:把「测试通过」升级为「测试通过且覆盖率达到门槛」,把编译告警、静态分析和覆盖率串成一条命令,并让 CI 只认这一条命令。
适用版本:JaCoCo 0.8.16、JUnit Jupiter 5.14.4、Maven 3.9.12、JDK 25.0.5。

承接的问题

2.1 和 2.2 解决了「代码有没有明显问题」:编译器警告变成错误,Checkstyle 和 SpotBugs 在 verify 阶段阻断构建。但一个项目可以零警告、零静态分析告警,测试也全绿,却几乎没有测到关键分支。libraryd 的 Loan.renew 有「未逾期」「已逾期」「已归还」「次数用尽」四条路径,只测一条也能让测试通过。

覆盖率门禁的作用不是证明代码正确,而是回答一个可量化的问题:这次改动之后,哪些代码从来没有被执行过?如果新增的 100 行里有 80 行没有测试,评审至少应该知道这件事。

要把覆盖率变成门禁,需要三步:选对计数口径、定一个不会逼人造假的阈值、把命令固定到 CI。三步里最容易做错的是第二步。

JaCoCo 的计数口径:LINE、BRANCH、INSTRUCTION

JaCoCo 在测试执行时通过 Java Agent 记录哪些指令被执行,运行结束后生成 target/jacoco.exec,再据此生成 HTML、XML 和 CSV 报告。报告里最常见的五个计数器:

  • INSTRUCTION:字节码指令覆盖,最细,但对人不够直观。
  • BRANCH:if、switch、短路运算等分支的覆盖,最能反映条件逻辑是否测全。
  • LINE:源码行覆盖,一行里只要有一条指令执行就算覆盖,最容易读。
  • COMPLEXITY:圈复杂度覆盖,和分支相关,报告里用得少。
  • METHOD:方法是否至少被调用一次。

libraryd 的 domain 与基础设施模块报告如下。数据从 target/site/jacoco/jacoco.csv 整理,列是「已覆盖/总数」:

类行覆盖分支覆盖指令覆盖
Loan28/2823/24198/198
Book10/1013/1657/57
LibraryException7/70/024/24
DatabaseConfig18/1810/1477/82
HttpConfig7/75/831/37

本机 mvn -Pquality verify 后读取 CSV 的结果。几个现象值得注意:

  • Loan 的行覆盖是 100%,分支覆盖是 23/24。有一条分支没有测到,可能是某个 || 的短路路径,行覆盖看不出来。
  • Book 的行覆盖也是 100%,分支覆盖只有 13/16。三个未覆盖分支来自紧凑构造器里的空值判断组合。
  • DatabaseConfig 和 HttpConfig 的指令覆盖低于行覆盖,因为一行里可能有多条指令,部分分支没有走到。

这就是为什么不能只看一个数字。行覆盖适合做「有没有完全没测的类」的粗筛,分支覆盖适合要求条件逻辑。libraryd 的门禁用 LINE,因为阈值要长期维护,行覆盖的解释成本最低;同时在 code review 里要求新增条件分支必须补测试,用分支覆盖做人工检查。

Loan 的分支覆盖不是 100%,但它的测试已经覆盖了借出、归还、续借、逾期、重复归还这些主路径。剩下的一条分支通常是无害的短路组合,不值得为了数字写一个只为覆盖服务的测试。覆盖率是下限,不是目标。

覆盖率门禁:阈值怎么定

JaCoCo 的 check goal 按规则检查计数器。libraryd 的规则是 BUNDLE 级别的行覆盖率不低于 0.60:

<plugin>
  <groupId>org.jacoco</groupId>
  <artifactId>jacoco-maven-plugin</artifactId>
  <version>${jacoco.version}</version>
  <executions>
    <execution>
      <id>prepare-agent</id>
      <goals>
        <goal>prepare-agent</goal>
      </goals>
    </execution>
    <execution>
      <id>report</id>
      <phase>verify</phase>
      <goals>
        <goal>report</goal>
      </goals>
    </execution>
    <execution>
      <id>check</id>
      <phase>verify</phase>
      <goals>
        <goal>check</goal>
      </goals>
      <configuration>
        <rules>
          <rule>
            <element>BUNDLE</element>
            <limits>
              <limit>
                <counter>LINE</counter>
                <value>COVEREDRATIO</value>
                <minimum>0.60</minimum>
              </limit>
            </limits>
          </rule>
        </rules>
      </configuration>
    </execution>
  </executions>
</plugin>

prepare-agent 把 JaCoCo agent 挂到 Surefire 的 JVM 参数上;report 生成 HTML/CSV;check 读取执行数据并按规则判断。三个执行都绑定在 verify 之前或之中,所以 mvn test 不会触发覆盖率检查,mvn verify 才会。

阈值定 0.60 而不是 0.90,是刻意的。覆盖率越接近 100%,越容易诱导人写「调用一下但不做断言」的测试来凑数。60% 是一个能拦住「新增模块完全没测试」的底线,同时给业务代码留出合理的排除空间。等模块稳定后,可以按模块提高:domain 提到 0.80,web 保持 0.60,app 因为只有装配代码而跳过。

阈值不是拍脑袋定的,要看失败时的反馈是否有用。把规则临时改成 BRANCH 0.99,对 domain 运行:

[WARNING] Rule violated for bundle libraryd-domain: branches covered ratio is 0.90,
but expected minimum is 0.99
[ERROR] Failed to execute goal org.jacoco:jacoco-maven-plugin:0.8.16:check (check)
on project libraryd-domain: Coverage checks have not been met.

本机输出。这个失败信息很清楚:当前 0.90,要求 0.99,差 9 个百分点。如果阈值定得合理,开发者看到信息后知道该补哪些分支;如果定得不合理,第一反应会是加排除或删规则。

BUNDLE 级别会把模块里所有类合并计算。这种方式适合「模块整体不能倒退」,但会掩盖单个类的低覆盖:一个 500 行的类没测试,可能被 5000 行高覆盖的代码稀释。更细的做法是按 CLASS 或 PACKAGE 设规则,libraryd 目前代码量小,先用 BUNDLE,等模块变大再拆。

CI 流水线:一条命令、两个阶段

门禁只有在 CI 上执行才算门禁。libraryd 的 CI 只认一条命令:

mvn -B -ntp -Pquality verify

-B 是 batch mode,关闭交互和 ANSI 颜色,日志更适合归档;-ntp 关闭依赖下载进度条,减少日志噪音;-Pquality 启用 Checkstyle、SpotBugs、JaCoCo;verify 而不是 test,因为质量插件绑定在 verify 阶段,test 阶段根本不会执行它们。

GitHub Actions 的最小配置如下。这是示意配置,本机没有在 GitHub Actions 上实际运行;其中的 Maven 命令与本机验证过的命令一致。

name: quality

on:
  pull_request:
  push:
    branches: [main]

jobs:
  verify:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-java@v4
        with:
          distribution: temurin
          java-version: '25'
          cache: maven

      - name: Verify with quality gates
        run: mvn -B -ntp -Pquality verify

      - name: Upload JaCoCo report
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: jacoco-report
          path: '**/target/site/jacoco/'

几个细节:

  • setup-java 的 cache: maven 缓存 ~/.m2/repository,但不缓存 target/。每次构建仍然从干净状态开始,符合 1.3 的可复现性要求。
  • if: always() 让测试失败时也上传覆盖率报告,方便判断失败是「测试挂了」还是「测试没跑到」。
  • path: '**/target/site/jacoco/' 会收集所有模块的报告;如果只想看汇总,可以在后续步骤里合并,但多模块分别上传更利于定位。
  • 分支保护里把 quality / verify 设为必需检查,合并前必须通过。

本地复现 CI 的命令:

$ mvn -q -Pquality verify
$ echo $?
0

本机输出,-q 下没有日志,退出码 0。CI 用非 -q 形式是为了保留 reactor 摘要和失败上下文;本地快速检查可以用 -q。

在 libraryd 里落地

quality profile 把三个插件放在一起,父 POM 的 <profiles> 里只声明要启用哪些插件,版本和配置在 pluginManagement 里:

<profile>
  <id>quality</id>
  <build>
    <plugins>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-checkstyle-plugin</artifactId>
      </plugin>
      <plugin>
        <groupId>com.github.spotbugs</groupId>
        <artifactId>spotbugs-maven-plugin</artifactId>
      </plugin>
      <plugin>
        <groupId>org.jacoco</groupId>
        <artifactId>jacoco-maven-plugin</artifactId>
      </plugin>
    </plugins>
  </build>
</profile>

两个模块显式跳过 JaCoCo:

<!-- libraryd-app:组合根只有装配代码,不计入覆盖率 -->
<properties>
  <jacoco.skip>true</jacoco.skip>
</properties>
<!-- libraryd-integration-tests:第 2 章还没有生产代码 -->
<properties>
  <checkstyle.skip>true</checkstyle.skip>
  <spotbugs.skip>true</spotbugs.skip>
  <jacoco.skip>true</jacoco.skip>
</properties>

app 的 jacoco.skip 只跳过覆盖率,Checkstyle 和 SpotBugs 仍然运行;integration-tests 三个都跳过。这些跳过是白名单式的例外:新增模块默认要接受检查,跳过必须写理由。反过来做——默认全跳过、想起来再开——门禁就会名存实亡。

完整验证:

[INFO] libraryd-parent .................................... SUCCESS [  3.781 s]
[INFO] libraryd-domain .................................... SUCCESS [  8.052 s]
[INFO] libraryd-persistence ............................... SUCCESS [  4.737 s]
[INFO] libraryd-web ....................................... SUCCESS [  6.388 s]
[INFO] libraryd-app ....................................... SUCCESS [  6.675 s]
[INFO] libraryd-integration-tests ......................... SUCCESS [  6.421 s]
[INFO] BUILD SUCCESS

本机 mvn -Pquality verify 输出。domain 的 8 秒里包含 26 个测试、Checkstyle 扫描、SpotBugs Max effort 分析和 JaCoCo 报告生成;app 跳过了 JaCoCo,所以比 web 略快。

报告位置:

libraryd-domain/target/site/jacoco/index.html   # 人读
libraryd-domain/target/site/jacoco/jacoco.csv   # 表格/脚本
libraryd-domain/target/site/jacoco/jacoco.xml   # CI 平台解析

HTML 适合本地看哪一行没覆盖;CSV 适合写脚本比较两次构建;XML 适合 SonarQube 之类的平台。三者数据同源,不要只上传 HTML。

常见错误与边界

覆盖率 100% 但断言为空。 一个测试只要调用方法就会产生覆盖,assertTrue(true) 也能让数字好看。覆盖率不能替代断言质量;libraryd 的规则是每个测试至少有一条针对返回値或异常的行为断言,评审时看测试名和断言,不看数字。

把 argLine 写死。 JaCoCo 的 prepare-agent 通过 argLine 注入 agent。如果 Surefire 配置里又写了一个固定的 <argLine>-Xmx512m</argLine>,agent 会被覆盖,jacoco.exec 为空,覆盖率报告显示 0%。需要追加 JVM 参数时用 @{argLine} 延迟替换,或者把参数放到 systemPropertyVariables 之外的变量里。

阈值定得太高,然后用排除把数字拉回来。 先定 0.90,发现过不去就给每个类加排除,最后排除文件比规则还长。正确做法是从 0.60 起步,稳定后按模块逐步提高,每次提高都补测试,而不是补排除。

把生成代码算进覆盖率。 DTO、MapStruct 生成器、protobuf 生成的类会拉高或拉低分母。JaCoCo 支持 <excludes>,把 **/generated/**、**/*Dto.class 这类路径排除掉。libraryd 在第 2 章还没有生成代码,第 6 章接入 DTO 后要重新检查。

只在本地跑 mvn test。 质量插件绑定在 verify,test 不会触发。开发者本地绿、CI 红,通常就是这个原因。把 CI 的确切命令写进 README,并且让本地预提交钩子跑同一条命令,能减少这类差异。

忽略 prepare-agent 的执行顺序。 JaCoCo 的 prepare-agent 必须在 Surefire 之前执行,默认绑定 initialize,不要手工改到 test 之后。改了之后 jacoco.exec 不会生成,报告会提示没有执行数据。

小结

  • JaCoCo 的 LINE 适合做底线门禁,BRANCH 更能反映条件逻辑;libraryd 用 0.60 的行覆盖率起步。
  • 行覆盖 100% 不代表分支覆盖 100%,Loan 的 23/24 和 Book 的 13/16 就是例子。
  • 阈值是下限,不是目标;过高会诱导写无断言的测试或用排除凑数。
  • prepare-agent、report、check 三个执行都围绕 verify,mvn test 不会触发覆盖率门禁。
  • CI 只认 mvn -B -ntp -Pquality verify,本地用同一条命令复现。
  • app 和 integration-tests 的跳过是写理由的例外,不是默认行为。

第 1、2 章把工程骨架和质量门禁搭好了:五个模块、依赖收敛、可复现构建、编译告警、静态分析和覆盖率。下一章开始接真实基础设施,从 JDBC 的 Connection、PreparedStatement 与结果集讲起。

阅读导航:上一节:2.2 静态分析:Checkstyle 与 SpotBugs 的规则取舍 · 下一节:3.1 JDBC 基础:Connection、PreparedStatement 与结果集 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发