重构前先证明测试能抓住错误
文章要求新增行为刻画测试至少识别一次人为代码变异,例如修改比较边界、替换默认值或移除舍入。通过少量针对性变异,检查测试是否真正约束重构涉及的行为,而非仅让代码执行一遍。
AI 深度解读
重构前补了测试,改完全部通过,代码就安全了吗?作者提醒:有些测试即使面对分支被删除、边界被改坏,也照样亮绿灯。测试执行过某行代码,不代表它能发现那行代码的行为变化。
他的办法很直接:先故意改坏代码,确认新增测试会失败,再开始重构。这篇值得看的地方,是把「测试有没有保护作用」变成了一次可以运行的检查。不过,检查脚本和结果解释都有边界,不能把它当成正确性证明。
测试通过之前,先证明它能失败
作者针对的是特征测试(characterization test):在整理旧代码前,先记录当前行为,避免重构时无意改变调用方依赖的结果。它回答的是「现在怎么运行」,未必回答「业务上应该怎样」。
问题出在断言太松。例如只检查结果不是 None,即使金额舍入、列表顺序或默认值发生变化,测试仍可能通过。覆盖率也解决不了这个问题,因为执行到一行代码,和检查这行代码造成的结果,是两回事。
作者提出一条规则:每个新增特征测试,至少要识别一个故意引入的变异。所谓变异,就是把 >= 改成 >、替换默认值、删掉舍入调用这类小改动。测试因此失败,才算「杀死」这个变异。
绿色结果只能证明测试通过,故意改坏后出现的失败,才能证明它观察到了某种变化。
范围也要收小。先写清准备保护的函数、公开方法或序列化数据,再明确哪个调用方必须继续正常工作。作者建议每个边界手写 2–6 个变异,无须一开始给整个文件打分。
先固定输入,再记录代码真正返回什么
如果时间、随机 ID 和环境变量每次都不同,变异前后的差异就难以解释。作者建议把这些输入收口到可替换的模块级函数,再通过 monkeypatch 固定。
原文固定了 _now 返回的 2026-03-01T00:00:00Z、_new_id 返回的 id-fixed-001,以及环境变量 BILLING_ROUNDING=half_up。随后连续运行目标测试 5 次,只要结果不同,就先解决确定性问题。
接下来运行现有实现,把真实输出写入断言。这段测试用来锁住指定输入下的完整返回值:
def test_partial_month():
out = prorate.compute(days_left=1, cents=1234)
assert out == {"amount_cents": 1234, "label": "1 day"}
要看的是第二行断言:金额和文案都被固定,保护范围比「返回值存在」具体得多。但这仍只是当前行为的记录,不能由此推断计费规则正确。作者也明确说,这些基准值可以作为临时支架,行为得到文档说明后再删除或重新推导。
然后,为调用方真正关心的差异编写变异:
MUTANTS = [
{"id": "M1", "file": "billing/prorate.py",
"old": "if days_left >= 1:", "new": "if days_left > 1:",
"target": "tests/test_prorate.py::test_partial_month"},
{"id": "M2", "file": "billing/prorate.py",
"old": "return round(cents, 2)", "new": "return round(cents, 0)",
"target": "tests/test_prorate.py::test_partial_month"},
]
这里要留意 old 和 target:前者必须在源码中恰好匹配一次,后者必须准确选中测试。匹配零次或多次属于 bad-pattern,不能算作变异存活。原文没有提供完整业务实现,也没有展示这两个变异实际运行后的结果。
结果分类,比写几个替换规则更关键
作者给出的执行器会复制仓库,在临时目录里应用一个变异,然后只运行对应的测试节点。调用形式是 sys.executable -m pytest -q --no-header,通过 subprocess.run 设置 cwd=work、capture_output=True、text=True 和 timeout=300。
按原文分类,退出码 0 是 survived,1 是 killed,5 是 no-tests;2、3、4 分别涉及中断、内部错误和用法错误,统一归入 error。其中 5 尤其容易漏看:测试根本没选中,当然不能证明任何保护作用。
存活结果也不能一律靠补断言解决。如果改动确实改变了调用方能观察到的行为,就收紧断言;如果属于等价变异,也就是改了源码却没有可观察差异,应从列表删除并说明原因。
重构时,作者建议一个提交只动一个边界,前后运行完全相同的变异集合,并比较被杀死的集合。他把新增或丢失的击杀都视为需要调查的信号。
AI 在这里负责提出候选输入、断言和变异。最终裁判仍是运行结果。原文提到 MonkeyCode 的免费模型和免费服务器,同时披露文章属于其产品推广;没有提供模型对比、配额保证或效率实测。
我的判断
我赞成用少量手写变异检查高风险边界。相较覆盖率,它问得更具体:如果这里发生某种错误,测试能否发现?相较直接给整个仓库跑变异测试,它也更容易控制耗时。
但原文脚本标注为未执行的脚手架,不能直接当成可靠工具。尤其是退出码 1,只代表 pytest 出现测试失败,不能单凭它确认是预期断言识别了行为变化。原文要求语法错误算执行器问题,脚本却没有进一步核验失败原因;timeout=300 触发后的异常也未处理。
「前后击杀集合一致」适合作为调查入口,不能机械认定新增击杀就是业务行为被改坏。文本替换可能在重构后失效,失败也可能来自导入或环境问题,需要结合具体输出判断。
这套方法证明的是测试能观察某些变化,不是现有行为正确,更不是重构已经安全。
作者没有给出缺陷拦截率或耗时对照,因此「比人工审大 diff 更便宜」仍是经验判断。它适合保留行为的局部重构;重写边界,或单次测试已经耗时数分钟时,作者也建议跳过。
能拿来做什么
- 重构前端或 Node 的旧逻辑时,先选一个明确的调用边界,记录实际输出,再针对边界值、默认值或排序设计 2–6 个小变异。
- 遇到时间、ID、环境变量参与结果计算,先固定输入并连续运行测试 5 次,确认稳定后再评估断言是否有效。
- 让 AI 同时阅读函数和调用方,要求它为每个边界输入提出一处应改变输出的单行修改,再逐项运行验证。
- 接入检查脚本前,先补齐失败原因核验、无测试匹配和超时处理,并保留失败输出,让每次「杀死变异」都有可检查的依据。