本节目标:把「测试通过」升级为「测试通过且覆盖率达到门槛」,把编译告警、静态分析和覆盖率串成一条命令,并让 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 整理,列是「已覆盖/总数」:
| 类 | 行覆盖 | 分支覆盖 | 指令覆盖 |
|---|---|---|---|
Loan | 28/28 | 23/24 | 198/198 |
Book | 10/10 | 13/16 | 57/57 |
LibraryException | 7/7 | 0/0 | 24/24 |
DatabaseConfig | 18/18 | 10/14 | 77/82 |
HttpConfig | 7/7 | 5/8 | 31/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 与结果集 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。