关于 working-with-legacy-code
这是一份面向开发者的未测试遗留代码改造手册,内容源自 Michael C. Feathers 的著作。它提供逐步建立测试、隔离依赖和验证改动的方法,避免把重写作为默认选择。
主要功能
- 01从测试点到重构的改动步骤
- 02依赖接缝与启用点
- 03特征测试与黄金主副本
- 04Sprout 与 Wrap 技术
- 05打破依赖的技术目录
适用场景
- 01为未测试模块建立测试框架
- 02安全修复遗留代码中的缺陷
- 03将重构与行为变更分开
- 04测试依赖数据库、时钟或 I/O 的代码
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ wondelai/ skills --skill working-with-legacy-code安装后在 Claude Code 中输入/working-with-legacy-code确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
references/case-studies.mdchange-algorithm.mdcharacterization-tests.mddependency-breaking.mdseams.md详细介绍
高效处理遗留代码
一份关于如何修改没有测试的代码的实战手册,内容提炼自 Michael C. Feathers 的 Working Effectively with Legacy Code。使用它将无法测试的类纳入测试框架,通过特征测试锁定当前行为,并一次迈出一个安全、可验证的步骤来修改代码,而不必诉诸重写。
核心原则
遗留代码就是没有测试的代码。 不是旧代码,也不是难看的代码,而是未经测试的代码:没有测试,你就无法知道修改是否保留了原有行为,因此每次编辑都是一场赌博。诀窍是在修改任何内容之前,适度打破依赖,以便先编写测试——先覆盖再修改,绝不盲目编辑后祈祷。
评分
目标:10/10。 根据以下原则,为对未测试代码的修改打 0 到 10 分。报告当前分数,以及达到 10/10 所需的具体步骤。
- 9-10: 在编辑前,先用特征测试覆盖修改点;行为变更和重构分成独立且经过验证的步骤;以侵入性最低的方式打破依赖
- 7-8: 大多数修改点都有测试,但偶尔会在同一提交中混合重构和行为变更,或进行了不必要的大幅依赖改造
- 5-6: 编写了一些特征测试,但仍凭感觉修改关键路径;新增代码不断累积,却没有后续回报计划
- 3-4: 盲目编辑后祈祷,再通过手动验证;修改后才编写测试,并断言新代码碰巧实现的行为
- 0-2: 直接在纠缠不清的代码中进行未经测试的修改;在同一提交中混合重构和行为变更;不写测试而提议重写
框架
1. 遗留代码困境与修改算法
核心概念: 困境在于:要安全地修改代码,我们需要测试;但要让测试到位,我们就得修改代码。解决之道是遵循固定顺序——识别修改点、找到测试点、打破依赖、编写测试,然后进行修改和重构——其中测试前的编辑应当保守且机械,真正的修改则只在安全网内进行。
为何有效: 盲目编辑后祈祷,是用细心代替反馈,而对于自己并未完全理解的代码,光靠细心无法规模化。先覆盖再修改,用测试牢牢锁定现有行为,这样任何非预期变更都会立即在你的机器上暴露,而不是等到上线后才发现。
关键见解:
- 修改代码有两个原因——改变行为(功能、缺陷修复)和改进结构(重构)。将两者混在同一步中,会让故障无法诊断
- 测试点很少就是修改点:影响会传播,因此你通常要在修改产生影响的地方进行测试,而不是在编辑发生的地方
- 在测试尚未就位前,为打破依赖而进行的编辑必须准确保留签名,并依靠编译器找出所有受影响的位置
- 覆盖率会随着你实际修改的路径而增长——这胜过任何从未获批资源的专门“测试项目”
- “编程是一种一次只做一件事的艺术”:算法中的每一步都可以单独验证
应用场景:
| 场景 | 应用 | 示例 |
|---|---|---|
| 修复未经测试模块中的缺陷 | 修改缺陷前,先执行五个步骤 | 先用测试固定 parseInvoice() 的行为,再修复舍入误差 |
| PR 混合了清理工作和功能 | 拆分为仅改结构和仅改行为的提交 | 先提取并重命名,确认测试通过,再添加折扣规则 |
| “这只是改一行” | 先找到最近的测试点 | 在调用你要修改的私有方法的公共方法上,添加一个固定行为的测试 |
在真实修改中执行这五个步骤时,请参阅 references/change-algorithm.md——其中将算法整理成可操作的流程,包含修改点/测试点检查清单,以及“没时间”情形下的分类处理方法。
2. 接缝:从何处撬开代码
核心概念: 接缝是程序中一个可以在不直接编辑该处代码的情况下改变行为的位置。每个接缝都有一个启用点——你在此处决定运行哪种行为。让遗留代码通过测试,很大程度上就是寻找接缝:在这些位置,测试可以替换缓慢的、全局的或外部的依赖,同时保持生产源代码不变。
为什么有效: 如果必须修改代码才能测试,就有可能改变你正试图确定的行为。接缝会把替换操作移到别处——例如子类、导入或构建标志——这样,被测代码就能像在生产环境中一样运行,而测试则可以从启用点控制其依赖项。
关键要点:
- 对象接缝是面向对象代码中的默认选择:每个可重写的调用都是一个接缝,其启用点就是创建或传入对象的位置
- 链接接缝和导入接缝会在构建或加载时替换实现——
jest.mock和unittest.mock.patch是披着现代外衣的链接接缝 - 预处理接缝(C/C++ 宏)是最粗暴的工具;最后再考虑使用它们
- 方法体内的
new Database()是一个尚未构建好的接缝——执行实际工作的构造函数、全局变量、静态成员和硬编码的 I/O 都是接缝消亡之处 - 无法访问启用点的接缝毫无用处:如果测试无法做出选择,就继续寻找
- 动态语言几乎让每次名称查找都成为接缝——成本低,但打补丁修改内部实现会让测试依赖文件布局
应用场景:
| 场景 | 应用方式 | 示例 |
|---|---|---|
| 类自行构造数据库客户端 | 通过构造函数参数创建对象接缝 | constructor(db: Db = new ProdDb()) — 测试传入一个假对象 |
模块调用顶层的 send_email() | 导入/链接接缝 | mocker.patch("billing.send_email") 或 jest.mock("./mailer") |
| 逻辑直接读取系统时钟 | 在时钟处设置接缝 | 注入一个 now() 提供程序;测试中冻结时间 |
在特定技术栈中寻找接缝时,请参阅 references/seams.md——其中包含接缝目录,列出了代码、启用点以及现代工具中的接缝(DI 容器、jest.mock、pytest monkeypatch、时钟和配置接缝)。
3. 特征测试
核心概念: 特征测试记录代码当前的实际行为,而不是规范、注释或任何人记忆中它应该有的行为。编写一个你确定会失败的探测测试,让失败消息揭示真实行为,然后修改断言,将该行为固定下来。
为何有效: 在遗留系统中,实际行为就是事实上的规范:调用方、报告和客户可能都依赖它,包括其中的种种怪癖。根据臆想的需求编写的测试会因各种原因失败,却无法提供任何有用信息;而特征测试在重构期间失败时,能准确指出你改变了现有行为的时间和位置。
关键要点:
- 操作步骤:在测试框架中调用代码,断言一个荒唐的结果(
expect(total).toBe(-1)),查看失败信息,再固定实际观察到的值 - 感知和分离是打破依赖关系的两个原因:分离让代码能在测试框架中运行,感知让断言能看到代码计算出的结果
- 对于复杂输出(报告、生成的文件、大型 JSON),使用黄金样本:捕获一次完整输出,之后始终与之进行差异比较
- 快照测试就是黄金样本——像审查代码一样审查第一份快照,并规范易变数据,否则你固定的只是噪声
- 特征测试时发现了 Bug?添加注释和工单,将其固定下来——下游代码可能依赖错误行为;之后再将修复作为独立、经过深思熟虑的更改进行
- 只对更改将触及的分支进行特征测试,而不是整个系统——覆盖范围应跟随更改
应用场景:
| 场景 | 应用方式 | 示例 |
|---|---|---|
| 重构税费计算器 | 固定有代表性的输入所对应的输出 | 运行 20 个案例,并断言每个记录下来的结果 |
| 遗留报告生成器 | 使用黄金样本进行差异比较 | 生成报告,并与已纳入版本控制的黄金样本文件进行比较 |
| 固定行为时发现差一问题 | 固定错误值,并记录说明 | assert days == 30 # BUG? expected 31 — TICKET-482 |
编写第一个探测测试并逐步构建固定行为的测试套件时,请参阅 references/characterization-tests.md,了解黄金样本、正确编写快照测试,以及一个完整的重构前后示例。
4. Sprout 和 Wrap:先不写测试时的变更方式
核心概念: 如果今天确实无法测试某个区域,就不要把新逻辑编织进未经测试的代码中。Sprout Method 或 Sprout Class:将新行为写成全新的、经过完整测试的代码,并在遗留代码位置用一行代码调用它。Wrap Method 或 Wrap Class:将旧代码重命名并放到一旁,然后像装饰器一样,在调用旧代码之前或之后添加行为。
为何有效: 即使无法在测试框架中实例化宿主,新方法或新类中的代码仍可通过测试驱动开发来编写,因此可测试性不再取决于能否将宿主放入测试框架。未经测试的宿主只改动一个调用点,因此未经验证的影响范围只有一行代码,而不是整个方法。
关键要点:
- 新逻辑只需在一个位置接入时使用 Sprout Method;宿主类甚至无法在测试框架中实例化时使用 Sprout Class
- 当行为围绕旧代码展开(如日志记录、通知、计量),而非与其混合时,适合使用 Wrap Method:将
pay()重命名为rawPay(),再将pay()重新创建为包装方法 - Wrap Class 是 Decorator 模式——当多个调用点都需要新增行为,或类已经过于臃肿时使用
- 坦诚面对这种权衡:宿主仍未经测试;你只是在糟糕的环境中添加了优质代码
- 将 sprout 作为债务跟踪并偿还——下次修改涉及宿主时,为宿主补上测试覆盖
- Sprouting 是变更算法中的一种战术手段,并非让代码通过测试的永久替代方案
应用场景:
| 上下文 | 应用方式 | 示例 |
|---|---|---|
400 行 process() 中的滞纳金规则 | Sprout Method,一行调用代码 | total += lateFee(order)——lateFee() 采用测试优先方式编写 |
在遗留 pay() 周围添加审计日志 | Wrap Method | 新的 pay() 记录日志、调用 rawPay(),然后再次记录日志 |
| 新增验证,但类无法实例化 | Sprout Class | 从遗留代码中调用 new OrderValidator().validate(data) |
5. 打破依赖的技巧
核心概念: 一套机械、低风险的操作,用来切断所有阻碍实例化或感知的因素:提取接口、参数化构造函数、参数化方法、提取并覆盖工厂方法或 Getter、引入实例委托器、适配参数、拆分出方法对象、继承并覆盖方法。由于这些操作会在测试尚不存在时进行,因此始终选择能解除阻碍的最小侵入性技巧。
为何有效: 代码难以测试,通常是因为少数几种反复出现的原因——构造函数执行实际工作、静态方法和单例、无法构造的参数、庞大的方法。每种阻碍都有一个明确且经过实践的应对方法,因此你可以执行已知的操作,而不必在没有安全网的代码上临时动手术。
关键见解:
- 带有生产环境默认值的参数化构造函数是主力方法:现有调用方无需修改即可编译,同时测试可以注入伪对象
- 提取接口是书中最安全的操作——引入接口不会改变行为,只会放宽类型约束
- 继承并覆盖方法是本目录中半数技巧的基础:用于替代危险部分的测试子类是正当工具,而非 hack
- 对于静态方法和单例,引入实例委托器可让调用方获得可替换的实例;静态 setter 也可在测试中取代单例
- 适配参数比对抗无法伪造的框架类型更好——用你自己的窄接口包装
HttpServletRequest,并针对该接口进行测试 - 动态语言更容易建立接缝:
unittest.mock.patch或jest.mock可以替代多种技巧,但参数化能留下更好的设计
应用场景:
| 上下文 | 应用方式 | 示例 |
|---|---|---|
| 构造函数打开数据库连接 | 参数化构造函数 | def __init__(self, conn=None): self.conn = conn or connect() |
到处都调用静态方法 Billing.charge() | 引入实例委托器 | 实例 charge() 委托给静态方法;测试中覆盖它 |
| 900 行的方法囤积局部变量 | 拆分出方法对象 | new RateCalculation(order, rates).run() — 局部变量变为字段 |
当某个具体阻碍妨碍实例化或感知时,请参阅 references/dependency-breaking.md——其中提供了每种技巧的前后代码示例,以及将阻碍映射到相应操作的决策表。
6. 梳理与理解
核心概念: 修改不了解的代码之前,先投入少量时间理解代码:影响草图追踪改动可能波及的范围;特征草图展示方法和字段如何在上帝类中聚集;试探性重构是为了学习而大胆重构,然后丢弃修改;讲述系统的故事则迫使你做出简化的总结。这样做的回报是找到瓶颈点——少量测试就能覆盖广泛行为的狭窄位置。
为什么有效: 在遗留代码中,瓶颈在于理解,而非打字。影响草图能把“这可能会破坏什么?”从焦虑转变为有限的清单;瓶颈点则能让少数测试像虎钳一样覆盖整组方法,通常还能揭示隐藏的类边界应该划在哪里。
关键见解:
- 影响草图:每个变量或方法画一个气泡,每个“影响”关系画一条箭头——从改动点向前追踪,直到行为可能泄漏出去的所有位置
- 瓶颈点是影响草图中的收窄处;在此处测试,就能覆盖其上游的一切
- 试探性重构是一种阅读技巧:提取、重命名并简化一个小时,然后还原——检出的代码虽然恢复原样,但获得的见解仍然留存
- 巨型方法策略:在瓶颈点为其建立黄金主测试,然后提取方法对象,再在新类中重构
- 上帝类策略:绘制特征草图,找出各组特征,再沿着它们之间的自然边界提取
- 没有时间时的分诊策略:某处第一次改动时,采用萌芽或包装;同一处再次改动时,就该为它编写测试了
应用场景:
| 场景 | 应用 | 示例 |
|---|---|---|
| “如果我改了这个字段,什么会出问题?” | 从该字段向外绘制影响草图 | 找到 3 个读取位置;2 个瓶颈点测试就能覆盖它们 |
| 在 5,000 行的类中添加功能 | 先编写瓶颈点测试,再萌芽 | 覆盖 postInvoice(),将新规则萌芽为一个类 |
| 团队中没人理解的代码 | 在分支上进行试探性重构 | 通过提取和重命名来理解代码,还原后规划真正的改动 |
需要完整的演练过程时,请参阅 references/case-studies.md——其中有 3 个场景:为未经测试的 800 行服务添加功能、让充斥着单例的模块通过测试,以及在修复 Bug 前驯服巨型方法。
常见错误
| 错误 | 失败原因 | 修正方法 |
|---|---|---|
| 在一步中同时重构并改变行为 | 出现问题时,你无法判断是哪个改动导致的 | 分开提交;每一步之后都确保测试通过 |
| 为遗留代码编写“应当如此”的测试 | 凭空设想的规格会大声报错,而你会去“修复”关键行为 | 描述代码实际的行为;将 Bug 单独记录 |
| 随处模拟所有依赖 | 测试会锁定实现,因此每次重构都会导致测试失败 | 只对阻碍实例化或观测行为的依赖使用替身 |
| 选择大爆炸式重写,而非逐步增加覆盖率 | 旧系统仍在不断变化;重写往往交付太晚,还会遗漏多年来积累的边缘情况 | 逐块覆盖并修改 |
| 描述行为时悄悄修复发现的 Bug | 调用方和报告可能依赖错误行为 | 用测试固定该行为并记录下来,再将修复作为独立且经过审慎考虑的改动 |
| 编写任何测试之前就进行侵入式清理 | 每次手动修改都可能影响行为,而且没有安全网 | 采用侵入性最小的技术;保留签名;依靠编译器 |
| 永远只萌芽,不收获回报 | 宿主代码始终未经测试,萌芽最终会固化成下一层遗留代码 | 跟踪萌芽债务;下次触及时覆盖热点 |
| 等待专门的“测试项目” | 这个项目永远得不到经费,覆盖率也永远不会提升 | 随每次交付的改动逐步提高覆盖率 |
快速诊断
| 问题 | 如果没有 | 操作 |
|---|---|---|
| 测试是否覆盖了你即将修改的代码? | 你是在边改边祈祷 | 运行变更算法;编辑前固定行为 |
| 能否在测试框架中实例化该类? | 依赖阻碍了隔离 | 使用 Parameterize Constructor、Extract Interface 或 Sprout Class |
| 测试能否感知变更的效果? | 断言看不到这些效果 | 找到感知点;使用 Extract and Override Getter |
| 此提交是否只改变行为或只改变结构? | 两者混杂 | 拆分;在两者之间运行测试 |
| 你是否了解此变更可能影响的一切? | 影响范围未知 | 绘制影响草图;在关键点进行测试 |
| 断言是否描述了观察到的行为? | 测的是期望 | 探测、阅读失败信息,并固定实际值 |
| 你选择的接缝是否是可用方案中成本最低的? | 做了不必要的手术 | 优先考虑构造函数参数和导入接缝 |
| 此次变更后,代码的测试覆盖是否会更好? | 下次变更的成本和这次一样高 | 至少在最近的测试点留下一个固定行为的测试 |
延伸阅读
- "Working Effectively with Legacy Code",作者:Michael C. Feathers
- "Refactoring: Improving the Design of Existing Code",作者:Martin Fowler
- "Tidy First?: A Personal Exercise in Empirical Software Design",作者:Kent Beck
- "Kill It with Fire: Manage Aging Computer Systems (and Future Proof Modern Ones)",作者:Marianne Bellotti
关于作者
Michael C. Feathers 是 R7K Research & Conveyance 的创始人。这是一家专注于软件设计和老旧系统改造的咨询公司。作为长期从事遗留代码咨询并在会议上演讲的专家,他撰写了 Working Effectively with Legacy Code(2004 年),并为这一领域给出了广为采用的定义:遗留代码就是没有测试的代码。