37 个用例跑完 41 秒,绿了 33 个。剩下 4 个红的里面,有 3 个不是代码错,是用例本身把方向写反了——一个断言「退款金额大于订单金额」,另一个把 assert result 写成了 assert not result。跑测试的人第一反应会是去改代码,改完才发现是自己写错了断言。
这件事之后我做了一轮更严格的检验:往被测算的模块里注入 12 个变异体(改一个比较符、删一行边界判断),看单测能不能抓住。第一轮的结果是 0 抓 12,一枪没中。
模型写单测时有一个默认行为:先读实现,再写断言。这个顺序决定了它写出的是「实现的重述」而不是「规格的检验」。
实现里写 rate = refunded / total * 0.85,它就断言 == 0.85 * refunded / total。测试永远绿,因为它复刻了那个 0.85——而这个 0.85 恰好是我漏掉优惠券分摊的 bug。测试从一个保护网变成了一份 bug 的固化记录。
我把这个现象量化了一下,用同一个模块(退款计算 + 优惠券分摊,62 行)试三种喂法,任务固定为「补全单测」:
喂给模型的材料 | 用例数 | 行覆盖率 | 12 个变异体杀死数 | 改坏函数后报红 |
|---|---|---|---|---|
整个 src/ 目录(含实现) | 37 | 88% | 1 / 12 | 0 次 |
只有接口文件 + docstring | 37 | 81% | 8 / 12 | 9 次 |
接口契约 + 明确要求断言错误类型 | 41 | 84% | 9 / 12 | 11 次 |
覆盖率第一行最高,保护力最弱。这个反差是整件事的关键:行覆盖率衡量的是「代码被执行过」,不是「行为被规定过」。
按出现频次排序,模型生成的 41 个用例里有这些形态:
断言形态 | 出现次数 | 后果 |
|---|---|---|
断言与实现里的常量相等 | 12 | 实现错则测试一起错,等于没测 |
只断言「不抛异常」 | 9 | 返回 None 也算通过 |
边界只测 0 和正数 | 7 | 负数、空集合、超限全部漏掉 |
浮点用 | 4 | 本地通过,CI 偶发失败 |
断言 message 精确匹配 | 3 | 改文案就挂,维护成本白涨 |
第一条是致命的,其余的是噪音。所以我的做法改了:不给实现,只给契约。
具体做法是把 src/ 从模型的可见范围里摘掉,只给一个 interface.py,里面是函数签名加 docstring,docstring 里写清业务规则:
def refund_rate(order: Order) -> float:
"""计算订单退款率。
规则:
- 退款率 = 退款件数 / 总件数,分母是件数不是订单数
- 赠品件数计入分母,不计入分子
- 空订单返回 0.0,不抛异常
- 优惠券只影响金额,不影响退款率
"""同样是 37 个用例,这版对变异体的杀伤从 1 提到 8。
下面两个用例的差别,是整篇文章我想说的东西。这是模型在「看得见实现」时写的:
# 反面:断言的是实现的输出,不是业务的定义
def test_refund_rate_with_coupon():
calc = RefundCalculator()
order = make_order(total_items=100, refunded_items=15, coupon=True)
# 0.85 这个系数是实现里的魔法数,被原样抄进断言
assert calc.rate(order) == 0.15 * 0.85这是屏蔽实现、改成规格断言后的版本:
# 正面:断言业务定义,实现怎么算都无所谓
import pytest
def test_refund_rate_with_coupon():
calc = RefundCalculator()
order = make_order(total_items=100, refunded_items=15, coupon=True)
# 规格第四条:优惠券不影响退款率
assert calc.rate(order) == pytest.approx(0.15)
def test_refund_rate_excludes_gifts_from_numerator():
calc = RefundCalculator()
order = make_order(total_items=100, refunded_gifts=20, refunded_paid=10)
# 规格第二条:赠品计入分母不计入分子
assert calc.rate(order) == pytest.approx(0.10)
def test_refund_rate_empty_order():
# 规格第三条:空订单返回 0.0 而不是抛异常
assert RefundCalculator().rate(make_empty_order()) == 0.0三个用例对应 docstring 里的三条规则,一一映射。这样写出来的测试不会随实现变化而失效,改实现的时候它还守着原来的规格。
覆盖率骗人这件事,光靠看是看不出来的。我现在固定的验收流程是两步:
# 第一步:跑测试 + 覆盖率,只看有没有漏测的整块分支
pytest tests/ -q --cov=src --cov-report=term-missing
# 41 passed in 41.28s
# Name Stmts Miss Cover Missing
# src/refund.py 62 5 92% 41-45# 第二步:注入变异体,看有几个被抓住
mutmut run --paths-to-mutate src/refund.py
mutmut results
# survived: 3 killed: 9 total: 12两个数字要一起看:覆盖率 92% 说明「代码基本都跑到了」,变异杀死率 9/12 说明「跑到了但没验住」。剩下 3 个存活的变异体我逐个看了,两个是 >= 改 > 的边界,一个是在优惠券分支里改常数——正好对应上面表格里「边界只测 0 和正数」和「复刻常量」两类。让模型针对这 3 个变异体定向补用例,第 4 轮才到 11/12。
这个循环我在独立 IDE 里跑,pytest 和 mutmut 的输出来回切换时不用重开终端;不过说白了这一步用任何能跑命令的环境都一样,工具不是重点,重点是「改坏了必须报红」这条验收标准。我平时用的是 PRIVO Code,它的批处理能在一条会话里连续跑三轮「补用例—跑变异—看存活」而不掉上下文。
三处,都不太光彩:
src/ 整个目录给了模型,37 个用例全绿,我以为成了。注入变异体之后 12 个里只抓到 1 个,剩下 11 个改坏了比较符它照样通过。后来才想明白:它读的是实现,写的是实现的翻译件。== 比较浮点结果,本地 37 次通过,CI 上偶发失败,我查了两小时以为是环境差异,最后发现是 0.1 + 0.2 的老问题。模型不会主动想起 pytest.approx,除非你在提示里写死。assert result, "退款率应为 0.15",那行中文是错的(实际值是 0.085)。断言信息误导了我一次,后来我改成要求断言信息里带实际值:f"期望 0.15,实际 {rate}"。让模型补单测,正确的心智模型是:让它把规格翻译成断言,而不是把代码翻译成断言。
判断有没有做对,不用看覆盖率,看两个动作——把实现藏起来还能不能写出来,把实现改坏之后测试会不会红。两个都能过,这批用例才有留下的价值;只过一个,就是给自己攒了一份需要持续维护的假安全感。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。