覆盖率量的是「跑到了没有」,不是「跑对了没有」。 断言要打在行为上:不是「结果不为 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 秒实验
- 挑一个你写过的测试文件,数一下有几条断言里带具体数字。 比例低于一半,那一片的测试基本是摆设。
- 对一个小函数做三处手动变异:改一个比较符、改一个常量、改一个返回值。 三条全过的话,重写那几条断言。
- 找一条「正好等于边界」的断言补上。 边界上的 bug 占实际事故的比例,比想象中高得多。
常见误区
- 「覆盖率上去了,测试就到位了。」
覆盖率只排除「没被执行」这一种情况。
一行
is not None就能把覆盖率刷满。 - 「AI 生成的测试数量多,质量应该不错。」 数量跟质量没关系。 它能看见你的代码,所以它很容易生成「刚好覆盖但断言很弱」的测试。
- 「变异测试得上专业工具。」 手改六处,五分钟。 工具给你一个百分比,手改告诉你「哪几条是摆设」。
自测三问(点开对答案)
1. 覆盖率和变异存活率各问什么问题? —— **覆盖率问「这行执行到了吗」,存活率问「改错了能发现吗」。** **前者能被「不断言」糊弄,后者不能。** 2. 「断言打在行为上」具体是什么意思? —— **断言里要有一个具体的数。** **把「不超过一千」换成「正好等于九百」。** 3. 什么叫等价变异?为什么它重要? —— **改完之后行为和原来完全一样的变异。** **抓不住它不是测试的问题;分不清这个,会把好测试重写一遍。**最后一篇把三段串起来:复现、修复、回归,每一段各有各的通过标准。
- ☐ (本篇完,下一篇 10.4 验证闭环:复现—修复—回归)