🛠️ 第10章 场景二:修复 bug、补测试、做回归
📄 一句话概括

覆盖率量的是「跑到了没有」,不是「跑对了没有」。 断言要打在行为上:不是「结果不为 None」,是「结果等于 900」。 六处人工变异:表面版抓着一条,行为版全抓。差的全在非典型输入上。

📝 本章和前面几章的关系

上一篇管住了「改哪儿」。这一篇管「改完怎么知道自己没改坏」。 为什么 AI 生成的测试特别容易「表面达标」? 因为它能看见你的代码,知道哪些行还没被执行—— 所以它很容易把覆盖率刷到百分之百,而每条断言都写得很弱。 这不是它偷懒,是「覆盖率」这个指标本身就能被这样满足。 Demo 用十行计价函数做六处人工变异,两组测试各打一遍: 表面版抓一条,行为版抓全部六条。

一、覆盖率不等于可靠

先把两个指标分清楚。

指标 问的问题 能被怎么糊弄
覆盖率 这一行执行到了吗 执行到但不断言
变异存活率 改错了能发现吗 糊弄不了

覆盖率有一个天然的上限:它只能证明「没被跳过」。 一行 assert result is not None 就能让那行代码算作被覆盖—— 哪怕那个 result 算错了一百倍。 所以「覆盖率百分之九十」这句话,信息量比听起来小得多。 它排除了一种情况(代码根本没被执行),仅此而已。 Demo 里表面版那五条断言:不为 None、等于零、是个数字、普通单正确、不超过原价。 五条全过,行覆盖满,而六处变异里它只抓住了返回值那一处。

二、断言要打在行为上

「打在行为上」这个说法有点抽象,换个说法:断言里要有一个具体的数。

表面写法 行为写法
结果不为 None 等于 0
是个数字 等于 900
不超过原价 正好等于 500

三行的差别是同一个:把「大概对」换成「正好是这个数」。 「不超过一千」这个断言,在结果是一千、九百、零的时候都成立——它几乎什么都排除不了。 第二行「等于 900」就不一样: 折扣算成九五折是 950,不打折是 1000,四舍五入错一位是 900.0000001——这三种错它都能抓。 边界值要单独立一条。 因为 > 和 >= 只差一个等号,而这一格之差,在最常见的输入上完全看不出来—— Demo 里 total > 500 改成 >=,只有「正好五百」那一组输入能发现。

三、变异测试的思路

变异测试的思路简单到不像个方法:把代码故意改错一处,看测试挂不挂。 挂,说明这条测试有用;不挂,说明它是摆设。 Demo 里改了六处,都是最常见的手误: 比较符差一格、常量写错、条件写反、精度少一位、返回值改了。 这六种错误不需要想象力,因为它们是真实事故的常见形态。 关键的一点:不用上工具。 六处手改,五分钟能验完。 上了工具你会得到几百个变异体和一个百分比,但五分钟那次手改能告诉你「哪几条测试是摆设」。 还有一个副产品:如果某个变异「怎么改都抓不住」,说明那行代码的行为没有被任何测试约束—— 要么补测试,要么承认它就是没人管的地方。 后者也是一种答案,比假装它被覆盖了要好。 注意一个陷阱:等价变异。 Demo 里本来想试 qty <= 0 改成 qty < 0,结果发现两种写法行为完全一样—— 因为数量为零时总额也是零,两条路径返回同一个值。 这种变异抓不住不是测试的问题,是这处改动压根没改变行为。 分不清「测试没用」和「变异无效」,会把好好的测试重写一遍。

四、动手 Demo:AI 生成的测试:覆盖率不等于可靠

一个十行的计价函数,两条边界、一个折扣、一处四舍五入。 两组测试:一组「表面达标」,一组「打在行为上」。 然后对源码做六处人工变异,每组各打一遍,看能抓出几处。 变异是真的改源码、真的重新加载模块、真的跑断言。

MUTANTS = [
    ("边界:qty <= 0 变 qty == 0", "if qty <= 0:", "if qty == 0:"),
    ("比较:total > 500 变 >=", "if total > 500:", "if total >= 500:"),
    ("常量:0.9 变 0.95", "total * 0.9", "total * 0.95"),
    ("条件:if vip 变 if True", "if vip:", "if True:"),
    ("精度:round 2 位变 1 位", "round(total, 2)", "round(total, 1)"),
    ("返回值:0 变 1", "return 0", "return 1"),
]

for label, old, new in MUTANTS:
    mut = PRICING.replace(old, new)
    mm = load(root, "m", mut)          # 真加载变异后的模块
    caught = any(not ok(mm.price) for _, ok in BEHAVIOR)
    print(label, "抓" if caught else "—")

实跑输出:

--------------------------------------------------------------------
AI 生成的测试:覆盖率不等于可靠
--------------------------------------------------------------------

  一个十行的计价函数,六处人工变异,两个测试集各打一遍,看能抓出几处。

  ① 被测函数
  --------------------------------------------------------------------

  [被测代码 · 本沙箱真跑]
  | # -*- coding: utf-8 -*-
  | """计价。"""
  | 
  | 
  | def price(qty, unit, vip=False):
  |     if qty <= 0:
  |         return 0
  |     total = qty * unit
  |     if vip:
  |         if total > 500:
  |             total = total * 0.9
  |     return round(total, 2)
  十行,两条边界、一个折扣、一处四舍五入。


  ② 表面达标的测试集
  --------------------------------------------------------------------
| 断言 | 原版 |
|---|---|
| 结果不为 None | 过 |
| 空数量返回 0 | 过 |
| 返回数字 | 过 |
| 普通单正确 | 过 |
| VIP 不超过原价 | 过 |

  五条全过,而且每条都执行到了——行覆盖率能到百分之百。
  ! 但它们断言的是「不为 None」「是个数字」,不是「应该是多少」。
  这种测试改天代码算错了,它也照样过。

  ③ 六处变异,看谁抓得住
  --------------------------------------------------------------------
| # | 变异 | 表面版 | 行为版 |
|---|---|---|---|
| 1 | 边界:qty <= 0 变 qty == 0 | — | 抓 |
| 2 | 比较:total > 500 变 >= | — | 抓 |
| 3 | 常量:0.9 变 0.95 | — | 抓 |
| 4 | 条件:if vip 变 if True | — | 抓 |
| 5 | 精度:round 2 位变 1 位 | — | 抓 |
| 6 | 返回值:0 变 1 | 抓 | 抓 |

  表面版抓 1 条,行为版抓 6 条,一共六条变异。
  ! 表面版漏掉的那几条,全都落在「非典型输入」上:负数量、正好五百、非 VIP、小数点后第三位。
  而表面版那五条输入都是「典型的」——一个正常数量、一个零、一个整数单。bug 从来不在典型输入上。

  ④ 断言打在行为上
  --------------------------------------------------------------------
| 表面写法 | 行为写法 | 差别 |
|---|---|---|
| 结果不为 None | 等于 0 | 后者能抓住返回值变了 |
| 是个数字 | 等于 900 | 后者能抓住折扣算错 |
| 不超过原价 | 正好等于 500 | 后者能抓住边界偏一格 |

  三行的规律一样:把「大概对」换成「正好是这个数」。
  边界值得单独立一条,因为 > 和 >= 只差一个等号,而这一格之差在最常见的输入上完全看不出来。
  ! 变异测试不用真上工具,手改六处就够了。改完如果测试全过,那些测试就是摆设——这件事五分钟能验完。

一抓六,这就是覆盖率百分之百和「真能发现问题」之间的距离。 表面版那五条断言不是错的,它们太弱——「不为 None」在一个返回数字的函数里几乎永远成立。 漏掉的那五条全落在非典型输入上:负数量、正好五百、非 VIP、小数点后第三位。 而表面版挑的输入都很典型:一个正常数量、一个零、一个整数单。 变异表最后一行暴露了另一件事:表面版唯一抓住的是「返回值改成 1」。 因为它有一条「空数量返回 0」——这是它唯一一条写了具体数字的断言。 也就是说,那五条里唯一有用的一条,恰好是唯一一条「打在行为上」的。 最后那张对照表要记住第三行。 「不超过原价」换成「正好等于 500」,看起来只是把话说死了一点, 但它把一条什么都排除不了的断言,变成了一条能抓住边界偏一格的断言。

30 秒实验

  1. 挑一个你写过的测试文件,数一下有几条断言里带具体数字。 比例低于一半,那一片的测试基本是摆设。
  2. 对一个小函数做三处手动变异:改一个比较符、改一个常量、改一个返回值。 三条全过的话,重写那几条断言。
  3. 找一条「正好等于边界」的断言补上。 边界上的 bug 占实际事故的比例,比想象中高得多。

常见误区

  1. 「覆盖率上去了,测试就到位了。」 覆盖率只排除「没被执行」这一种情况。 一行 is not None 就能把覆盖率刷满。
  2. 「AI 生成的测试数量多,质量应该不错。」 数量跟质量没关系。 它能看见你的代码,所以它很容易生成「刚好覆盖但断言很弱」的测试。
  3. 「变异测试得上专业工具。」 手改六处,五分钟。 工具给你一个百分比,手改告诉你「哪几条是摆设」。
自测三问(点开对答案) 1. 覆盖率和变异存活率各问什么问题? —— **覆盖率问「这行执行到了吗」,存活率问「改错了能发现吗」。** **前者能被「不断言」糊弄,后者不能。** 2. 「断言打在行为上」具体是什么意思? —— **断言里要有一个具体的数。** **把「不超过一千」换成「正好等于九百」。** 3. 什么叫等价变异?为什么它重要? —— **改完之后行为和原来完全一样的变异。** **抓不住它不是测试的问题;分不清这个,会把好测试重写一遍。**

最后一篇把三段串起来:复现、修复、回归,每一段各有各的通过标准。

相关笔记