智能工具库

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

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

接手遗留代码时,重构前先写特征测试来记录现有行为,防止意外破坏。本文用 TypeScript 示例,讲解如何识别关键行为、选择测试边界、处理副作用与外部依赖,并利用 AI 加速发现测试。

2026-08-31 0来源:freeCodeCamp

重构遗留代码的常见陷阱

很多工程师接手遗留代码时,第一反应是“改造它”。你可能会遇到难以理解的函数、重复的逻辑、嵌套过深的条件判断,甚至数据库调用和业务规则混在一起,导致测试几乎无从下手。你知道代码可以更好,于是开始动手清理。

然后,某个功能突然坏了。

问题往往不在于新实现本身有明显错误,而是旧实现里藏着一个没人知道它存在的行为。这正是遗留系统现代化过程中最常见的风险之一。在修改代码之前,你需要一个办法回答一个简单的问题:我是否保留了原本就重要的行为?

什么是特征测试

特征测试(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 的参数
});

从行为出发,而非实现

写特征测试前,先明确你要保护的行为边界。不要试图覆盖所有代码路径,而是:

  1. 选择一个能力:比如“订单总额计算”,而不是整个订单模块。
  2. 找到最小的有用测试边界:从入口函数开始,观察它改变了什么状态、触碰了哪些外部系统。
  3. 先捕捉现有输出:跑通测试,记录返回值、数据库写入、事件发布等。

处理副作用和外部依赖

很多遗留代码的难点在于副作用。返回值只是行为的一部分,你还需要验证:

  • 数据库是否被正确更新?
  • 事件是否被发布?
  • 日志、缓存、文件系统是否被触碰?

对于数据库,可以用内存数据库或事务回滚来隔离;对于外部服务,用 mock 或 spy 记录调用参数。关键原则是:不要改变被测代码的调用方式,只在外部边界上做替身。

用 AI 加速测试发现

AI 可以帮你快速生成测试框架。你可以把函数签名和当前实现喂给 AI,让它建议测试用例,尤其是边界情况(如负数、空值、特殊国家代码)。

但要注意:不要让 AI 编造预期行为。特征测试的预期值必须来自真实运行结果,而不是 AI 的推测。AI 生成的是测试骨架,最终断言值要靠实际执行来填充。

避免冻结实现细节

特征测试记录的是可观测行为,不是实现步骤。不要断言内部变量名、调用顺序或中间计算过程——那些是你可以自由重构的部分。只锁定输入-输出关系、副作用结果。

当特征测试暴露了 bug

如果测试发现当前行为明显不合理(比如负价格被截断为 0),你有两个选择:

  • 先记录现状,重构后再单独修 bug;
  • 如果 bug 影响重大,先修 bug 再写特征测试。

通常建议先记录、后修改,这样你能知道重构前后行为差异到底来自哪里。

实际工作流

  1. 选择要重构的能力区域。
  2. 列出入口、状态变化、外部依赖。
  3. 写少量特征测试,覆盖主要路径和关键边界。
  4. 运行测试,确认它们通过并记录当前行为。
  5. 开始重构,每步都跑测试。
  6. 重构完成后,将特征测试逐步升级为真正的单元测试(表达预期行为)。

特征测试的局限

特征测试不能告诉你“应该怎样”,只能告诉你“现在怎样”。它无法发现缺失的功能,也无法保证覆盖所有路径。它更像一份临时知识基础设施——帮你把隐性的行为显性化,为后续重构提供安全网。

结语

特征测试不是要永远保留每一行遗留行为,而是让你在改动代码之前,先看清现状。对于开发者而言,这是重构前最值得投入的一步:花一小时写测试,可能省下几天排查“神秘回归”的时间。

本文基于 freeCodeCamp 的公开内容,由 AI 辅助整理改写后发布。

原标题:How to Build Characterization Tests Before Refactoring Legacy Code

阅读原文