重构前锁定行为,再用变异验证测试
文章建议先用接近真实业务的 JSONL 输入建立固定行为快照,再故意引入缺陷,确认测试能够识别变化后才开始小步重构。对于依赖时间、网络或随机数的函数,应先隔离这些依赖,避免快照失去稳定性。
AI 深度解读
重构老代码,最难的往往不是改写逻辑,而是回答:那些没人写进测试的行为,到底有没有变?作者给出的办法是先冻结一组输入及其输出,再故意制造错误,验证这套检查确实能报警,最后才动代码。
这套流程适合处理输入可复现、职责较小的函数。它的价值不只在于保存快照,更在于用变异测试检查快照的盲区。不过,原文提供的是方法和示例,没有实测对照数据;附带的 Python 脚本也有需要补齐的地方。
先把旧行为钉住,再讨论怎么改
作者把第一步限定得很窄:选一个函数,而不是一个文件。目标最好输入面小、没有隐藏全局变量,能够从脚本直接调用,并把选择理由写进提交信息。
如果函数依赖当前时间、网络或随机数,应先建立可替换的依赖入口。比如把时间作为参数注入,再记录包装函数的行为。否则第二次运行就可能得到不同结果,快照无法区分环境波动和代码变化。
输入用 corpus.jsonl 保存,每行一个案例,包含稳定的 id 和关键字参数 kwargs。来源可以是已有 fixture、仓库调用点,以及已经脱敏的日志载荷。不能带入客户数据、token 或邮箱。
作者认为,许多小函数可以从 20–40 个案例起步。这是经验建议,不是覆盖率保证。空字符串、零、负数、缺失字段都应纳入,因为重构同样可能改变错误路径。原文还提到重复 id,但后面的实现并不能可靠保留这种情况,后面会谈到。
输出也不能直接拿来比较。作者的 normalize 会按字符串形式的键排序字典,把列表和元组统一成列表,把集合和不可变集合排序,并将浮点数舍入到小数点后 9 位。普通对象尝试通过 vars(value) 提取属性;失败时,退回 __type__ 和空的 __attrs__。
这些规则是在定义「哪些差异可以忽略」。浮点数舍入会吞掉微小变化;只有 slots、没有实例属性字典的对象可能丢失全部内部状态。作者要求把这些取舍写进测试脚本头部,这一点很实用:后来维护的人需要知道,绿灯究竟覆盖了什么。
快照要保存异常,也要证明自己能报警
下面这段是检查的核心:调用目标函数,把正常结果或异常转换为稳定载荷,再计算指纹。
def fingerprint(mod, fn_name, case):
fn = getattr(mod, fn_name)
try:
payload = {"ok": True, "out": normalize(fn(**case["kwargs"]))}
except Exception as exc:
payload = {"ok": False, "err": type(exc).__name__,
"msg": str(exc)}
blob = json.dumps(payload, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(blob.encode()).hexdigest(), payload
要关注 normalize(fn(**case["kwargs"])) 这一行:比较对象是归一化后的结果。异常则固定类名和 str(exc);如果消息含时间、路径或动态标识,仍会漂移。原文后面建议归一化异常消息,但这段代码尚未实现。
完整脚本通过 importlib.util.spec_from_file_location 加载模块,把每个案例的 sha256 和 payload 一起写入 golden.json。保留载荷方便排查,哈希用于判断变化。
命令接口包含必填的 --target、--function,以及 --corpus、--golden、--mode。其中语料和基线默认是 corpus.jsonl、golden.json,模式可选 write、check,默认 check。
作者要求先写入,再立即检查一次,确认基线能够稳定复现:
python char_harness.py --target ./src/pricing.py --function normalize_offer \
--corpus corpus.jsonl --golden golden.json --mode write
python char_harness.py --target ./src/pricing.py --function normalize_offer --mode check
第二条命令使用了默认语料和基线路径。第一次运行成功,只说明文件写出来了;第二次仍然一致,才说明这批输入至少没有立即暴露不稳定因素。
接下来故意改坏代码。例如把 if qty >= 1: 改成 if qty > 1:,只改变数量等于 1 时的边界行为。每个变异保存成独立的小补丁,逐个应用、检查、恢复。
输出分为三类:CAUGHT 表示检查失败,错误被发现;MISSED 表示检查仍通过,语料可能没覆盖对应行为;SKIP 表示补丁无法应用或变异后无法导入,不能算覆盖证据。
快照能通过,只说明当前结果一致;故意改坏之后能失败,才证明它对这类错误有反应。
作者建议用 MISSED 驱动新增案例,再重新固定基线和探测。仍然存活的变异,需要人工判断是否与原实现等价,不能仅凭测试通过下结论。
重构期间保持零漂移,完成后再测一次变异
建立基线并验证报警能力后,才开始重命名、提取、内联或移动代码。每次提交后运行检查;如果哈希变化,作者建议回退、拆小,再尝试。
这里的「零漂移」,准确范围是已记录案例经过归一化后的行为。它无法保证所有输入都不变。
原文给了 ok: 34 cases unchanged 的输出示例,并提到 34 个案例、9 个变异的展示规模,明确说明这不是实测基准。不能拿它推断实际项目的覆盖率或耗时。
作者还用 git diff --numstat | awk '{a+=$1; d+=$2} END {print "changed:", a+d; exit ((a+d) > 40)}' 限制一次修改的新增与删除总行数不超过 40。这个阈值用于控制审查范围,并不代表 40 行以内就安全。
另一条约束更关键:凡是更新 golden.json 的提交,提交信息必须包含 BEHAVIOR:,解释为什么接受行为变化。否则,开发者可能顺手刷新快照,把回归一起保存下来。
重构完成后,还要重跑变异探测,原先被捕获的变异仍应被捕获。不过移动或重命名可能让旧补丁失效,出现 SKIP 时需要维护补丁,不能把它当作通过。
我的判断
我认同作者把「验证测试会失败」放在重构之前。和继续补手写断言相比,这种方法更容易快速记录复杂返回值及异常;手写测试则更适合表达明确的业务规则,两者可以配合使用。
它也有一个天然代价:旧行为中的 bug 会一起被固定。快照回答的是「是否发生变化」,正确性仍要由需求、业务断言和人工审查决定。
示例脚本还有几处边界。结果按 id 建字典,重复编号会覆盖前面的案例;检查只遍历已有基线,不会拒绝新增但尚未固定的案例。变异循环则把检查命令的非零退出都计为 CAUGHT,检查工具自身报错也可能制造假阳性。实际接入时,应分别处理输入校验、工具故障和行为差异。
原文披露了 MonkeyCode 产品推广合作。「免费模型访问」和「免费服务器」属于提供方的可用性声明,没有性能或成本对照。让模型起草归一化分支、比较运算符、默认值、分支及校验路径变异,是合理用途;让模型判断门禁是否通过,则缺少可靠依据。
最后,函数不能无副作用地导入、输入无法合法冻结,或者主要风险来自安全与并发时,这套方案并不合适。没有人持续维护变异集,也很难长期相信它的结果。
能拿来做什么
- 整理 Node 项目中的纯业务函数时,可以先从参数转换、价格规则或配置归一化里挑一个小目标,收集真实形状的脱敏输入,同时固定成功与异常结果。
- 使用 AI 辅助重构时,可以让模型提出边界条件和候选变异,再由人确认每个变异确实改变行为,并检查它能否被现有语料捕获。
- 把基线更新纳入代码审查,要求写明
BEHAVIOR:;同时检查重复编号、新增案例,以及归一化是否隐藏了本次关注的差异。 - 保存
MISSED清单,用它安排下一批案例;对补丁失效、导入失败和检查工具报错分别处理,避免用一串绿灯替代有效证据。