覆盖率 100%,一个断言都没有
一个运费计算函数,三套测试,行覆盖率和分支覆盖率都是 100%。第一套测试里没有任何断言。把函数里的
>=改成>、把乘法改成除法、把返回值改成 0,这套测试全部照样通过。
覆盖率回答的问题是「哪些代码被测试执行过」。它经常被当成另一个问题的答案:「哪些行为被测试验证过」。这两个问题之间隔着两层:执行过的代码,结果不一定被检查;被检查的结果,不一定包含出错的那些输入。再往外还有一层:需求里有而代码里没写的东西,覆盖率的分母里根本没有它。
本文用 JaCoCo 和变异测试工具 PIT 在同一份代码上把这几层分别量出来。实验见文末。
一、被测的函数和三套测试
/** 满 99 元包邮;首重 1 公斤 8 元,续重每 500 克 2 元、不足 500 克按 500 克算;会员八折。金额单位是分,重量单位是克。 */
public static long fee(long amountCents, int weightGrams, boolean member) {
if (amountCents < 0 || weightGrams <= 0) {
throw new IllegalArgumentException("invalid input");
}
if (amountCents >= 9900) {
return 0;
}
long fee = 800;
if (weightGrams > 1000) {
int extra = (weightGrams - 1000 + 499) / 500;
fee += extra * 200L;
}
if (member) {
fee = fee * 80 / 100;
}
return fee;
}三套测试:
- 没有断言:包邮、首重、续重加会员、非法输入,每条路径调用一次,不检查返回值;非法输入用
try/catch包住。 - 典型值加断言:同样的路径,每条取一个典型值并断言结果,例如
fee(5000, 2000, false) == 1200。 - 补上分界点:在此基础上,对每个比较条件取分界点两侧的值:9899 与 9900 分、1000 与 1001 克、1500 与 1501 克、金额 0。
二、覆盖率量的是什么
JaCoCo 这类工具在字节码里插入探针,记录每个探针有没有被执行到,再换算成行、分支、方法的覆盖比例。它不知道测试有没有断言,也不知道断言了什么。
由此可以直接得出覆盖率能说明和不能说明的事:
- 覆盖率低是有信息的。 没被执行过的代码一定没有被验证,这是覆盖率最可靠的用途:找出完全没有测试碰过的地方。
- 覆盖率高没有对称的含义。 100% 只说明每一行都被跑过。没有断言的测试是极端情况,更常见的是断言太弱:只检查「不为空」「没有抛异常」「返回了 200」。
- 分支覆盖也不等于分界点被测过。
amountCents >= 9900有两个分支,取 5000 和 20000 就都覆盖了,但写成> 9900的错误要取 9900 才能发现。 - 分母是写出来的代码。 该写而没写的判断不产生任何未覆盖的行。
把覆盖率定成必须达到的指标之后,前两条的差别会被放大:最省事的达标方式就是写只调用、不检查的测试。
三、变异测试:把代码改错,看测试会不会失败
变异测试直接检验「测试能不能发现错误」:工具每次对被测代码做一处小改动(一个变异体),然后跑测试。有测试失败,这个变异体被「杀死」;测试全部通过,它「存活」,说明这处改动造成的行为变化没有任何测试在意。
PIT 的默认算子对这个函数生成了 17 个变异体,主要是四类:比较运算符换边界(< 与 <= 互换)、条件取反、算术运算符替换、把返回值换成 0。
| 测试 | 行覆盖 | 分支覆盖 | 杀死 | 存活的变异体 |
|---|---|---|---|---|
| 没有断言 | 11/11 | 10/10 | 2/17 | 其余 15 个 |
| 典型值加断言 | 11/11 | 10/10 | 14/17 | 3 个边界变异 |
| 补上分界点 | 11/11 | 10/10 | 16/17 | 1 个边界变异 |
没有断言的测试也杀死了 2 个:把第一个 if 的条件取反之后,正常输入会抛异常,测试因为未捕获的异常而失败。这说明「不抛异常」本身就是一个隐含的断言,但它只能发现最粗的错误。
典型值测试漏掉的 3 个都是边界变异:
amountCents < 0 改成 amountCents <= 0 金额为 0 的订单被拒绝
amountCents >= 9900 改成 amountCents > 9900 刚好 99 元的订单不包邮
weightGrams > 1000 改成 weightGrams >= 1000 (见下一节)前两个都是会被用户遇到的缺陷,而且正好发生在业务规则写明的那个数字上。测试设计里「等价类取一个代表值、分界点两侧各取一个值」的做法,针对的就是这一类错误;变异测试给了它一个可以自动检查的标准。
四、杀不死的变异体
补上分界点之后还剩 1 个:把 weightGrams > 1000 改成 >= 1000。重量恰好是 1000 克时会进入续重分支,但这时 (1000 - 1000 + 499) / 500 等于 0,运费不变。改动前后程序对所有输入的结果都相同,这叫等价变异体,没有任何测试能杀死它。
所以变异分数不必追求 100%。存活的变异体需要人看一眼:是测试缺了一个用例,还是等价变异体,还是这段代码本身可以简化。把变异分数也定成硬指标,会重复覆盖率的问题,只是成本更高。
五、没写出来的需求
假设需求里还有一条:单件超过 30 公斤不承运。代码里没有这个判断。
三套测试的覆盖率仍然是 100%,变异测试也不会生成任何与它有关的变异体,因为没有代码可以变异。只有按需求写出一个测试,才会发现:
MissingRequirementTest.rejectsOverweightParcel:11
Expected java.lang.IllegalArgumentException to be thrown, but nothing was thrown.
Tests run: 1, Failures: 1覆盖率和变异测试都是从代码出发的,它们能检查「写出来的代码有没有被测好」,不能检查「该写的代码有没有写」。后者要从需求出发:把每条业务规则对应到至少一个测试,规则里的每个数字对应到分界点两侧的用例。评审测试时,对照的应该是需求清单,而不是覆盖率报告。
生成的测试还有一种更基础的问题:「测试文件能被解析」不等于「测试被执行了」。Python 的 ast.parse 只检查语法,不导入模块、不运行任何断言;测试框架能收集到测试函数、测试真的运行、断言真的在检查被测代码(而不是检查一份复制进测试文件里的实现),是逐级递进的四件事。评审生成的测试时,至少要看到它在真实的被测代码上运行并失败过一次。
六、同一个百分比,口径可以差很多
看一个覆盖率数字之前,先确认三件事。
计数器是哪一种。 JaCoCo 同时给出指令、分支、行、方法和圈复杂度几种计数器。行覆盖的规则是「这一行至少有一条指令被执行过」,所以一行里有一半没执行,也算这行被覆盖(报告里标黄的部分覆盖行)。同一份代码,行覆盖往往明显高于分支覆盖;团队约定的阈值要写明是哪一种计数器。
短路条件按字节码分支计算。 if (vip && amount > 100) 在源码里是一行、一个 if,编译后是两个条件跳转、四个分支。只用 vip = false 的输入测试,这一行算被覆盖,但第二个条件一次都没有被求值过。分支覆盖能发现这种情况,行覆盖不能。
排除清单里有什么。 生成的代码(Lombok、MapStruct、协议编译器)、配置类、DTO 常被排除在统计之外。JaCoCo 会自动过滤带有名称包含 Generated 的注解(类保留或运行时保留)的方法,Lombok 需要在 lombok.config 里打开 lombok.addLombokGeneratedAnnotation 才会加上这个注解。排除有合理的理由,但每一项都会让百分比变高,而被验证的东西一点没有变多。排除清单应该写在构建配置里、经过评审,并且定期检查是否有业务逻辑被顺手排除了。
七、这两个指标怎么用
覆盖率当下限,不当目标。 用它找没有测试碰过的代码,尤其是新增和改动的部分;看到某个模块覆盖率很高时,不据此得出「测得很充分」的结论。
变异测试用在值得的地方。 它要对每个变异体跑一遍相关的测试,比普通测试慢一到两个数量级。适合的范围是规则密集、出错代价高的核心逻辑:计费、权限判断、状态流转、限额。可以只对改动的代码运行(PIT 支持按版本控制的变更范围和增量分析),放在合并前或每日构建里,而不是每次提交。
评审测试时看断言。 一个测试方法里如果没有断言,或者只断言了与被测规则无关的东西,它贡献的只是覆盖率数字。AI 生成的测试尤其要看这一点(见 AI 辅助编码的工程纪律)。
不用复杂度给测试开豁免。 有一种规则是「圈复杂度低于某个值的方法可以不写单元测试」。复杂度数的是分支,而很多错误根本不在分支里:
static long discounted(long priceCents, int qty, int percentOff) {
return priceCents * qty * ((100 - percentOff) / 100); // 先整除,折扣变成 0
}
static int total(int priceCents, int qty) {
return priceCents * qty; // int 相乘溢出
}
static double averageYuan(long totalCents, int qty) {
return (totalCents / qty) / 100.0; // 先整除,丢掉小数
}discounted、total、averageYuan 的圈复杂度与 NPath 都是 1(PMD 7.28.0)
常见输入(不打折、小额、能整除):3/3 通过
边界输入:0/3 通过;discounted(1999, 2, 10) 期望 3598 实际 0;total(2000000, 1500) 期望 3000000000 实际 -1294967296;averageYuan(1000, 6) 期望 1.67 实际 1.66三个方法都没有分支,一组常见输入就能让每一行都被执行到,测试也全部通过;错误只在边界输入上出现。复杂度低说明的是「容易测」,而不是「不用测」。决定一段代码要不要测、测到什么程度,看的是它出错的代价和输入的边界,和覆盖率一样,复杂度也只是一个辅助信号。复杂度本身各数什么,见嵌套、卫语句、switch 与多态:复杂度到底降了没有。
让代码容易被断言。 能对返回值直接断言的纯函数最容易测好;结果藏在数据库、当前时间、外部调用里的代码,测试往往只能做到「调用了、没报错」。这是设计问题,见 关注点分离与可测试性。
配套实验
- codesphere-labs/testing/coverage-is-not-verification:同一个运费函数的三套测试在 JaCoCo 0.8.15 与 PIT 1.30.0(JUnit Jupiter 6.1.3)下的行覆盖、分支覆盖与每个变异体的结论,以及针对未实现需求的测试结果(验证记录)
- codesphere-labs/testing/low-complexity-defects:PMD 7.28.0、JDK 25:三个圈复杂度为 1 的计价方法在常见输入与边界输入下的结果(验证记录)
参考资料