重构旧代码前,先用特征测试兜底

接手遗留代码时,重构前先写特征测试来记录现有行为,防止意外破坏。本文用 TypeScript 示例,讲解如何识别关键行为、选择测试边界、处理副作用与外部依赖,并利用 AI 加速发现测试。
重构遗留代码的常见陷阱
很多工程师接手遗留代码时,第一反应是“改造它”。你可能会遇到难以理解的函数、重复的逻辑、嵌套过深的条件判断,甚至数据库调用和业务规则混在一起,导致测试几乎无从下手。你知道代码可以更好,于是开始动手清理。
然后,某个功能突然坏了。
问题往往不在于新实现本身有明显错误,而是旧实现里藏着一个没人知道它存在的行为。这正是遗留系统现代化过程中最常见的风险之一。在修改代码之前,你需要一个办法回答一个简单的问题:我是否保留了原本就重要的行为?
什么是特征测试
特征测试(Characterization Test)的出发点不是“软件应该做什么”,而是记录软件今天实际在做什么。
这个区别很关键。在全新项目中,测试通常表达预期行为;但在遗留项目中,你可能首先需要测试来捕捉现有行为,这样你才能在不意外改变可观测结果的前提下,修改实现逻辑。
本文将通过 TypeScript 和 Vitest 的示例,展示如何用特征测试作为重构前的安全网,涵盖:识别值得保护的行为、选择有用的测试边界、捕捉当前输出、处理副作用、应对数据库和外部系统、借助 AI 加速测试发现、避免冻结实现细节、判断哪些内容不该特征化,以及如何把特征测试变成重构的基础。
特征测试到底保护什么
假设你继承了这样一个函数:
type Customer = { id: string; type: "STANDARD" | "PREMIUM" };
type Order = {
id: string;
customer: Customer;
subtotal: number;
country: string;
paymentMethod: "CARD" | "TRANSFER";
};
async function processOrder(order: Order) {
let total = order.subtotal;
if (order.customer.type === "PREMIUM") {
total = total * 0.9;
}
if (order.country === "AR" && order.paymentMethod === "TRANSFER") {
total = total - 500;
}
if (total < 0) total = 0;
await ordersRepository.save({ ...order, total });
await eventBus.publish({ type: "ORDER_PROCESSED", orderId: order.id, total });
return total;
}
这个函数既有业务规则(折扣、地区优惠)、又有副作用(保存订单、发布事件),还依赖外部系统。重构前,你需要先记录它的当前行为。
特征测试不关心“应该怎样”,只关心“现在怎样”。例如:
it("applies the existing premium customer behavior", async () => {
const save = vi.spyOn(ordersRepository, "save");
const publish = vi.spyOn(eventBus, "publish");
const order: Order = {
id: "order-1",
customer: { id: "customer-1", type: "PREMIUM" },
subtotal: 10000,
// ...
};
// 调用 processOrder,断言返回值、save 和 publish 的参数
});
从行为出发,而非实现
写特征测试前,先明确你要保护的行为边界。不要试图覆盖所有代码路径,而是:
- 选择一个能力:比如“订单总额计算”,而不是整个订单模块。
- 找到最小的有用测试边界:从入口函数开始,观察它改变了什么状态、触碰了哪些外部系统。
- 先捕捉现有输出:跑通测试,记录返回值、数据库写入、事件发布等。
处理副作用和外部依赖
很多遗留代码的难点在于副作用。返回值只是行为的一部分,你还需要验证:
- 数据库是否被正确更新?
- 事件是否被发布?
- 日志、缓存、文件系统是否被触碰?
对于数据库,可以用内存数据库或事务回滚来隔离;对于外部服务,用 mock 或 spy 记录调用参数。关键原则是:不要改变被测代码的调用方式,只在外部边界上做替身。
用 AI 加速测试发现
AI 可以帮你快速生成测试框架。你可以把函数签名和当前实现喂给 AI,让它建议测试用例,尤其是边界情况(如负数、空值、特殊国家代码)。
但要注意:不要让 AI 编造预期行为。特征测试的预期值必须来自真实运行结果,而不是 AI 的推测。AI 生成的是测试骨架,最终断言值要靠实际执行来填充。
避免冻结实现细节
特征测试记录的是可观测行为,不是实现步骤。不要断言内部变量名、调用顺序或中间计算过程——那些是你可以自由重构的部分。只锁定输入-输出关系、副作用结果。
当特征测试暴露了 bug
如果测试发现当前行为明显不合理(比如负价格被截断为 0),你有两个选择:
- 先记录现状,重构后再单独修 bug;
- 如果 bug 影响重大,先修 bug 再写特征测试。
通常建议先记录、后修改,这样你能知道重构前后行为差异到底来自哪里。
实际工作流
- 选择要重构的能力区域。
- 列出入口、状态变化、外部依赖。
- 写少量特征测试,覆盖主要路径和关键边界。
- 运行测试,确认它们通过并记录当前行为。
- 开始重构,每步都跑测试。
- 重构完成后,将特征测试逐步升级为真正的单元测试(表达预期行为)。
特征测试的局限
特征测试不能告诉你“应该怎样”,只能告诉你“现在怎样”。它无法发现缺失的功能,也无法保证覆盖所有路径。它更像一份临时知识基础设施——帮你把隐性的行为显性化,为后续重构提供安全网。
结语
特征测试不是要永远保留每一行遗留行为,而是让你在改动代码之前,先看清现状。对于开发者而言,这是重构前最值得投入的一步:花一小时写测试,可能省下几天排查“神秘回归”的时间。
本文基于 freeCodeCamp 的公开内容,由 AI 辅助整理改写后发布。
原标题:How to Build Characterization Tests Before Refactoring Legacy Code
阅读原文