Skip to content

覆盖率 100%,一个断言都没有 ​

一个运费计算函数,三套测试,行覆盖率和分支覆盖率都是 100%。第一套测试里没有任何断言。把函数里的 >= 改成 >、把乘法改成除法、把返回值改成 0,这套测试全部照样通过。

覆盖率回答的问题是「哪些代码被测试执行过」。它经常被当成另一个问题的答案:「哪些行为被测试验证过」。这两个问题之间隔着两层:执行过的代码,结果不一定被检查;被检查的结果,不一定包含出错的那些输入。再往外还有一层:需求里有而代码里没写的东西,覆盖率的分母里根本没有它。

本文用 JaCoCo 和变异测试工具 PIT 在同一份代码上把这几层分别量出来。实验见文末。

一、被测的函数和三套测试 ​

java
/** 满 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。
没有断言每条路径都调用一遍行覆盖 11/11,分支覆盖 10/10杀死变异体 2/17典型值加断言每条路径一个值行覆盖 11/11,分支覆盖 10/10杀死变异体 14/17再补上分界点9899 与 9900、1000 与 1001 克行覆盖 11/11,分支覆盖 10/10杀死变异体 16/17存活的边界变异:amountCents < 0 改成 <= 0;amountCents >= 9900 改成 > 9900;weightGrams > 1000 改成 >= 1000最后一个是等价的:重量恰好 1000 克时续重为 0,走不走这个分支结果都是 800需求里「超过 30 公斤不承运」没有实现:针对它的测试失败,而三份覆盖率报告都是 100%
图 1 · 实测(JaCoCo 0.8.15、PIT 1.30.0):同一个 11 行的运费函数,三套测试的行覆盖和分支覆盖都是 100%。没有断言的那套只杀死 17 个变异体里的 2 个;每条路径取一个典型值并断言的杀死 14 个,漏掉的 3 个都是比较运算符的边界;补上分界点两侧的值之后杀死 16 个,剩下的 1 个是等价变异体,改了之后程序行为不变,任何测试都杀不死。

二、覆盖率量的是什么 ​

JaCoCo 这类工具在字节码里插入探针,记录每个探针有没有被执行到,再换算成行、分支、方法的覆盖比例。它不知道测试有没有断言,也不知道断言了什么。

需求里要求的行为包括还没有写成代码的规则:超过 30 公斤不承运写出来的代码覆盖率的分母:11 行、10 个分支被测试执行过的代码覆盖率的分子:三套测试都是 11/11、10/10结果被断言检查的行为变异测试量的是这一层覆盖率看不到这一圈以外没有断言的测试停在第三层:代码都跑过了,改错任何一处它都照样通过第四层内部还有分界点:只取典型值的测试,发现不了 >= 写成 > 这类错误
图 2 · 覆盖率工具量的是第二层和第三层之间的比例:写出来的代码里有多少被测试执行过。它的分母是已经写出来的代码,没写出来的需求不在里面;它的分子只要求代码被执行,不要求结果被检查。变异测试量的是更里面一层:把代码改错之后,有没有测试会失败。

由此可以直接得出覆盖率能说明和不能说明的事:

  • 覆盖率低是有信息的。 没被执行过的代码一定没有被验证,这是覆盖率最可靠的用途:找出完全没有测试碰过的地方。
  • 覆盖率高没有对称的含义。 100% 只说明每一行都被跑过。没有断言的测试是极端情况,更常见的是断言太弱:只检查「不为空」「没有抛异常」「返回了 200」。
  • 分支覆盖也不等于分界点被测过。 amountCents >= 9900 有两个分支,取 5000 和 20000 就都覆盖了,但写成 > 9900 的错误要取 9900 才能发现。
  • 分母是写出来的代码。 该写而没写的判断不产生任何未覆盖的行。

把覆盖率定成必须达到的指标之后,前两条的差别会被放大:最省事的达标方式就是写只调用、不检查的测试。

三、变异测试:把代码改错,看测试会不会失败 ​

变异测试直接检验「测试能不能发现错误」:工具每次对被测代码做一处小改动(一个变异体),然后跑测试。有测试失败,这个变异体被「杀死」;测试全部通过,它「存活」,说明这处改动造成的行为变化没有任何测试在意。

PIT 的默认算子对这个函数生成了 17 个变异体,主要是四类:比较运算符换边界(< 与 <= 互换)、条件取反、算术运算符替换、把返回值换成 0。

测试行覆盖分支覆盖杀死存活的变异体
没有断言11/1110/102/17其余 15 个
典型值加断言11/1110/1014/173 个边界变异
补上分界点11/1110/1016/171 个边界变异

没有断言的测试也杀死了 2 个:把第一个 if 的条件取反之后,正常输入会抛异常,测试因为未捕获的异常而失败。这说明「不抛异常」本身就是一个隐含的断言,但它只能发现最粗的错误。

典型值测试漏掉的 3 个都是边界变异:

text
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%,变异测试也不会生成任何与它有关的变异体,因为没有代码可以变异。只有按需求写出一个测试,才会发现:

text
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 辅助编码的工程纪律)。

不用复杂度给测试开豁免。 有一种规则是「圈复杂度低于某个值的方法可以不写单元测试」。复杂度数的是分支,而很多错误根本不在分支里:

java
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;                        // 先整除,丢掉小数
}
text
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 与多态:复杂度到底降了没有。

让代码容易被断言。 能对返回值直接断言的纯函数最容易测好;结果藏在数据库、当前时间、外部调用里的代码,测试往往只能做到「调用了、没报错」。这是设计问题,见 关注点分离与可测试性。


配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权