主题
界面
主题
从 《JUnit 5:测试结构与断言》 到 《Mockito:Mock、Stub 与 Verify》 解决了"测试怎么写"。本节回答一个更难的问题:我怎么知道还有哪些代码没被测试碰过?
覆盖率工具就是答案。它不会告诉你"测试写得好不好"(那是 T1-012 的事),但它能精确指出"这块代码一行都没执行过"——这是人工审查很难做到的事。
完成本节后,你应该能够:
jacoco.exec 的追加模式会怎样误导你。看一个真实的问题:你能说出你项目里有哪些方法从来没被测试调用过吗?
靠人工审查很难——测试文件和被测代码的对应关系不是一眼可见的。而覆盖率工具做得又快又准。
但覆盖率也是最容易被误用的指标:一旦它变成"必须达到 90%"的考核项,团队就会开始写没有断言的测试来凑数(T1-013 会展开)。
本节的定位:先学会用它发现缺口,再学会不迷信它。
JaCoCo 从五个维度统计覆盖率:
| 计数器 | 统计什么 | 特点 |
|---|---|---|
| 指令(INSTRUCTION) | 字节码指令 | 最细粒度,与源码行不完全对应 |
| 分支(BRANCH) | if / switch 的每个分支 | 最能反映逻辑覆盖是否充分 ★ |
| 行(LINE) | 源码行 | 最直观,但容易高估(一个 if 行只算一行) |
| 方法(METHOD) | 方法是否被调用过 | 粗粒度,适合看"整个方法是否被测" |
| 类(CLASS) | 类是否被加载执行 | 最粗 |
它们的关系:行覆盖 ≥ 分支覆盖(一行里可能有多个分支)。
为什么分支覆盖最重要:因为它对应"逻辑决策"。一个 if (a && b) 有 4 个分支组合(a真b真、a真b假、a假...短路),只测"a 真 b 真"会让行覆盖满,但分支覆盖不满——而 bug 恰恰常在那些没测到的分支里。
本项目的实测对比(下面 5.4 会展开):
行覆盖 73/73 = 100.0%
分支覆盖 33/36 = 91.7% ← 行满了,分支还有 3 个没走到在父 POM 里配置一次,所有模块生效:
<build>
<plugins>
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.15</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>两个 execution 的分工:
| execution | 做什么 | 原理 |
|---|---|---|
prepare-agent | 给测试 JVM 挂一个 Java agent | 运行时探针:在字节码里插入计数逻辑 |
report | 读取 .exec 数据,生成报告 | 汇总成 HTML / CSV / XML |
注意 prepare-agent 做的是"字节码插桩"——它修改了内存中的类,但不改磁盘上的 class 文件。所以测试跑完,target/classes 里的字节码是干净的。
labs/testing-unit/target/site/jacoco/
├── index.html # 总览(按包分组,可点进去)
├── jacoco.csv # 明细数据(每类一行,适合脚本处理)
├── jacoco.xml # 结构化数据(给 CI 工具解析)
└── cn.kb.learn.testing/
└── *.html # 每个类的行级详情(红色=未覆盖)| 格式 | 用途 |
|---|---|
| HTML | 人工阅读:从总览点进类,能看到每一行的覆盖情况 |
| CSV | 脚本处理:一行一个类,方便做门禁(T1-018) |
| XML | CI 集成:SonarQube、Jenkins 等工具解析它 |
读 HTML 报告的技巧:
| 颜色 | 含义 |
|---|---|
| 绿色 | 该行被完整覆盖 |
| 黄色 | 行被执行了,但分支没走全(重点看这里) |
| 红色 | 该行从未被执行 |
黄色比红色更值得关注——红色通常是"这个功能还没测",一眼可见;黄色是"看起来测了,其实有分支没走到",隐蔽得多。
第一次采集(86 个测试):
类数 8
指令覆盖 282/323 = 87.3%
分支覆盖 31/36 = 86.1%
行覆盖 66/73 = 90.4%
User 行 2/2 分支 0/0
UserService 行 13/18 分支 5/6 ← 有未覆盖
Payment 行 1/1 分支 0/0
OrderService 行 14/14 分支 2/2
Article 行 1/1 分支 0/0
OrderCalculator 行 17/19 分支 12/14 ← 有未覆盖
PasswordValidator 行 6/6 分支 4/4
ArticleService 行 12/12 分支 8/10 ← 有未覆盖报告直接告诉我们三件事:
UserService 行覆盖 13/18:有 5 行从未执行——因为前面的测试只测了 rename,delete 方法一次都没被调用过;OrderCalculator 分支 12/14:三参数版本的两处参数校验没走到;ArticleService 分支 8/10:有分支缺口。照着报告补测试后(88 个测试):
指令覆盖 311/323 = 96.3%
分支覆盖 33/36 = 91.7%
行覆盖 73/73 = 100.0% ← 行满了注意这个对比:行覆盖已经 100%,但分支覆盖只有 91.7%。
这就是"用覆盖率指导补测试"的完整闭环:
跑测试 → 看报告 → 找到未覆盖的类/分支 → 判断"这个缺口重要吗"
→ 补测试 → 再跑一次 → 数字上升补测试的代码(CoverageGapTest):
@Test
@DisplayName("UserService.delete:此前完全未被覆盖的方法")
void covers_delete_method() {
UserRepository repository = mock(UserRepository.class);
Notifier notifier = mock(Notifier.class);
when(repository.findById("u1"))
.thenReturn(Optional.of(new User("u1", "张三", "zhangsan@test.com")));
UserService service = new UserService(repository, notifier);
service.delete("u1");
verify(repository).delete(new User("u1", "张三", "zhangsan@test.com"));
verify(notifier).send("u1", "账号已删除");
}效果:UserService 的行覆盖从 13/18 升到 18/18。
关键点:这不是"为了覆盖率写测试",而是"覆盖率指出了真实的功能缺口"——delete 方法确实从来没有被验证过,这是一个应该被发现的漏洞。
| 覆盖率说不了的事 | 例子 |
|---|---|
| 断言是否有效 | 一个测试调用方法但不断言,仍然贡献 100% 覆盖 |
| 逻辑是否正确 | 测试和实现有同样的错误时,覆盖率高且全绿 |
| 是否覆盖了该测的场景 | 分支走了,但输入数据不是边界值(如只测了 n=5 没测 n=0) |
尤其是第一条,它直接引出了覆盖率最大的陷阱(见 T1-013):
@Test
void covers_everything_without_asserting() {
service.rename("u1", "新名"); // 只调用,不断言
// 覆盖率 +100%,保护力 +0
}结论:覆盖率是"发现缺口"的工具,不是"证明质量"的指标。
catch (Exception) 的兜底分支、main 方法)不值得为覆盖率补测试。cd labs
./mvnw -pl testing-unit test
# 报告生成在 testing-unit/target/site/jacoco/index.html见 5.4 节。三步法:
// 反例:调用了一遍,什么也没验证
@Test
void increase_coverage() {
service.delete("u1"); // 没有断言,只要不抛异常就算"覆盖"了
}问题:它让覆盖率上升,但没有验证任何行为。如果 delete 里把 repository.delete(user) 写成了 repository.save(user),这个测试仍然绿。
正确写法见 5.4 节的代码——调用之后必须验证"做了什么"。
配置(labs/pom.xml,父 POM 对所有模块生效):
<build>
<plugins>
<!-- 覆盖率采集与报告:prepare-agent 在测试前挂载探针,report 在 test 阶段生成报告 -->
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<!-- 0.8.12 不认识 JDK 25 的内部类字节码,会刷 IllegalClassFormatException 警告 -->
<version>0.8.15</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>运行命令:
cd labs
./mvnw -pl testing-unit test预期输出(节选):
[INFO] Tests run: 88, Failures: 0, Errors: 0, Skipped: 0
[INFO] --- jacoco:0.8.15:report (report) @ testing-unit ---
[INFO] Loading execution data file C:\...\labs\testing-unit\target\jacoco.exec
[INFO] Analyzed bundle 'labs-testing-unit' with 8 classes
[INFO] BUILD SUCCESS读取报告数字(本项目附带的小脚本能直接算出来):
cd labs
python -X utf8 -c "import csv; rows=list(csv.DictReader(open('testing-unit/target/site/jacoco/jacoco.csv',encoding='utf-8'))); ti=sum(int(r['INSTRUCTION_COVERED'])+int(r['INSTRUCTION_MISSED']) for r in rows); ci=sum(int(r['INSTRUCTION_COVERED']) for r in rows); tb=sum(int(r['BRANCH_COVERED'])+int(r['BRANCH_MISSED']) for r in rows); cb=sum(int(r['BRANCH_COVERED']) for r in rows); tl=sum(int(r['LINE_COVERED'])+int(r['LINE_MISSED']) for r in rows); cl=sum(int(r['LINE_COVERED']) for r in rows); print(f'指令 {ci}/{ti}={ci/ti*100:.1f}% 分支 {cb}/{tb}={cb/tb*100:.1f}% 行 {cl}/{tl}={cl/tl*100:.1f}%')"指令 311/323=96.3% 分支 33/36=91.7% 行 73/73=100.0%三个数字要连起来读:
| 数字 | 说明 |
|---|---|
| 行 100.0% | 每一行代码都执行过 |
| 分支 91.7% | 但有 3 个分支没走到(行满了不等于逻辑测全了) |
| 指令 96.3% | 有 12 条指令未执行(通常是未走到分支里的那几行) |
报告里未覆盖的具体位置:打开 target/site/jacoco/index.html,进入 UserService 与 ArticleService,能看到黄色的行——它们对应 userId == null || userId.isBlank() 这类短路判断中"左边为真"的那一支。
target/site/jacoco/index.html,点击 ArticleService,找到黄色的行——观察它标出的分支数(如 2 of 4 branches missed),这比"整行未覆盖"精确得多;prepare-agent 执行的那一行(Maven 日志里)观察它为测试 JVM 加的 -javaagent 参数——这就是"运行时插桩"的实现方式;target/classes/cn/kb/learn/testing/UserService.class 的文件修改时间——它没有因为插桩而改变,证明插桩只发生在内存中;jacoco.csv 打开一个类,对比 LINE_COVERED 与 BRANCH_COVERED 的比例——感受两者的差距。jacoco.exec 的追加模式让覆盖率虚高(本项目实测踩到) 现象:删掉了一个测试,重跑后覆盖率没有下降。
原因:JaCoCo 的 prepare-agent 默认 append=true——每次运行把新数据追加到已有的 jacoco.exec。
实测过程:
# 当时存在一个临时测试(补 null 分支),分支覆盖 36/36 = 100%
# 删除那个临时测试、重跑测试后:
分支 36/36 = 100% ← 数据仍然显示 100%,实际应回到 91.7%
# 手动删除 jacoco.exec 后重跑:
分支 33/36 = 91.7% ← 真实值为什么危险:本地开发时你会看到一个"比真实情况更好"的数字。CI 上通常没问题(每次都是干净检出),所以会出现"本地 100%、CI 91.7%"的困惑。
修复:删掉 target/jacoco.exec 后重跑;或在配置里显式改:
<configuration>
<append>false</append> <!-- 每次重新采集 -->
</configuration>建议:本地怀疑数字时先删 target 目录重跑;CI 上保持默认(干净环境无影响)。
java.lang.instrument.IllegalClassFormatException: Error while instrumenting
sun/util/resources/cldr/provider/CLDRLocaleDataMetaInfo with JaCoCo 0.8.12
Caused by: java.io.IOException: Error while instrumenting ...现象:构建仍然成功,但输出被大量 stack trace 淹没。
原因:JaCoCo 会尝试 instrument JDK 内部类,而 0.8.12 不认识 JDK 25 的字节码版本。
修复:升级 JaCoCo。
<version>0.8.15</version> <!-- 0.8.12 → 0.8.15 后警告消失 -->这条与 T1-010 的 byte-buddy 问题是同一类问题:新 JDK 常常需要同步升级字节码工具链(JaCoCo、Byte Buddy、Lombok、ASM 都属于这一类)。
行覆盖率 100% → 以为测全了实际可能:if (a && b) 的四个组合只测了一个,但这一行算"已覆盖"。
修复:同时看分支覆盖率,并以分支为核心判断逻辑覆盖是否充分(T1-013)。
一旦覆盖率与绩效挂钩,团队的最优策略就变成"用最少的工作量把数字推上去"——写没有断言的测试、给 getter/setter 补测试、把不可达代码加 @Generated 排除。
修复:覆盖率作为发现问题的手段,不作为目标。门禁只对"新代码"设要求(T1-018)。
mvnw test 之后找不到 target/site/jacoco/检查顺序:① 插件是否加在了当前模块或父 POM 的 <build><plugins> 里(不是 <pluginManagement>——那里只声明不执行);② report execution 的 phase 是否为 test;③ 测试是否真的跑了(Tests run: 0 时报告为空)。
目标:完整走一遍"覆盖率指导补测试"的闭环。
步骤:
./mvnw -pl testing-unit test,用第 7 节的命令读出三个数字,记录它们;target/jacoco.exec 再跑一次,确认数字没有变化(排除追加模式的干扰);CoverageGapTest 的写法),要求有断言;验收方式:能说出第 3 步找到的缺口在哪,以及第 6 步为什么危险。
扩展任务:给 labs 的另一个模块(如 collections)也配置覆盖率采集,看看那些"只写了示例、没写测试"的模块覆盖率是多少——这能帮你判断哪些模块值得补测试。
基础题
代码阅读题
类名 行覆盖 分支覆盖
OrderService 14/14 2/2
UserService 18/18 5/6
ArticleService 12/12 8/10UserService 的分支缺口最可能在哪里?(提示:读一下它的参数校验)编程题
Discount:long apply(long amount, String level);level 为 NONE / SILVER / GOLD / 其他(抛异常);综合任务
labs 里的模块,或你自己的项目);提示 1(方向):第 4 题的关键是"行覆盖相同但分支覆盖不同"——分支缺口才代表逻辑没测全。
提示 2(关键点):第 5 题的 UserService.rename 里有 if (newName == null || newName.isBlank()),只测了 isBlank() 为真的情况。
提示 3(接近答案):第 6 题的"提升最大"通常是第一次补测试——因为从 0 到 1 的边际收益最高(后续补的往往是原本已被间接覆盖的分支)。
if (a && b) 这类判断在一行里包含多个分支。本项目实测出现过"行 100% 但分支 91.7%"的情况。prepare-agent;report。第 4 题:ArticleService(分支 8/10,缺 2 个)最需要补。
理由:
| 类 | 行覆盖 | 分支覆盖 | 判断 |
|---|---|---|---|
OrderService | 100% | 2/2 满 | 逻辑已被完整覆盖 ✓ |
UserService | 100% | 5/6(缺 1) | 有一个分支组合没走到 |
ArticleService | 100% | 8/10(缺 2) | 缺口最大 |
关键原则:行覆盖相同时,看分支覆盖——分支没走全意味着某条逻辑路径从未被验证过。
第 5 题:最可能在参数校验的短路判断上。
if (newName == null || newName.isBlank()) { // 两个分支
throw new IllegalArgumentException("新名字不能为空");
}现有测试传的是 " "(空白字符串),所以走的是"左边为假 → 判右边 → 为真"的路径。"左边为真"(即 newName == null)这一支从未被走到——这就是那 1 个未覆盖的分支。
补测试:
@Test
@DisplayName("传 null 时也抛异常(此前未覆盖的分支)")
void rejects_null_name() {
assertThatThrownBy(() -> service.rename("u1", null))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("新名字不能为空");
}顺带一个设计讨论:这个测试也验证了一个真实的行为契约——"传 null 不会抛 NPE,而是抛带明确消息的 IllegalArgumentException"。这正是分支覆盖比行覆盖更有价值的原因:它逼你思考"每种输入组合会发生什么"。
package cn.kb.learn.testing;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@DisplayName("折扣计算:按覆盖率逐步补测试")
class DiscountCoverageTest {
private static final Map<String, Integer> DISCOUNTS = Map.of(
"NONE", 0, "SILVER", 5, "GOLD", 10);
static long apply(long amountInCents, String level) {
if (amountInCents < 0) {
throw new IllegalArgumentException("金额不能为负");
}
Integer percent = DISCOUNTS.get(level);
if (percent == null) {
throw new IllegalArgumentException("未知会员等级:" + level);
}
return amountInCents * (100 - percent) / 100;
}
@Test
@DisplayName("无折扣")
void none() {
assertThat(apply(10_000L, "NONE")).isEqualTo(10_000L);
}
@Test
@DisplayName("黄金会员 9 折")
void gold() {
assertThat(apply(10_000L, "GOLD")).isEqualTo(9_000L);
}
}逐步补测试的预期数字(用 jacoco.csv 的 DiscountCoverageTest 所在包统计):
| 阶段 | 测试数 | 行覆盖 | 分支覆盖 | 说明 |
|---|---|---|---|---|
| 起步 | 2 | 部分 | 低 | 只覆盖了 NONE 与 GOLD |
| 补 SILVER | 3 | 上升 | 上升 | 覆盖了 percent != null 的另一条路径 |
| 补未知等级 | 4 | 近乎满 | 上升 | 覆盖 percent == null 分支 |
| 补负数金额 | 5 | 满 | 满 | 覆盖最前面的校验分支 |
哪一次提升最大:通常是第一次。
原因:边际收益递减。第一个测试能覆盖大段"公共代码"(如方法入口、正常路径),后续测试往往只补一两个分支。本项目从 86 个测试到 88 个测试,只增加了 2 个测试就把行覆盖从 90.4% 提到 100%——这就是"用报告精准打击"的效率:
86 个测试 → 行 90.4% / 分支 86.1%
88 个测试 → 行 100.0% / 分支 91.7% ← 只加了 2 个测试如果没有覆盖率报告,你要靠人工审查才能发现"delete 方法从没被测过"——而这 2 个测试是照着报告找出来的,不是随便写的。
体检报告模板(以本项目为例):
【模块】labs/testing-unit(8 个类)
【总体】指令 96.3% / 分支 91.7% / 行 100%
【需要补测试的类(按优先级)】
1. ArticleService(分支 8/10)
- 缺口位置:title == null || title.isBlank() 与 content == null || content.isBlank() 的短路分支
- 性质:**功能没测**(参数校验的第一种输入没覆盖)
- 建议:必须补 —— 传 null 时的行为是明确的契约
2. UserService(分支 5/6)
- 缺口位置:newName == null 的短路分支
- 性质:**功能没测**
- 建议:必须补
3. User(分支 0/0,行 2/2)
- 缺口位置:无
- 性质:record 的自动方法,已 100%
- 建议:不用动
【可以不补的缺口】
- 如果存在 catch (Exception) 的兜底分支:只在极端环境触发,补测试需要大量构造
- main 方法、纯 getter/setter:价值低
【如果只能补一个测试】
补 ArticleService 的 null 标题测试 —— 因为:
① 缺口最大(2 个分支);
② 传 null 是真实会发生的调用错误;
③ 它能固化"抛 IllegalArgumentException 而不是 NPE"这个契约判断依据(这是本任务的核心):
| 缺口类型 | 判断 | 处理 |
|---|---|---|
| 业务规则分支(等级判断、状态流转) | 重要 | 必须补 |
| 参数校验的短路分支(null vs 空串) | 重要 | 建议补(契约明确) |
异常兜底分支(catch (Exception)) | 中等 | 视成本决定 |
| 框架要求但业务不需要的代码(无参构造、getter) | 低 | 不补,或用 @Generated 排除 |
"如果只能补一个"的答案通常是"缺口最多 + 业务最可能发生"的那个——而不是"最容易写的那个"。
最后提醒:不要把这个报告变成"必须补全"的清单。有些缺口是合理的(比如"磁盘满"这种分支),覆盖率的价值在于让你"知道缺口在哪",而不是"消灭所有缺口"。
jacoco.exec 的追加模式会误导本地判断。《分支覆盖率与覆盖率误区》:深入那个"行 100% 但分支 91.7%"的缺口。