测试与代码质量

为遗留代码添加测试:重构前必须做的事

对于遗留代码,应该先用特征测试固定当前行为,再进行重构。本篇还以实践流程整理了如何通过接缝隔离因依赖而无法测试的代码。

3 分钟阅读
为遗留代码添加测试:重构前必须做的事 封面图

“这段代码一动就不知道哪里会崩,所以没法改。”

面对遗留代码,大家至少都说过一次这句话吧。

先说结论。重构前唯一要做的事,就是先创建**“原样锁定当前代码行为的测试”**。

在把代码改得更漂亮之前,先明确它现在到底做了什么。

今天我会完整讲解自己给真实遗留代码添加测试时遵循的顺序。只要按顺序操作,就能大幅减少“改着改着出问题”的事故。


为什么测试要先于重构?

先从重构的定义说起。

重构是在不改变外部可见行为的前提下,只改善内部结构。

这里的关键是“保持行为不变”这一约定。

如何确认遵守了这个约定?靠测试。

没有测试,我们只能凭“好像没变”的感觉发布。凭感觉发布支付代码……光想想就让人害怕。

因此 Michael Feathers 在《修改代码的艺术》中这样定义:“遗留代码就是没有测试的代码”

判断标准不是代码是否老旧,而是有没有安全网。


重构前检查清单(重点总结)

为了方便忙碌的读者,先整理一下顺序。

  1. 先确定要修改代码的范围(边界)
  2. 添加记录当前行为的特征测试
  3. 确认测试显示绿灯(成功)
  4. 然后再按小单元进行重构
  5. 每一步都重新运行测试

按顺序完成这五件事,就已经成功了一半。下面逐一展开。

显示 PASSED 绿色测试条的代码编辑器,以及贴满便利贴的显示器
亮起一盏绿灯的瞬间,心里就踏实了

如何添加特征测试?

这是最常被问到的问题:“不了解行为,该写什么测试?”

这里需要反过来思考。不是知道正确答案后再写,而是直接把代码当前输出的结果固定为正确答案

这叫作特征测试(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分钟就能避免最糟糕的事故。

问:测试写得很乱也没关系吗?

没关系。特征测试就像临时脚手架。重构完成、代码变干净后,再顺手整理测试即可。

在显示 TEST OK 勾选标记的屏幕前敲击机械键盘的双手
只包住一个函数,心里就会轻松很多

归根结底就是一个顺序:先固定(测试)→再修改(重构)→再确认。

遗留代码令人害怕,不是因为代码糟糕,而是因为没有安全网。今天就挑一个函数,添加特征测试吧。从那以后,动手会轻松很多。为你加油!

延伸阅读