“这段代码一动就不知道哪里会崩,所以没法改。”
面对遗留代码,大家至少都说过一次这句话吧。
先说结论。重构前唯一要做的事,就是先创建**“原样锁定当前代码行为的测试”**。
在把代码改得更漂亮之前,先明确它现在到底做了什么。
今天我会完整讲解自己给真实遗留代码添加测试时遵循的顺序。只要按顺序操作,就能大幅减少“改着改着出问题”的事故。
为什么测试要先于重构?
先从重构的定义说起。
重构是在不改变外部可见行为的前提下,只改善内部结构。
这里的关键是“保持行为不变”这一约定。
如何确认遵守了这个约定?靠测试。
没有测试,我们只能凭“好像没变”的感觉发布。凭感觉发布支付代码……光想想就让人害怕。
因此 Michael Feathers 在《修改代码的艺术》中这样定义:“遗留代码就是没有测试的代码”。
判断标准不是代码是否老旧,而是有没有安全网。
重构前检查清单(重点总结)
为了方便忙碌的读者,先整理一下顺序。
- 先确定要修改代码的范围(边界)
- 添加记录当前行为的特征测试
- 确认测试显示绿灯(成功)
- 然后再按小单元进行重构
- 每一步都重新运行测试
按顺序完成这五件事,就已经成功了一半。下面逐一展开。
如何添加特征测试?
这是最常被问到的问题:“不了解行为,该写什么测试?”
这里需要反过来思考。不是知道正确答案后再写,而是直接把代码当前输出的结果固定为正确答案。
这叫作特征测试(Characterization Test)。
方法出乎意料地简单。先随便填一个值并运行测试。测试失败时会告诉你“实际值就是这个”,把该值原样粘贴进去就完成了。
// 1) 因为不知道实际返回值,所以故意填入错误的值
@Test func 折扣计算_当前行为() {
let result = calcDiscount(user: user, cart: cart)
#expect(result == 0) // 失败并告知实际值
}
// 2) 固定失败消息中显示的实际值(例如: 1500))
// #expect(result == 1500)
现在,这个测试就成了守护“这段代码原本返回1500”这一事实的哨兵。
之后重构时如果误返回1200,测试会立刻亮红灯提醒你。
现在还不用判断代码好不好。目标只是先固定它当前的样子。
无法添加测试的代码该怎么办?
这才是遗留代码真正的高墙。当 DB、外部 API、当前时间等无法控制的东西嵌在函数中间时,测试就无法运行。
这时,Feathers 提出的**“接缝(Seam)”**概念很有帮助。稍微切断代码流程,创建可以插入伪值的位置。
最安全的方法,是只把必要的一行提取为函数参数。
例如,如果函数直接调用현재시간(),只需改成通过参数接收它。这样就能在测试中传入想要的时间。
需要注意的是,为了添加测试而进行的这点最小修改,也要尽量机械、谨慎地完成。因为这里还没有安全网。
常见问题(Q&A)
问:开始前要把覆盖率做到多少?
不需要覆盖全部。只要包住现在要修改的部分,也就是那个边界,就足够了。100%很多时候不是目标,而是陷阱。
问:没有时间添加测试怎么办?
我很清楚那种压力。这时只要先用特征测试包住需要修改的一个函数,再开始动手。5分钟就能避免最糟糕的事故。
问:测试写得很乱也没关系吗?
没关系。特征测试就像临时脚手架。重构完成、代码变干净后,再顺手整理测试即可。
归根结底就是一个顺序:先固定(测试)→再修改(重构)→再确认。
遗留代码令人害怕,不是因为代码糟糕,而是因为没有安全网。今天就挑一个函数,添加特征测试吧。从那以后,动手会轻松很多。为你加油!

