Skip to content

分支覆盖率与覆盖率误区 ​

T1-013必学进阶约 45 分钟JDK 21测试专项 A:单元测试 · 覆盖率与门禁

1. 本节定位 ​

《JaCoCo:覆盖率指标与报告》 学会了用覆盖率找缺口。本节讲怎么不被它骗——因为覆盖率是测试领域里最容易被误读的指标。

核心是一个我们亲手做出来的实测对比:行覆盖率 100%,分支覆盖率只有 91.7%。

2. 学习目标 ​

完成本节后,你应该能够:

  • 说清行覆盖率与分支覆盖率的定义差异;
  • 解释"一行里为什么会有多个分支"(&& / || / 三元 / switch);
  • 定位那 8.3% 的分支缺口到底在哪,并说明它的业务含义;
  • 说出五种覆盖率误区,并给出各自的反制方法;
  • 用"发现缺口"而不是"达成指标"的心态使用覆盖率。

3. 前置知识检查 ​

4. 为什么需要这个知识 ​

先看一组我们自己的真实数字:

text
行覆盖   73/73  = 100.0%
分支覆盖 33/36  = 91.7%

如果只看行覆盖,你会认为"测试已经完整了"——每一行代码都执行过。

但事实是:有 3 个分支从未被走到,其中一个就在这段代码里:

java
if (newName == null || newName.isBlank()) {          // 这一行"被覆盖了"
    throw new IllegalArgumentException("新名字不能为空");
}

"这行被覆盖"的含义是:判断语句执行过。 它不代表的含义是:newName == null 这一支被验证过。

这就是本节要解决的问题:行覆盖会高估测试的充分性,而分支覆盖能暴露真相。

5. 核心概念 ​

5.1 行覆盖 vs 分支覆盖 ​

行覆盖(LINE)分支覆盖(BRANCH)
统计单位源码行每个判断的每个可能结果
含义这行执行过吗这个判断的每条路都走过了吗
会高估吗会(一行可能含多个分支)相对可靠
对应到什么"代码被碰过""逻辑决策被验证过"

关键认知:覆盖率是"被执行过的比例",而"决策"的覆盖才是真正衡量逻辑测试的指标。

5.2 一行里怎么会有多个分支 ​

四种常见结构:

java
// 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 个分支:

#分支现有测试是否覆盖
1newName == null 为真❌ 未覆盖
2newName == null 为假 → 继续判断右边✅
3newName.isBlank() 为真✅(传了 " ")
4newName.isBlank() 为假✅(传了正常名字)

这就是缺的那 1 个分支的准确位置。

5.3 我们的缺口分布(实测) ​

类行覆盖分支覆盖缺口位置总数
UserService18/18 ✅5/6newName == null 为真6
ArticleService12/12 ✅8/10title == null 为真、content == null 为真10
OrderService14/14 ✅2/2 ✅无2
OrderCalculator19/19 ✅14/14 ✅无14
PasswordValidator6/6 ✅4/4 ✅无4

三处缺口有一个共同点:都是"参数为 null"的那一支。

这不是巧合——它反映了测试设计的一个普遍盲区:

写测试时,人会自然地想到"传一个空白字符串""传一个超长字符串",但很少想到传 null。

而 null 在真实调用中随时可能出现(上游服务返回 null、JSON 反序列化、可选字段)。

补上它的测试:

java
@Test
@DisplayName("传 null 时也抛 IllegalArgumentException(此前未覆盖的分支)")
void rejects_null_name() {
    assertThatThrownBy(() -> service.rename("u1", null))
            .isInstanceOf(IllegalArgumentException.class)
            .hasMessage("新名字不能为空");
}

效果实测:

text
补 null 分支前:分支 33/36 = 91.7%
补 null 分支后:分支 36/36 = 100.0%

一个测试方法(三个断言)就把分支覆盖从 91.7% 拉到 100%——因为缺口都集中在同一类输入上。

5.4 五种覆盖率误区 ​

误区一:追求 100% ​

问题:最后的几个百分点成本极高(要构造极端场景),收益却很低。

例子:catch (IOException) 的兜底分支、main 方法、框架要求的无参构造。

反制:覆盖率是"发现缺口"的工具,不是"消灭缺口"的任务清单。看到未覆盖时先问"这块业务上重要吗"。

误区二:只调用不断言 ​

java
@Test
void covers_delete() {
    service.delete("u1");        // 覆盖率 +5 行,保护力 +0
}

问题:覆盖率高 ≠ 测试有效(T1-012 的"假绿")。

反制:任何"为了覆盖率"补的测试都必须有断言。如果写不出有意义的断言,说明这个缺口不值得补。

误区三:把覆盖率当目标(古德哈特定律) ​

当一个度量变成目标时,它就不再是一个好的度量。

具体表现:

手段覆盖率的反应实际质量
给 getter/setter 写测试上升无变化
写无断言的"跑一遍"测试上升下降(维护成本增加)
把难测的代码标 @Generated 排除上升下降(关键是那块代码没测)
拆大方法为小方法再逐个测上升可能上升(也可能只是数字游戏)

反制:只对"新增代码"设覆盖率要求(T1-018),老代码只要求"不下降"。

误区四:忽略"未覆盖的是不是死代码" ​

未覆盖的代码有两种可能:

  1. 缺测试(应该补);
  2. 死代码(应该删)。
java
if (config.getMode() == Mode.LEGACY) {        // 这个分支从未被走过
    doLegacyThing();
}

如果 Mode.LEGACY 早就废弃了,那这段代码的存在本身就是问题——覆盖率报告实际上帮你发现了可删除的代码。

反制:看到长期未覆盖的代码,先问"它还该存在吗"。

误区五:用覆盖率评价个人或团队 ​

问题:

  1. 对新人不公平:他们通常接手最难测的老代码;
  2. 鼓励"挑好写的测":而非"挑重要的测";
  3. 制造"测试与实现的博弈":为了数字好看而调结构,而不是为了设计质量。

反制:把覆盖率当"团队的技术债仪表盘",不是"个人考核表"。用它来讨论"哪里需要投入",而不是"谁做得不够"。

5.5 覆盖率的三条正确用法 ​

用法说明
发现缺口"这个类的分支覆盖只有 60%,去看看"
看趋势这季度从 70% 到 78%,说明补测试有成效
新增代码门禁新提交的代码覆盖率必须达到 85%(T1-018)

一句话总结:

覆盖率的价值在于"让你知道哪里没测",而不在于"证明你测得好"。

6. 递进示例 ​

6.1 最小示例:看清一行里的分支 ​

java
if (a != null && a.isValid()) { }        // JaCoCo 计 4 个分支

测试矩阵:

a 的取值覆盖的分支
null覆盖"a == null 为真"
无效对象覆盖"a != null 为假" + "isValid() 为假"
有效对象覆盖"isValid() 为真"

要覆盖全部 4 个分支,需要至少 3 组输入——只用两组输入就会留下缺口。

6.2 业务示例:定位并补上缺口 ​

见 5.3 节。

6.3 反例:把分支覆盖当成"必须补满" ​

java
// 反例:为了覆盖这个兜底分支,编造了不现实的测试
@Test
void covers_disk_full_branch() throws Exception {
    // 用反射替换静态字段,模拟"磁盘满"
    Field field = Storage.class.getDeclaredField("diskFull");
    field.setAccessible(true);
    field.set(null, true);
    // 一个测试 40 行,只为了 1 个分支
}

问题:成本高、脆弱(依赖内部字段名)、价值低(磁盘满的场景不可能在 CI 复现)。

更好的处理:记下来,标注为"已知未覆盖";或者重构让这个分支变得可测(把"是否可写"的判断提取成一个可注入的依赖)。

7. 完整可运行代码 ​

报告数字的读取(续 T1-004):

powershell
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]"

预期输出:

text
行 73/73=100.0%  分支 33/36=91.7%
  UserService 分支 5/6
  ArticleService 分支 8/10

报告直接点出了两个类、三处缺口——而且行覆盖是 100%。

补上缺口的测试:

java
@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,避免追加模式的干扰):

powershell
cd labs
Remove-Item testing-unit/target/jacoco.exec
./mvnw -pl testing-unit test
text
行 73/73=100.0%  分支 36/36=100.0%

从 91.7% 到 100%,只补了一个测试——这就是"用分支覆盖发现缺口"的效率。

8. Debug 观察点 ​

  1. 在 IDEA 里打开 UserService.rename,看行号左侧的覆盖率色条(需要安装覆盖率插件或跑 JaCoCo 后查看 index.html):if (newName == null || newName.isBlank()) 那一行是黄色——点开能看到 1 of 4 branches missed;
  2. 在 rename("u1", null) 处断点,观察短路求值:newName == null 为真后,newName.isBlank() 不会被执行——这正是那 2 个分支的差别;
  3. 用 jacoco.csv 对比同一行的 LINE_COVERED 与 BRANCH_COVERED:一行的覆盖,掩盖了四个分支里的一个缺口。

9. 常见错误 ​

错误 1:以为"行覆盖 100% 就是测全了" ​

见 5.2 节。修复:把分支覆盖率作为逻辑测试的主要指标。

错误 2:为覆盖率编造不现实的测试 ​

见 6.3 节。修复:识别"不值得测的分支"并记录,而不是硬造测试。

错误 3:把分支覆盖不到 100% 当作失败 ​

看两个数字:

场景合理判断
业务核心类分支 60%需要补
业务核心类分支 95%,缺的是"磁盘满"兜底可以不补
工具类分支 100%,但没断言更糟(假绿)

判断标准:这个未覆盖的分支,如果出 bug,用户会感知到吗?

错误 4:只补"容易补"的分支 ​

结果:覆盖率上去了,重要的分支仍然没测。

修复:按"业务重要性 × 触发概率"排序补测试的顺序,而不是按"好写程度"。

一个自查问题:如果你只能再写三个测试,你会写哪三个? 按这个顺序补。

错误 5:忽略"死代码" ​

见误区四。修复:把长期未覆盖的代码当作重构信号(可能该删)。

10. 动手实验 ​

目标:亲手验证"行覆盖会高估"。

步骤:

  1. 跑出当前的覆盖率数字,确认"行 100% / 分支 91.7%";
  2. 找出那 3 个未覆盖的分支的具体位置(提示:看 jacoco.csv 里 BRANCH_MISSED > 0 的类,再去 HTML 报告里找黄色的行);
  3. 写出它们的业务含义(不要只写"null 分支",要写"调用方传 null 时会怎样");
  4. 补一个测试把 3 个分支都覆盖(传 null),重跑并确认分支覆盖到 100%;
  5. 反向实验:写一个"只调用不断言"的测试,观察覆盖率数字与保护力的分离;
  6. 找一个"长期未覆盖"的代码(可以是自己项目里的),判断它是"缺测试"还是"该死代码"。

验收方式:能说出第 3 步三个分支的业务含义,以及第 6 步的判断依据。

扩展任务:给你项目里覆盖率最高的那个类做一次"断言审查"——它的每个测试都有有效断言吗? 如果发现有"只调用不断言"的测试,写出改进版本。

11. 练习题 ​

基础题

  1. 判断:行覆盖率 100% 意味着每个逻辑分支都被验证过。(对 / 错)
  2. 填空:JaCoCo 对 a != null && a.isValid() 统计 ______ 个分支;要全覆盖它,至少需要 ______ 组不同的输入。
  3. 简答:为什么"把覆盖率当目标"会让测试质量下降?

代码阅读题

java
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;
}

现有测试:

java
@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); }
  1. 当前的行覆盖率是多少?分支覆盖率是多少?
  2. 哪些分支还没覆盖?补测试时会发现什么问题?(提示:amount <= 0 的分支测了吗?)

编程题

  1. 给一个"订单状态机"做分支覆盖率分析:
    • 被测类 OrderState:String next(String current, String action);
    • 规则:NEW + pay → PAID;PAID + refund → REFUNDED;NEW + refund → 抛异常;其他组合 → 抛异常;
    • 先写 2 个测试(覆盖两条正常路径),跑分支覆盖率并记录;
    • 列出所有分支组合的清单(画一个状态转移表),逐个补测试;
    • 记录每次补测试后的数字变化,说明哪一次提升最大;
    • 额外要求:找出 1-2 个"业务上重要但容易漏"的分支,说明为什么容易漏。

综合任务

  1. 为一个模块写一份"覆盖率误区自查报告":
    • 检查项目里是否存在这五种误区(逐一给出证据或"未发现");
    • 特别检查:有没有"只调用不断言"的测试?(搜索只有调用没有 assert/verify 的测试方法);
    • 给出改进建议:哪 3 个测试最应该被修正;
    • 说明:如果你要向团队解释"为什么不该追求 100% 覆盖率",你会用什么数据或例子?
分级提示

提示 1(方向):第 4 题的关键是"每个 if 有两个分支,&& 会额外增加分支"。

提示 2(关键点):第 5 题的现有测试完全没有测 amount <= 0 的异常路径——所以那 1 行是红色的(未覆盖),不只是黄色。

提示 3(接近答案):第 7 题的"只调用不断言"可以通过搜索测试方法体里没有 assert / verify / assertThat 的方法来发现。

12. 参考答案 ​

基础题
  1. 错。行覆盖只统计"这行执行过没有"。一行可以包含多个分支(&&、||、三元、switch),所以行 100% 完全可能伴随分支未满(本项目实测就是行 100%、分支 91.7%)。
  2. 4 个分支;至少 3 组输入。

为什么是 3 组:

输入覆盖
null"a == null 为真" 这一支
a 有效但 isValid() 为假"a != null 为假" + "isValid() 为假"
a 有效且 isValid() 为真"isValid() 为真"

用两组输入(null + 有效对象)会漏掉"无效对象"那两支。

  1. 因为度量的定义与目标不匹配:覆盖率衡量的是"代码被执行的比例",而团队需要的是"缺陷被拦住的比例"。当两者被绑定时,团队会优化前者——写没有断言的测试、给 getter 加测试、把难测的代码排除在统计外。这些动作都能提升数字,但都不提升质量。

古德哈特定律:当一个度量变成目标时,它就不再是一个好的度量。

代码阅读题(第 4、5 题)

第 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 题:两个缺口:

  1. amount <= 0 的分支——异常路径完全没测(这是红色的行,一眼可见);
  2. coupon 非 null 但不以 "SAVE" 开头的分支(如 "DISCOUNT10")——这个缺口最隐蔽:现有测试传了 "SAVE10" 和 null,看起来"两种情况都测了",但漏掉了"非 null 且不匹配"的第三种情况。

补测试时可能发现的问题:

java
@Test
void non_save_coupon_is_ignored() {
    assertThat(calculate(5_000, "DISCOUNT10")).isEqualTo(5_000);
}

如果实现里写成了 if (coupon != null || coupon.startsWith("SAVE"))(用了 || 而不是 &&),这个测试会暴露它(因为 null 与正常 coupon 都能过,只有"非空且不匹配"会触发区别)。

这正是分支覆盖的价值:它迫使你思考"每种输入组合",而不只是"典型输入"。

编程题(第 6 题)
java
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("非法状态转移");
    }
}

状态转移表(分支清单):

currentaction期望测试覆盖
NEWpayPAID✅
PAIDrefundREFUNDED✅
NEWrefund抛 IllegalStateException✅
PAIDpay抛 IllegalArgumentException✅(用 REFUNDED+pay 代替)
REFUNDED*抛异常✅
其他 current*抛异常✅

哪一次提升最大:第一次(从 0 到 1)。

业务上重要但容易漏的分支:

  1. PAID + pay(重复支付)——这是真实场景中会发生的(用户重复点击、网络重试),而它落进了"兜底异常"分支。测试时要专门为它写一个用例,因为"重复支付"的业务含义(应拒绝)与"非法状态转移"不同;
  2. REFUNDED + refund(重复退款)——同理,重复退款是资金相关的高危场景。

为什么容易漏:因为它们的代码路径与"兜底异常"是同一条(都落到最后一个 throw),所以行覆盖与分支覆盖都不会告警——但业务上它们是不同的场景,需要不同的测试与断言。

这条经验非常重要:

覆盖率能发现"代码路径没走到",但发现不了"同一个代码路径对应的不同业务场景"。

唯一的解法是"先列场景清单,再写测试"(这正是 T1-006 的 AAA 里"准备要可视化"的深层原因)——覆盖率的角色是"补漏",不是"驱动设计"。

综合任务(第 7 题)

误区自查报告模板:

text
【模块】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 里根本无法稳定复现,
    为它写测试的维护成本远高于收益

结论一句话:
  "我们用覆盖率**找问题**,不用覆盖率**交作业**。"

"只调用不断言"的搜索方法:

bash
# 找出测试方法中不含断言关键字的(粗略筛查,需要人工复核)
grep -A 10 "@Test" src/test/java/**/*.java | grep -B 2 -A 8 "void " | grep -L "assert\|verify"

更可靠的方法:用 IDE 的"结构化搜索"或写脚本解析测试方法体,检查是否包含 assert* / verify* / fail。

最后一个提醒:这份自查报告的价值不在于"发现多少问题",而在于建立一种习惯——每次看到覆盖率数字时,先问"它衡量的是我想衡量的东西吗"。

13. 掌握标准 ​

  • [ ] 能说清行覆盖与分支覆盖的差异,并举出一行多分支的例子;
  • [ ] 能定位到具体的未覆盖分支,并写出它的业务含义;
  • [ ] 能说出五种覆盖率误区与反制方法;
  • [ ] 补测试时按"业务重要性"排序而不是"好写程度";
  • [ ] 知道覆盖率发现不了"同一路径上的不同业务场景";
  • [ ] 记住那句话:用覆盖率找问题,不用覆盖率交作业。

14. 扩展资源 ​

15. 下一步学习 ​

《JaCoCo 阈值与 CI 质量门禁》:把覆盖率接到流水线上,并且确保它真的在工作。

进度只保存在本机浏览器,可随时清除

个人学习项目:AI 生成内容均经人工复核,第三方资源仅作索引