bigywater.tech
Python · 104

变异测试:测试用例真的能发现错误吗

覆盖率告诉你代码被执行过,不告诉你测试会不会发现错误。变异测试反过来:故意把代码改坏一点点(把 >= 改成 >、+ 改成 -),看你的测试会不会「报警」。一处改动测试没发现,就说明这处代码的测试是形同虚设。这篇用 mutmut 在一个覆盖率 100% 的小模块上真的做一遍。

100% → 5/11
覆盖率 100%,但 11 个变异体里只有 5 个被测试发现
6 个存活
clamp 边界、is_adult 边界、total 的运算符和 tax 项
2 个等价变异体
补全测试后仍无法杀死:改动没有改变任何可观察行为

做的事:一个「覆盖率 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_6

mutmut 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 个变异体。变异体数量大致随代码量线性增长,每个都要跑一遍测试套件,大项目上会慢得多,所以通常只对关键模块或变更的部分运行。这个耗时我没有在大项目上验证。

分步演示:11 次「故意改坏」,测试发现了几次

状态取自 mutmut 的真实结果
变异测试
当前变异体

    本文用到的工具与边界

    • 本机 macOS 27 / Python 3.14.7,mutmut 3.8.0、pytest 9.1.1、coverage 7.16.2;变异体由 mutmut 生成,不是我手写
    • 没有验证:mutmut 之外的工具(cosmic-ray、mutatest)、大型项目上的耗时与变异体数量、如何自动识别等价变异体
    京ICP备2026067502号-1京公网安备11010502063487号