覆盖率告诉你代码被执行过,不告诉你测试会不会发现错误。变异测试反过来:故意把代码改坏一点点(把 >= 改成 >、+ 改成 -),看你的测试会不会「报警」。一处改动测试没发现,就说明这处代码的测试是形同虚设。这篇用 mutmut 在一个覆盖率 100% 的小模块上真的做一遍。
src/calc.py def clamp(x, lo, hi): if x < lo: return lo if x > hi: return hi return x def is_adult(age): return age >= 18 def total(prices, tax): s = 0 for p in prices: s += p return s * (1 + tax)
test_calc.py(初版) def test_clamp_runs(): clamp(5, 0, 10); clamp(-1, 0, 10); clamp(99, 0, 10) # 只调用, 不断言 def test_adult(): assert is_adult(30) def test_total(): assert total([1, 2], 0) == 3
mutmut mutmut, version 3.8.0 pytest: 3 passed in 0.07s Name Stmts Miss Branch BrPart Cover Missing --------------------------------------------------------- src/calc.py 13 0 6 0 100% --------------------------------------------------------- TOTAL 13 0 6 0 100%
3 个测试全部通过,行覆盖与分支覆盖都是 100%——第 100 篇里讲过,这个数字只说明代码被执行了。
共生成 11 个变异体, 用时 0.7 s: 被测试杀死 5 个, 存活 6 个 (存活 = 测试没发现这个改动)
全部变异体及其状态:
calc.x_clamp__mutmut_1 survived - if x < lo: + if x <= lo:
calc.x_clamp__mutmut_2 survived - if x > hi: + if x >= hi:
calc.x_is_adult__mutmut_1 survived - return age >= 18 + return age > 18
calc.x_is_adult__mutmut_2 survived - return age >= 18 + return age >= 19
calc.x_total__mutmut_1 killed - s = 0 + s = None
calc.x_total__mutmut_2 killed - s = 0 + s = 1
calc.x_total__mutmut_3 killed - s += p + s = p
calc.x_total__mutmut_4 killed - s += p + s -= p
calc.x_total__mutmut_5 survived - return s * (1 + tax) + return s / (1 + tax)
calc.x_total__mutmut_6 survived - return s * (1 + tax) + return s * (1 - tax)
calc.x_total__mutmut_7 killed - return s * (1 + tax) + return s * (2 + tax)
mutmut results 里存活的变异体:
calc.x_clamp__mutmut_1
calc.x_clamp__mutmut_2
calc.x_is_adult__mutmut_1
calc.x_is_adult__mutmut_2
calc.x_total__mutmut_5
calc.x_total__mutmut_6mutmut run 把源码里的比较、算术、常量等各改动一处,生成一个变异体,然后对每个变异体重新跑测试套件:测试失败,变异体被杀死(测试能发现这个错误);测试仍然全部通过,变异体存活(测试对这处改动毫无察觉)。11 个变异体里,5 个被杀死、6 个存活。
calc.x_clamp__mutmut_1: - if x < lo: + if x <= lo: calc.x_clamp__mutmut_2: - if x > hi: + if x >= hi: calc.x_is_adult__mutmut_1: - return age >= 18 + return age > 18 calc.x_is_adult__mutmut_2: - return age >= 18 + return age >= 19 calc.x_total__mutmut_5: - return s * (1 + tax) + return s / (1 + tax) calc.x_total__mutmut_6: - return s * (1 + tax) + return s * (1 - tax)
每个存活变异体都精确对应一个测试盲点:x < lo 改成 x <= lo 没人发现——因为测试从没有让 x 恰好等于下限;age >= 18 改成 > 18 或 >= 19 没人发现——只测了 30;s * (1 + tax) 改成 s / (1 + tax) 或 s * (1 - tax) 没人发现——只测了 tax=0,乘除、加减在 0 时结果一样。它们全在「覆盖率 100%」的代码里。
共 11 个变异体, 用时 0.7 s: 杀死 9 个, 存活 2 个 仍然存活: calc.x_clamp__mutmut_1: - if x < lo: + if x <= lo: 仍然存活: calc.x_clamp__mutmut_2: - if x > hi: + if x >= hi:
补上 clamp 的等于上下限、is_adult 的 17 与 18、total 的 tax=0.5 和空列表,再跑:11 个变异体里杀死 9 个。剩下 2 个是等价变异体:if x <= lo: return lo——当 x == lo 时它返回 lo,而原来的代码(不进这个 if)返回 x,两者相等,行为完全一样,没有任何测试能区分。变异测试因此不能追求「存活为 0」,要人工审视每个存活的变异体:是测试的漏洞,还是等价的?
另外这个小例子上,一次完整的变异测试只花了 0.7 s——因为只有 11 个变异体。变异体数量大致随代码量线性增长,每个都要跑一遍测试套件,大项目上会慢得多,所以通常只对关键模块或变更的部分运行。这个耗时我没有在大项目上验证。