你写了 200 个测试,全绿。信心满满地说“这个模块测完了”。但问题来了——你的测试跑过多少行代码?哪些 if-else 分支从未走进?哪些方法完全没有测试光顾?覆盖率就是来回答这些问题的。它不会告诉你测试对不对,但会告诉你测试没碰过哪些地方。
什么是代码覆盖率
代码覆盖率(Code Coverage)是指测试运行时执行到的代码比例。它可以拆分为多个维度:
| 维度 | 含义 | 举例 |
|---|---|---|
| 行覆盖率(Line) | 多少行被执行了 | 200 / 250 行 = 80% |
| 分支覆盖率(Branch) | if/else、switch 的每个分支都走了吗 | 15 / 20 个分支 = 75% |
| 方法覆盖率(Method) | 多少个方法被调了 | 8 / 10 个方法 = 80% |
| 类覆盖率(Class) | 多少个类被触达了 | 12 / 15 个类 = 80% |
| 圈复杂度覆盖率(Complexity) | 每个执行路径都走了吗 | 配合圈复杂度度量 |
从视觉上来说,一个覆盖率报告长这样:
Calculator.java - 行覆盖率: 75%
--------------------------------
public int divide(int a, int b) {
1 if (b == 0) { ← 绿色(测试覆盖了)
2 throw new ArithmeticException(); ← 红色(没覆盖到!)
}
3 return a / b; ← 绿色
}
绿色 = 执行过,红色 = 没执行过,黄色 = 部分执行(比如一个分支走了,另一个没走)。
代码覆盖率的底层原理:插桩
覆盖率工具怎么知道某行代码有没有被执行?答案叫插桩(Instrumentation)。
在 JVM 编译后的字节码里,覆盖率工具额外插入计数指令——相当于在每个关键位置埋一个计数器:
原始字节码:
iconst_0 // b == 0 ?
if_icmpne 20 // 不等,跳到 return a / b
new #2 // 新建 ArithmeticException
...
插桩后:
iconst_0
invokestatic CoverageCounter.hit(12) ← 插入:记录"第 12 行已执行"
if_icmpne 20
invokestatic CoverageCounter.hit(13) ← 插入:记录"第 13 行已执行"
new #2
...
测试跑完后,所有计数器的数据汇总,就能算出哪些行、哪些分支被执行过。
插桩有两种时机:
| 方式 | 时机 | 代表工具 |
|---|---|---|
| On-the-fly(运行时插桩) | JVM 加载类时动态植入 | JaCoCo 的 agent 模式 |
| Offline(编译期插桩) | 编译完成后修改 class 文件 | Cobertura、Emma |
运行时插桩更常用——不需要修改构建产物,不影响正常编译流程。
覆盖率的历史:Emma → Cobertura → JaCoCo
Java 覆盖率工具的演进,是一部典型的“旧工具消亡、新工具统一”的故事。
Emma(2005 ~ 2012,已停止维护)
Emma 是 Java 覆盖率工具的早期玩家。它的特点是轻量——单个 jar 包就能跑,不需要额外依赖。
# Emma 的使用方式
java -cp emma.jar emma instr -m overwrite -cp target/classes
java -cp emma.jar:target/classes YourTest
java -cp emma.jar emma report -r html -in coverage.em
它的局限也很明显:
- 不支持 Java 8(2012 年以后就停更了,连 Java 7 的 invokedynamic 支持都不完整)
- 离线插桩,需要修改 class 文件,流程繁琐
- 不支持分支覆盖率,只有行覆盖率
所以随着 Java 版本升级,Emma 不可避免地退出舞台。
Cobertura(2006 ~ 2015,已停止维护)
Cobertura 在 Emma 基础上做了提升:
- 支持分支覆盖率
- HTML 报告比 Emma 更美观
- 和 Maven 集成更好(
mvn cobertura:cobertura)
但 Cobertura 和 Emma 有同样的死穴——离线插桩,且计算方式有问题:Cobertura 默认只统计“被测试触动过的类”,也就是说如果你的类完全没有被测试引用,它不会被算进分母。这导致覆盖率数字虚高——看起来很漂亮,其实大量代码根本没测试。
而且 Cobertura 也停更了,最后一个版本停留在 2015 年。
JaCoCo(2011 ~ 至今,统治级)
JaCoCo(Java Code Coverage)是 Mt. Emma 的同作者开发的新一代工具,名字里就有传承意味。它吸取了 Emma 和 Cobertura 的所有教训,做出了根本性改进:
| Emma | Cobertura | JaCoCo | |
|---|---|---|---|
| 插桩方式 | 离线 | 离线 | 运行时(agent) + 离线双模式 |
| 行覆盖率 | ✅ | ✅ | ✅ |
| 分支覆盖率 | ❌ | ✅ | ✅ |
| 指令覆盖率(最精细) | ❌ | ❌ | ✅ |
| 圈复杂度 | ❌ | ❌ | ✅ |
| Java 8+ 支持 | ❌ | ❌ | ✅(持续更新) |
| Maven 集成 | 一般 | 好 | 完美 |
| SonarQube 集成 | ❌ | ❌ | ✅ |
| 维护状态 | 2012 停更 | 2015 停更 | 活跃维护 |
JaCoCo 引入了一个独特的指标——指令覆盖率(Instruction Coverage),这是 Java 字节码层面的覆盖率,比行覆盖更精细:
// 这一行 Java 代码
int result = a > 0 ? a : -a;
// 编译成字节码后可能是多条指令
// iload a
// ifle L1
// iload a ← 这个分支走到了
// goto L2
// L1: iload a
// ineg ← 这个分支没走到?指令覆盖就暴露出来了
// L2: istore result
行覆盖率会说“这一行覆盖了”,但指令覆盖率能告诉你——三元表达式的另一半没走到。
结论:2023 年以后(甚至 2018 年以后),JaCoCo 就是 Java 覆盖率工具的唯一选择。 Emma 和 Cobertura 已成历史,了解它们只是为了理解这个领域的演进脉络。
覆盖率数字的真相
覆盖率是个容易让人产生错觉的指标。
高覆盖率 ≠ 好测试
@Test
void testEverything() {
service.process(new Request()); // 一行覆盖了 80% 的代码
// 但:没验证任何返回值
// 没验证任何副作用
// 没测试任何异常路径
}
这叫“为了覆盖率而写测试”——行覆盖率 80%,实际验证效果为 0。
低覆盖率 ≠ 坏代码
// 这是自动生成的 equals/hashCode/toString
@Override
public boolean equals(Object o) { /* Lombok 自动生成,20 行 */ }
@Override
public int hashCode() { /* 5 行 */ }
@Override
public String toString() { /* 8 行 */ }
这 33 行拉低了覆盖率,但它们不需要测试——框架生成的代码,框架保证正确性。
100% 覆盖率的成本曲线
覆盖率
100% ┤ ╱
┤ ╱
90% ┤ ╱
┤ ╱
80% ┤ ╱
┤ ╱
60% ┤ ╱╲╱╲╱╲╱╲
┤──╲╱──
└──────────────────────────────→ 测试编写时间
从 0% 到 80% 相对容易,从 80% 到 90% 需要精心设计的测试用例,从 90% 到 100% 的成本可能比前面加起来还大——而且收益递减。
务实的目标:核心业务逻辑 80%~90%,工具类/配置类不必强求。
覆盖率在 CI/CD 流水线中的位置
覆盖率处在单元测试和 SonarQube 之间:
代码提交 → 静态检查 → 编译 → 单元测试 + 覆盖率采集 → SonarQube 质量门禁 → 打包
↑
JaCoCo 在此处工作
每次 mvn test,JaCoCo 自动插桩、自动采集、自动生成报告。然后 SonarQube 读取报告,用质量门禁判定:
if 新增代码覆盖率 < 80% → 构建失败,打回修改
if 新增代码覆盖率 ≥ 80% → 放行
小结
代码覆盖率是一个度量工具,不是质量目标:
- 它告诉你测试的盲区在哪——哪些代码从未被测试触碰
- 它不能告诉你测试是否写对了——断言是否正确、边界是否考虑
- 覆盖率低一定有问题,覆盖率高不说明没问题
下一篇我们会深入 JaCoCo 的实战——如何配置、如何生成报告、如何解读报告里的每个数字、如何把报告喂给 SonarQube。
每天前进一小步,就是一个新的高度!