重构前先写特征测试:保护已有行为
一个让我栽过跟头的重构
三年前我接手过一个计费模块,代码大约 1200 行,没有任何测试。产品说「计费逻辑有 bug,你顺便优化一下结构」。我看了两天代码,觉得理清了逻辑,花了一周重构——拆函数、改命名、抽出策略模式。提交后 QA 跑了回归,发现 7 个用例挂了。
其中有 3 个是真正的 bug 修复,但另外 4 个是我不知道的「历史行为」:某个边界条件下价格会四舍五入到角而不是分,某个老客户的折扣叠加顺序和文档描述不一致,某个退款场景下税费计算会跳过而不是归零。这些行为没有任何注释,也没有任何测试。它们在代码里默默运行了四年,所有下游系统都基于这些行为做了假设。
那次教训让我明白一件事:重构没有测试保护的旧代码,本质上是在猜。你猜哪些行为是业务规则,哪些是历史偶然,哪些边界条件有下游依赖。猜错了就是线上事故。
Michael Feathers 在《Working Effectively with Legacy Code》里给「遗留代码」下过一个著名定义:遗留代码就是没有测试的代码。这个定义跟代码写了多久、用了什么语言、架构是否过时毫无关系。只要没有测试,它就是遗留代码——因为你无法在不改变行为的前提下安全地修改它。
而特征测试(Characterization Tests),就是在动手重构之前,先把代码「现在做了什么」记录下来。
特征测试到底是什么
特征测试这个概念来自 Michael Feathers。他在书中描述的方法很朴素:对一个已有模块,从外部接口灌入各种输入,把当前实际输出记录下来作为断言。不判断输出「对不对」,只记录「它就是这样」。
DaedTech 的 Erik Dietrich 把这种做法叫做「代码的罗夏墨迹测验」——你不去评判代码应该做什么,你只是如实描述它现在的样子。当你发现一个奇怪的输出,先别急着修正,先把这个奇怪记录下来。因为在遗留系统里,「奇怪」往往意味着「有下游在依赖」。
特征测试的核心思路可以用一句话概括:先画地图,再修路。地图不评价路修得好不好,它只记录路现在在哪里。
这和单元测试的出发点完全不同。单元测试回答的是「这段代码应该做什么」,特征测试回答的是「这段代码现在在做什么」。前者是规格驱动,后者是观察驱动。
| 维度 | 单元测试 | 特征测试 | 回归测试 |
|---|---|---|---|
| 驱动力 | 规格/意图 | 观察/快照 | 已知缺陷 |
| 断言来源 | 「应该是什么」 | 「现在是什么」 | 「之前是什么」 |
| 写测试的时机 | 实现前或实现中 | 重构前 | 修复 bug 后 |
| 主要目的 | 验证正确性 | 记录现状 | 防止倒退 |
| 对「意外输出」的态度 | 立即修正 | 先记录下来 | 根据情况判断 |
| 适合的场景 | 新功能、清晰规格 | 旧代码、行为不明确 | 已有测试基础 |
| 维护成本 | 中等 | 初期低,后期需演进 | 持续积累 |
特征测试的实战步骤
我后来的工作方式变得很固定:拿到一个没有测试的旧模块,先不急着动结构。第一步是写特征测试,把外部可观察行为锁住;第二步才做内部重构,每做完一步跑一遍测试;第三步是等重构完成、行为理清之后,把特征测试逐步替换成更有表达力的意图测试。
这个过程可以用一张流程图来描述:
案例一:一个混乱的价格计算函数
我曾经遇到过一个 calculateFinalPrice 函数,大概 200 行,里面嵌套了五层 if-else,涉及会员等级、地区系数、促销活动、满减叠加和运费规则。没有任何测试。
我先写了一组特征测试,不去理解内部逻辑,只从输入和输出来记录:
// 特征测试阶段:只记录当前行为,不评价对错
describe('calculateFinalPrice 特征测试', () => {
it('普通用户 + 北京 + 无促销 + 商品 100 元', () => {
const result = calculateFinalPrice({
userId: 'u1',
region: 'beijing',
items: [{ price: 100, quantity: 1 }],
promotions: [],
})
// 我不知道为什么是 106,但当前就是返回 106
expect(result).toBe(106)
})
it('金卡用户 + 上海 + 满减 200-30 + 商品 300 元', () => {
const result = calculateFinalPrice({
userId: 'u2',
region: 'shanghai',
items: [{ price: 300, quantity: 1 }],
promotions: ['full_reduction_200_30'],
})
// 当前返回 261.9,不是 270
expect(result).toBe(261.9)
})
it('边界:商品价格为 0', () => {
const result = calculateFinalPrice({
userId: 'u1',
region: 'guangzhou',
items: [{ price: 0, quantity: 1 }],
promotions: [],
})
// 当前返回 0,没有触发运费
expect(result).toBe(0)
})
})写完跑了一遍,第二条用例第一次失败了——实际返回是 261.9,我最开始断言写的是 270。这正是特征测试的价值:它把我脑子里的「应该」拉回到代码的「实际」。那个 8.1 的差额后来查出来是一个地区运费补贴和金卡折扣的叠加顺序导致的,这个顺序在需求文档里完全没提过,但线上确实就是这样跑的。
确认所有特征测试都通过之后,我才开始重构。
// 重构后:结构清晰,但行为保持一致
function calculateFinalPrice(order: PriceOrderInput): number {
const basePrice = sumItems(order.items)
const promotionDiscount = applyPromotions(basePrice, order.promotions)
const memberDiscount = applyMemberDiscount(basePrice - promotionDiscount, order.userId)
const shippingFee = calculateShipping(basePrice - promotionDiscount - memberDiscount, order.region)
return roundToCent(basePrice - promotionDiscount - memberDiscount + shippingFee)
}重构后跑一遍特征测试,全部通过。这意味着外部行为完全一致。如果某个测试失败了,我需要判断:是我破坏了某个不该改的历史行为,还是旧代码里的 bug 终于被修正了。
| 阶段 | 做了什么 | 测试变化 | 风险 |
|---|---|---|---|
| 重构前 | 写特征测试,记录实际输出 | 新增 15 条特征测试 | 低:只加不改 |
| 重构中 | 拆函数、改命名、调顺序 | 每条改动后运行特征测试 | 中:测试失败需要立即分析 |
| 重构后 | 补充意图测试 | 新增意图测试,保留关键特征测试 | 低:已有完整保护 |
| 维护期 | 意图测试逐步替代特征测试 | 删除重复的特征测试 | 低:行为已有意图测试覆盖 |
案例二:一个接口的返回格式
另一个场景是一个用户信息查询接口 getUserProfile,返回一个嵌套很深的对象。这个接口被 5 个前端页面调用,每个页面取的字段不一样。接口代码经过了多次迭代,有的字段是 null,有的字段是空字符串,有的字段干脆不存在。
我没法通过阅读代码确定「当用户没有设置昵称时,displayName 到底是返回 null、空字符串还是用户名」。三个前端页面可能分别假设了三种不同的情况。
特征测试的做法很简单:构造各种用户状态,调用接口,把完整的返回结构序列化下来做快照断言。
// 用 JSON 快照做特征测试
describe('getUserProfile 特征测试', () => {
it('未设置昵称的用户', async () => {
const profile = await getUserProfile('user_no_nickname')
expect(profile).toMatchSnapshot()
// 快照记录了:displayName 是 null,nickname 是空字符串
// 三个前端都基于这个行为在工作
})
it('被封禁的用户', async () => {
const profile = await getUserProfile('user_banned')
expect(profile).toMatchSnapshot()
// 快照记录了:status 是 'banned',但 email 字段仍然存在
// 这看起来像是个 bug,但前端已经在根据这个字段做提示
})
})快照测试是特征测试的一种实用实现方式。当你不知道一个复杂对象的哪个字段重要时,把整个结构快照下来反而最安全。重构过程中如果快照变了,你就能精确定位到哪个字段发生了变化。
| 场景 | 手写断言 | 快照断言 |
|---|---|---|
| 简单输入输出 | ✅ 清晰直观 | 可以但没必要 |
| 复杂嵌套对象 | 容易漏字段 | ✅ 全量覆盖 |
| 不确定哪些字段重要 | 需要反复调试 | ✅ 先快照再精简 |
| 需要表达业务意图 | ✅ 断言即文档 | ❌ 快照不可读 |
| 长期维护 | ✅ 稳定 | ⚠️ 快照膨胀需要定期清理 |
案例三:一个数据处理管道
我曾经重构一个 CSV 导入管道。原始数据有各种脏数据:多余空格、日期格式不一致、金额带了货币符号、某些行有字段缺失。旧代码用了大量的 trim、replace、正则和条件判断来处理这些情况。
特征测试的策略是:准备几个真实的 CSV 文件作为输入,跑一遍管道,把最终写入数据库的结构快照下来。
describe('CSV 导入管道特征测试', () => {
it('标准输入文件', async () => {
const result = await runImportPipeline('fixtures/standard.csv')
expect(result).toMatchSnapshot()
})
it('包含脏数据的输入文件', async () => {
const result = await runImportPipeline('fixtures/dirty-data.csv')
expect(result).toMatchSnapshot()
// 快照记录了:日期 '2024/03/15' 被转为 '2024-03-15'
// 金额 '¥1,200.50' 被转为 1200.5
// 缺失字段 'remark' 被填为空字符串
})
it('完全空行被跳过', async () => {
const result = await runImportPipeline('fixtures/with-empty-rows.csv')
expect(result.records).toHaveLength(8) // 10 行减去 2 行空行
})
})重构时我把原来一个 300 行的 parseRow 函数拆成了 cleanString、parseDate、parseAmount 和 fillDefaults 四个函数。每一步改完都跑特征测试,确保输出字节级别一致。
特征测试的边界与陷阱
特征测试很好用,但它不是万能的。在实践中我踩过几个坑,也总结了几条边界。
第一个陷阱是「把 bug 锁进测试」。特征测试记录的是当前行为,而当前行为可能本身就是错的。如果你的特征测试把一个明确的 bug 当作「正常行为」锁住了,后续修复反而会失败。我的做法是:在特征测试的注释里标注「这里看起来像 bug,但先保留」,等重构完成、行为理清之后再统一处理。
第二个陷阱是「快照膨胀」。如果用快照做特征测试,随着重构进行,快照文件会越积越多。我会定期清理快照,把已经演进为意图测试的部分删掉。
第三个陷阱是「只测 happy path」。特征测试的价值恰恰在于覆盖边界和异常情况。空输入、null、极端值、错误格式——这些才是旧代码里藏雷最多的地方。
| 陷阱 | 表现 | 应对策略 |
|---|---|---|
| 锁定 bug | 把已知错误当作正常行为断言 | 注释标记,重构后统一处理 |
| 快照膨胀 | 快照文件越来越多,难以维护 | 定期清理,演进为意图测试 |
| 只测 happy path | 边界情况没覆盖,重构时踩雷 | 刻意覆盖空值、极端值、错误格式 |
| 过度依赖快照 | 不理解快照内容,盲目更新 | 每次快照变化都要人工审查 |
| 测试粒度太粗 | 一个测试覆盖整个系统,定位困难 | 分层测试,按模块拆分 |
| 忽视性能特征 | 只关注正确性,忽略性能退化 | 必要时增加性能基准测试 |
从特征测试到意图测试的演进
特征测试是手段,不是终点。一个模块的理想状态是:每个测试都能清晰表达「这段代码应该做什么」,而不是「这段代码现在返回什么」。
我的经验是,重构完成之后应该做一轮测试「升级」:把特征测试中那些已经理解清楚的部分,改写成表达业务意图的测试。
// 升级前:特征测试,只记录数字
it('金卡用户 + 上海 + 满减 + 300 元 → 261.9', () => {
const result = calculateFinalPrice({ /* ... */ })
expect(result).toBe(261.9)
})
// 升级后:意图测试,表达业务规则
it('满减促销和会员折扣按先后顺序叠加,满减优先', () => {
const result = calculateFinalPrice({
userId: 'gold_member',
region: 'shanghai',
items: [{ price: 300, quantity: 1 }],
promotions: ['full_reduction_200_30'],
})
// 300 - 30(满减)= 270,270 * 0.97(上海地区金卡 3% 折扣)= 261.9
// 再减去地区运费补贴 0 元 = 261.9
expect(result).toBe(261.9)
})意图测试的好处是:当业务规则变化时,你知道该改哪些测试。而特征测试里只有一个数字,你看到这个数字变了,不知道意味着什么。
| 维度 | 特征测试 | 意图测试 |
|---|---|---|
| 表达力 | 「输出是 261.9」 | 「满减先于折扣,上海金卡折扣 3%」 |
| 可读性 | 需要对照代码才能理解 | 读测试就知道业务规则 |
| 变更时的指引 | 不知道数字变化意味着什么 | 知道哪个业务规则变了 |
| 维护成本 | 后期高(数字含义不明) | 后期低(规则清晰) |
| 适合阶段 | 重构前、行为不明 | 重构后、行为已理清 |
什么时候该写特征测试,什么时候不该
不是所有代码都需要特征测试。如果模块有完整的测试覆盖、有清晰的文档、逻辑不复杂,直接重构就好。特征测试的价值在于处理那些「没有测试、行为不明确、改了怕出事」的模块。
| 场景 | 是否需要特征测试 | 理由 |
|---|---|---|
| 新模块,有完整单测 | 不需要 | 已有测试保护 |
| 旧模块,逻辑简单,行为明确 | 不需要 | 直接重构即可 |
| 旧模块,逻辑复杂,无测试 | ✅ 需要 | 特征测试是安全网 |
| 旧模块,被多个下游依赖 | ✅ 需要 | 必须锁定兼容性 |
| 模块行为文档齐全且有测试 | 不需要 | 文档和测试已提供规格 |
| 模块行为文档缺失且无测试 | ✅ 需要 | 只有代码本身能告诉你行为 |
我的检查清单
经过多次实践,我整理了一份重构前特征测试的检查清单。每次拿到一个没有测试的旧模块,我会按照这个清单逐步执行:
- 识别模块的外部接口:哪些函数、API、CLI 命令暴露给外部调用
- 列出所有调用方:哪些模块、服务、前端页面在依赖这个模块
- 收集典型输入:正常场景下的 3-5 组代表性输入
- 收集边界输入:空值、null、极端值、错误格式、超长字符串
- 收集历史 bug 相关的输入:出过问题的场景要重点覆盖
- 编写特征测试,先用「assert 任何东西然后看失败」的方式获取实际输出
- 将实际输出填入断言,确保所有特征测试通过
- 给每个「看起来奇怪」的行为加注释,标注「疑似历史行为,暂不修改」
- 如果输出是复杂对象,考虑用快照测试代替手写断言
- 开始重构:每做一步小改动就运行特征测试
- 测试失败时先分析:是破坏了不该改的兼容,还是修正了该修的 bug
- 重构完成后,把关键特征测试升级为意图测试
- 定期清理不再需要的特征测试和快照文件
- 确认所有调用方回归通过,尤其注意那些没有自动化测试的下游
写在最后
回到开头那个计费模块的故事。后来我用特征测试给那个模块加了 40 多条测试,覆盖了所有能想到的输入组合。重构花了两轮,每一轮都跑全量测试。最终上线时只有 1 个特征测试失败——是一个确实该修复的税费计算 bug,提前和产品、下游团队确认过了。
特征测试不是什么高深的技术。它的核心就是一件事:在你还不理解代码的时候,先把代码做的事记下来。这个「记下来」的动作,给了你后续所有重构的底气。
好的重构不是让代码看起来新,而是让行为更可控。
参考资料
- Working Effectively with Legacy Code — Michael Feathers,特征测试概念的原始出处
- Characterization Tests — DaedTech — Erik Dietrich 对 Feathers 概念的解释和延伸
- Characterization Testing — Michael Feathers — Feathers 本人关于特征测试的博文
- How to Improve Legacy Code w/ Characterization Tests — Khalil Stemmler 的实战指南,包含四层测试架构
- Characterization Testing: Adding Tests to Legacy Code — Mario Cervera 对特征测试在遗留代码中应用的总结
- Regression Tests vs Characterization Tests vs Approval Tests — 三种测试方法的对比与辨析
- Characterization Tests With Snapshot Testing — 快照测试作为特征测试实现方式的实践
- The Key Points of Working Effectively with Legacy Code — 全书要点提炼