主题
界面
主题
《JaCoCo:覆盖率指标与报告》 学会了用覆盖率找缺口。本节讲怎么不被它骗——因为覆盖率是测试领域里最容易被误读的指标。
核心是一个我们亲手做出来的实测对比:行覆盖率 100%,分支覆盖率只有 91.7%。
完成本节后,你应该能够:
&& / || / 三元 / switch);先看一组我们自己的真实数字:
行覆盖 73/73 = 100.0%
分支覆盖 33/36 = 91.7%如果只看行覆盖,你会认为"测试已经完整了"——每一行代码都执行过。
但事实是:有 3 个分支从未被走到,其中一个就在这段代码里:
if (newName == null || newName.isBlank()) { // 这一行"被覆盖了"
throw new IllegalArgumentException("新名字不能为空");
}"这行被覆盖"的含义是:判断语句执行过。 它不代表的含义是:newName == null 这一支被验证过。
这就是本节要解决的问题:行覆盖会高估测试的充分性,而分支覆盖能暴露真相。
| 行覆盖(LINE) | 分支覆盖(BRANCH) | |
|---|---|---|
| 统计单位 | 源码行 | 每个判断的每个可能结果 |
| 含义 | 这行执行过吗 | 这个判断的每条路都走过了吗 |
| 会高估吗 | 会(一行可能含多个分支) | 相对可靠 |
| 对应到什么 | "代码被碰过" | "逻辑决策被验证过" |
关键认知:覆盖率是"被执行过的比例",而"决策"的覆盖才是真正衡量逻辑测试的指标。
四种常见结构:
// 1. 逻辑运算符(&& / ||)——短路求值带来多个分支
if (a != null && a.isValid()) { } // 4 个分支
// 2. 三元运算符
long price = member ? 9_000L : 10_000L; // 2 个分支
// 3. switch
switch (level) {
case "A" -> ... // 每个 case 都是一个分支
default -> ...
}
// 4. 循环的进入判断
while (hasMore()) { } // 2 个分支(进入 / 不进入)JaCoCo 对 || 与 && 的统计口径:每个操作数算两个分支(真 / 假)。
所以 newName == null || newName.isBlank() 一共是 4 个分支:
| # | 分支 | 现有测试是否覆盖 |
|---|---|---|
| 1 | newName == null 为真 | ❌ 未覆盖 |
| 2 | newName == null 为假 → 继续判断右边 | ✅ |
| 3 | newName.isBlank() 为真 | ✅(传了 " ") |
| 4 | newName.isBlank() 为假 | ✅(传了正常名字) |
这就是缺的那 1 个分支的准确位置。
| 类 | 行覆盖 | 分支覆盖 | 缺口位置 | 总数 |
|---|---|---|---|---|
UserService | 18/18 ✅ | 5/6 | newName == null 为真 | 6 |
ArticleService | 12/12 ✅ | 8/10 | title == null 为真、content == null 为真 | 10 |
OrderService | 14/14 ✅ | 2/2 ✅ | 无 | 2 |
OrderCalculator | 19/19 ✅ | 14/14 ✅ | 无 | 14 |
PasswordValidator | 6/6 ✅ | 4/4 ✅ | 无 | 4 |
三处缺口有一个共同点:都是"参数为 null"的那一支。
这不是巧合——它反映了测试设计的一个普遍盲区:
写测试时,人会自然地想到"传一个空白字符串""传一个超长字符串",但很少想到传 null。
而 null 在真实调用中随时可能出现(上游服务返回 null、JSON 反序列化、可选字段)。
补上它的测试:
@Test
@DisplayName("传 null 时也抛 IllegalArgumentException(此前未覆盖的分支)")
void rejects_null_name() {
assertThatThrownBy(() -> service.rename("u1", null))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("新名字不能为空");
}效果实测:
补 null 分支前:分支 33/36 = 91.7%
补 null 分支后:分支 36/36 = 100.0%一个测试方法(三个断言)就把分支覆盖从 91.7% 拉到 100%——因为缺口都集中在同一类输入上。
问题:最后的几个百分点成本极高(要构造极端场景),收益却很低。
例子:catch (IOException) 的兜底分支、main 方法、框架要求的无参构造。
反制:覆盖率是"发现缺口"的工具,不是"消灭缺口"的任务清单。看到未覆盖时先问"这块业务上重要吗"。
@Test
void covers_delete() {
service.delete("u1"); // 覆盖率 +5 行,保护力 +0
}问题:覆盖率高 ≠ 测试有效(T1-012 的"假绿")。
反制:任何"为了覆盖率"补的测试都必须有断言。如果写不出有意义的断言,说明这个缺口不值得补。
当一个度量变成目标时,它就不再是一个好的度量。
具体表现:
| 手段 | 覆盖率的反应 | 实际质量 |
|---|---|---|
| 给 getter/setter 写测试 | 上升 | 无变化 |
| 写无断言的"跑一遍"测试 | 上升 | 下降(维护成本增加) |
把难测的代码标 @Generated 排除 | 上升 | 下降(关键是那块代码没测) |
| 拆大方法为小方法再逐个测 | 上升 | 可能上升(也可能只是数字游戏) |
反制:只对"新增代码"设覆盖率要求(T1-018),老代码只要求"不下降"。
未覆盖的代码有两种可能:
if (config.getMode() == Mode.LEGACY) { // 这个分支从未被走过
doLegacyThing();
}如果 Mode.LEGACY 早就废弃了,那这段代码的存在本身就是问题——覆盖率报告实际上帮你发现了可删除的代码。
反制:看到长期未覆盖的代码,先问"它还该存在吗"。
问题:
反制:把覆盖率当"团队的技术债仪表盘",不是"个人考核表"。用它来讨论"哪里需要投入",而不是"谁做得不够"。
| 用法 | 说明 |
|---|---|
| 发现缺口 | "这个类的分支覆盖只有 60%,去看看" |
| 看趋势 | 这季度从 70% 到 78%,说明补测试有成效 |
| 新增代码门禁 | 新提交的代码覆盖率必须达到 85%(T1-018) |
一句话总结:
覆盖率的价值在于"让你知道哪里没测",而不在于"证明你测得好"。
if (a != null && a.isValid()) { } // JaCoCo 计 4 个分支测试矩阵:
| a 的取值 | 覆盖的分支 |
|---|---|
null | 覆盖"a == null 为真" |
无效对象 | 覆盖"a != null 为假" + "isValid() 为假" |
有效对象 | 覆盖"isValid() 为真" |
要覆盖全部 4 个分支,需要至少 3 组输入——只用两组输入就会留下缺口。
见 5.3 节。
// 反例:为了覆盖这个兜底分支,编造了不现实的测试
@Test
void covers_disk_full_branch() throws Exception {
// 用反射替换静态字段,模拟"磁盘满"
Field field = Storage.class.getDeclaredField("diskFull");
field.setAccessible(true);
field.set(null, true);
// 一个测试 40 行,只为了 1 个分支
}问题:成本高、脆弱(依赖内部字段名)、价值低(磁盘满的场景不可能在 CI 复现)。
更好的处理:记下来,标注为"已知未覆盖";或者重构让这个分支变得可测(把"是否可写"的判断提取成一个可注入的依赖)。
报告数字的读取(续 T1-004):
cd labs
./mvnw -pl testing-unit test
cd ..
python -X utf8 -c "import csv; rows=list(csv.DictReader(open('labs/testing-unit/target/site/jacoco/jacoco.csv',encoding='utf-8'))); 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'行 {cl}/{tl}={cl/tl*100:.1f}% 分支 {cb}/{tb}={cb/tb*100:.1f}%'); [print(' ', r['CLASS'], '分支', r['BRANCH_COVERED']+'/'+str(int(r['BRANCH_COVERED'])+int(r['BRANCH_MISSED']))) for r in rows if int(r['BRANCH_MISSED'])>0]"预期输出:
行 73/73=100.0% 分支 33/36=91.7%
UserService 分支 5/6
ArticleService 分支 8/10报告直接点出了两个类、三处缺口——而且行覆盖是 100%。
补上缺口的测试:
@Test
@DisplayName("传 null 时也抛 IllegalArgumentException(此前未覆盖的分支)")
void rejects_null_name() {
UserRepository repository = mock(UserRepository.class);
Notifier notifier = mock(Notifier.class);
when(repository.findById("u1"))
.thenReturn(Optional.of(new User("u1", "旧名", "u1@test.com")));
UserService service = new UserService(repository, notifier);
assertThatThrownBy(() -> service.rename("u1", null))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("新名字不能为空");
}验证效果(记得先删 jacoco.exec,避免追加模式的干扰):
cd labs
Remove-Item testing-unit/target/jacoco.exec
./mvnw -pl testing-unit test行 73/73=100.0% 分支 36/36=100.0%从 91.7% 到 100%,只补了一个测试——这就是"用分支覆盖发现缺口"的效率。
UserService.rename,看行号左侧的覆盖率色条(需要安装覆盖率插件或跑 JaCoCo 后查看 index.html):if (newName == null || newName.isBlank()) 那一行是黄色——点开能看到 1 of 4 branches missed;rename("u1", null) 处断点,观察短路求值:newName == null 为真后,newName.isBlank() 不会被执行——这正是那 2 个分支的差别;jacoco.csv 对比同一行的 LINE_COVERED 与 BRANCH_COVERED:一行的覆盖,掩盖了四个分支里的一个缺口。见 5.2 节。修复:把分支覆盖率作为逻辑测试的主要指标。
见 6.3 节。修复:识别"不值得测的分支"并记录,而不是硬造测试。
看两个数字:
| 场景 | 合理判断 |
|---|---|
| 业务核心类分支 60% | 需要补 |
| 业务核心类分支 95%,缺的是"磁盘满"兜底 | 可以不补 |
| 工具类分支 100%,但没断言 | 更糟(假绿) |
判断标准:这个未覆盖的分支,如果出 bug,用户会感知到吗?
结果:覆盖率上去了,重要的分支仍然没测。
修复:按"业务重要性 × 触发概率"排序补测试的顺序,而不是按"好写程度"。
一个自查问题:如果你只能再写三个测试,你会写哪三个? 按这个顺序补。
见误区四。修复:把长期未覆盖的代码当作重构信号(可能该删)。
目标:亲手验证"行覆盖会高估"。
步骤:
jacoco.csv 里 BRANCH_MISSED > 0 的类,再去 HTML 报告里找黄色的行);验收方式:能说出第 3 步三个分支的业务含义,以及第 6 步的判断依据。
扩展任务:给你项目里覆盖率最高的那个类做一次"断言审查"——它的每个测试都有有效断言吗? 如果发现有"只调用不断言"的测试,写出改进版本。
基础题
a != null && a.isValid() 统计 ______ 个分支;要全覆盖它,至少需要 ______ 组不同的输入。代码阅读题
public long calculate(long amount, String coupon) {
if (amount <= 0) { // 分支 A
throw new IllegalArgumentException("金额必须为正");
}
if (coupon != null && coupon.startsWith("SAVE")) { // 分支 B
return amount - 1_000;
}
return amount;
}现有测试:
@Test void positive_amount_with_save_coupon() { assertThat(calculate(5_000, "SAVE10")).isEqualTo(4_000); }
@Test void positive_amount_with_null_coupon() { assertThat(calculate(5_000, null)).isEqualTo(5_000); }amount <= 0 的分支测了吗?)编程题
OrderState:String next(String current, String action);NEW + pay → PAID;PAID + refund → REFUNDED;NEW + refund → 抛异常;其他组合 → 抛异常;综合任务
assert/verify 的测试方法);提示 1(方向):第 4 题的关键是"每个 if 有两个分支,&& 会额外增加分支"。
提示 2(关键点):第 5 题的现有测试完全没有测 amount <= 0 的异常路径——所以那 1 行是红色的(未覆盖),不只是黄色。
提示 3(接近答案):第 7 题的"只调用不断言"可以通过搜索测试方法体里没有 assert / verify / assertThat 的方法来发现。
&&、||、三元、switch),所以行 100% 完全可能伴随分支未满(本项目实测就是行 100%、分支 91.7%)。为什么是 3 组:
| 输入 | 覆盖 |
|---|---|
null | "a == null 为真" 这一支 |
a 有效但 isValid() 为假 | "a != null 为假" + "isValid() 为假" |
a 有效且 isValid() 为真 | "isValid() 为真" |
用两组输入(null + 有效对象)会漏掉"无效对象"那两支。
古德哈特定律:当一个度量变成目标时,它就不再是一个好的度量。
第 4 题:
行覆盖:3/5 = 60%(amount <= 0 的 throw 行与 return amount 的路径都没走到?见下面分析)
实际上按"行"统计:
| 行 | 是否执行 |
|---|---|
if (amount <= 0) | ✅ |
throw new IllegalArgumentException(...) | ❌ 未覆盖 |
if (coupon != null && ...) | ✅ |
return amount - 1_000 | ✅ |
return amount | ✅ |
行覆盖 4/5 = 80%;分支覆盖:
| 分支 | 覆盖 |
|---|---|
amount <= 0 为真 | ❌ |
amount <= 0 为假 | ✅ |
coupon != null 为真 | ✅ |
coupon != null 为假 | ✅ |
startsWith("SAVE") 为真 | ✅ |
startsWith("SAVE") 为假 | ❌ |
分支覆盖 4/6 = 66.7%
第 5 题:两个缺口:
amount <= 0 的分支——异常路径完全没测(这是红色的行,一眼可见);coupon 非 null 但不以 "SAVE" 开头的分支(如 "DISCOUNT10")——这个缺口最隐蔽:现有测试传了 "SAVE10" 和 null,看起来"两种情况都测了",但漏掉了"非 null 且不匹配"的第三种情况。补测试时可能发现的问题:
@Test
void non_save_coupon_is_ignored() {
assertThat(calculate(5_000, "DISCOUNT10")).isEqualTo(5_000);
}如果实现里写成了 if (coupon != null || coupon.startsWith("SAVE"))(用了 || 而不是 &&),这个测试会暴露它(因为 null 与正常 coupon 都能过,只有"非空且不匹配"会触发区别)。
这正是分支覆盖的价值:它迫使你思考"每种输入组合",而不只是"典型输入"。
package cn.kb.learn.testing;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@DisplayName("订单状态机:分支覆盖率分析")
class OrderStateTest {
static String next(String current, String action) {
if ("NEW".equals(current) && "pay".equals(action)) {
return "PAID";
}
if ("PAID".equals(current) && "refund".equals(action)) {
return "REFUNDED";
}
if ("NEW".equals(current) && "refund".equals(action)) {
throw new IllegalStateException("未支付的订单不能退款");
}
throw new IllegalArgumentException("非法状态转移:" + current + " + " + action);
}
@Test
@DisplayName("正常路径:NEW + pay → PAID")
void new_pay_goes_to_paid() {
assertThat(next("NEW", "pay")).isEqualTo("PAID");
}
@Test
@DisplayName("正常路径:PAID + refund → REFUNDED")
void paid_refund_goes_to_refunded() {
assertThat(next("PAID", "refund")).isEqualTo("REFUNDED");
}
@Test
@DisplayName("业务规则:未支付不能退款")
void new_refund_is_rejected() {
assertThatThrownBy(() -> next("NEW", "refund"))
.isInstanceOf(IllegalStateException.class)
.hasMessage("未支付的订单不能退款");
}
@Test
@DisplayName("兜底:非法转移")
void unknown_transition_is_rejected() {
assertThatThrownBy(() -> next("REFUNDED", "pay"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("非法状态转移");
}
}状态转移表(分支清单):
| current | action | 期望 | 测试覆盖 |
|---|---|---|---|
| NEW | pay | PAID | ✅ |
| PAID | refund | REFUNDED | ✅ |
| NEW | refund | 抛 IllegalStateException | ✅ |
| PAID | pay | 抛 IllegalArgumentException | ✅(用 REFUNDED+pay 代替) |
| REFUNDED | * | 抛异常 | ✅ |
| 其他 current | * | 抛异常 | ✅ |
哪一次提升最大:第一次(从 0 到 1)。
业务上重要但容易漏的分支:
PAID + pay(重复支付)——这是真实场景中会发生的(用户重复点击、网络重试),而它落进了"兜底异常"分支。测试时要专门为它写一个用例,因为"重复支付"的业务含义(应拒绝)与"非法状态转移"不同;REFUNDED + refund(重复退款)——同理,重复退款是资金相关的高危场景。为什么容易漏:因为它们的代码路径与"兜底异常"是同一条(都落到最后一个 throw),所以行覆盖与分支覆盖都不会告警——但业务上它们是不同的场景,需要不同的测试与断言。
这条经验非常重要:
覆盖率能发现"代码路径没走到",但发现不了"同一个代码路径对应的不同业务场景"。
唯一的解法是"先列场景清单,再写测试"(这正是 T1-006 的 AAA 里"准备要可视化"的深层原因)——覆盖率的角色是"补漏",不是"驱动设计"。
误区自查报告模板:
【模块】labs/testing-unit
【覆盖率】行 100.0% / 分支 91.7%
【误区一:追求 100%】
- 检查结果:未发现(项目只把覆盖率当参考,未设硬指标)
- 证据:分支覆盖停在 91.7%,没有为了补满而写无意义测试
【误区二:只调用不断言】
- 检查结果:未发现
- 检查方法:搜索所有 @Test 方法,确认都有 assertThat / assertThrows / verify
- 反例参考:CoverageGapTest 虽然是为覆盖率而补,但每条都验证了"做了什么"
【误区三:把覆盖率当目标】
- 检查结果:需要注意
- 风险点:CoverageGapTest 的注释写明了"照着报告补测试",
要防止后续演变成"报告缺什么就补什么、不管业务重要性"
【误区四:忽略死代码】
- 检查结果:未发现
- 说明:当前 8 个类的未覆盖分支都有明确的业务含义(null 输入校验)
【误区五:用覆盖率评价个人】
- 检查结果:不适用(本项目为个人学习项目)
- 但需要记住:如果团队引入覆盖率,**只用于讨论技术债,不用于考核**
【改进建议(按优先级)】
1. 补 3 个 null 输入的测试,把分支覆盖提到 100%(成本低、契约明确)
2. 给 OrderCalculator 的三参数版本补充边界用例(当前分支已满,但边界值可再细化)
3. 建立"新增代码覆盖率"检查(见 T1-018),只对新代码设要求
【如何向团队解释"不该追求 100%"】
用这组数据:
- 行覆盖 100% 时,分支覆盖只有 91.7% —— 说明"看起来测满了"本身就是错觉
- 从 86 个测试到 88 个测试,加 2 个测试把行覆盖从 90.4% 提到 100%
—— 说明"用报告精准补缺"效率很高,但**再往上补 1% 的成本会陡增**
- 更有说服力的例子:某些分支(磁盘满、网络超时)在 CI 里根本无法稳定复现,
为它写测试的维护成本远高于收益
结论一句话:
"我们用覆盖率**找问题**,不用覆盖率**交作业**。""只调用不断言"的搜索方法:
# 找出测试方法中不含断言关键字的(粗略筛查,需要人工复核)
grep -A 10 "@Test" src/test/java/**/*.java | grep -B 2 -A 8 "void " | grep -L "assert\|verify"更可靠的方法:用 IDE 的"结构化搜索"或写脚本解析测试方法体,检查是否包含 assert* / verify* / fail。
最后一个提醒:这份自查报告的价值不在于"发现多少问题",而在于建立一种习惯——每次看到覆盖率数字时,先问"它衡量的是我想衡量的东西吗"。
《JaCoCo 阈值与 CI 质量门禁》:把覆盖率接到流水线上,并且确保它真的在工作。