“这段代码我改不起,因为一动测试就崩”——这不是代码的宿命,而是可测试性缺失的症状。 遗留代码改造的第一原则是:先铺安全网,再动手术。用特征测试(Characterization Test)把现有行为固定下来,找到接缝(Seam)引入依赖,然后在"随时能回退"的保障下逐块重构。本指南带你走完从"不可测"到"安全重构"的完整路径。
一、为什么代码会变得不可测
1.1 不可测代码的典型症状
症状清单:
· 依赖埋在方法内部:new Date()、静态方法、全局单例、环境变量
· 副作用无处不在:直接写库、发消息、打日志
· 大函数:几百行,一个方法几十个 if
· 状态藏在静态变量里:测试之间互相污染
· 依赖基础设施:连数据库/Redis/外部 HTTP 才能跑
· 私有方法藏着逻辑:没法单独验证
· 时机敏感:sleep、真实时间、真实随机
1.2 不可测的代价
· 改一行代码要全量手工回归 → 不敢改 → 代码腐化加速
· 没有测试 → 重构无安全网 → 越改越坏
· 新需求只能"叠代码",复杂度失控
· 团队最终被技术债拖垮
关键:可测试性不是"测试团队的要求",而是"代码质量的先决条件"
二、可测试性设计原则
2.1 六项核心原则
原则一:依赖显式化
不要 new 依赖,从外部注入
→ 测试时可替换为替身
原则二:纯函数优先
逻辑与副作用分离:计算函数纯化,IO 推给薄壳层
→ 纯函数直接测,无需环境
原则三:依赖倒置(面向接口)
依赖抽象而非具体实现 → 测试可注入 Mock/Fake
原则四:控制外部时间/随机
时钟、随机源、UUID 都从接口注入 → 测试确定性
原则五:避免静态/全局可变状态
静态变量是测试隔离的头号杀手
原则六:小方法 + 单一职责
方法越小越容易测,一个方法一个行为
2.2 从"坏味道"到"可测"的改造示例
# ❸ 不可测版本:依赖埋在内部、直接读写全局
def place_order(user_id, cart):
db = get_db() # 全局连接
user = db.query(User).get(user_id)
now = datetime.now() # 真实时钟
order = Order(user=user, total=sum(i.price for i in cart),
created_at=now)
db.add(order)
db.commit()
send_email(user.email, order) # 外部副作用
return order.id
# ✅ 可测版本:注入依赖、纯逻辑与副作用分离
class OrderService:
def __init__(self, repo, notifier, clock):
self.repo = repo # 注入仓库
self.notifier = notifier # 注入通知
self.clock = clock # 注入时钟
def place_order(self, user_id, cart):
user = self.repo.get_user(user_id)
order = Order(user=user,
total=self._calc_total(cart), # 纯函数
created_at=self.clock.now())
self.repo.save(order)
self.notifier.send(user.email, order)
return order.id
def _calc_total(self, cart): # 纯函数,可单独测
return sum(i.price for i in cart)
# 测试:所有依赖都可替身,时钟可控
def test_order_total():
svc = OrderService(FakeRepo(), FakeNotifier(), FakeClock(t=1000))
order = svc.place_order(1, [Item(price=10), Item(price=5)])
assert order.total == 15
三、特征测试(Characterization Test):先固定现有行为
3.1 什么是特征测试
特征测试 ≠ 你想要的测试:
传统测试:断言"代码应该做什么"(规范)
特征测试:断言"代码现在做什么"(现状快照)
用途:
· 代码行为没文档、没人敢动 → 先把现状固化
· 目标不是"正确",而是"可观察、可回归"
· 有了特征测试,重构后才能确认"行为没变"
做法:调用现有函数,把输出原样记录下来作为断言
3.2 特征测试示例
import json
from legacy import legacy_pricer # 不可测的旧函数
def test_legacy_pricer_characterization():
# 第一次运行:记录真实输出到 golden 文件
# 之后:与 golden 文件比对
cases = [
{"qty": 2, "unit": 10, "discount": 0.2},
{"qty": 0, "unit": 10, "discount": 0.5},
{"qty": 100, "unit": 1, "discount": 0.9},
]
for c in cases:
assert legacy_pricer(**c) == load_golden("pricer", json.dumps(c))
3.3 Golden Master 模式
Golden Master 是特征测试的"全量快照"版本:
· 对一组代表性输入,捕获函数全部输出(可能是文件、DB 状态、返回值)
· 存成 golden 文件
· 每次修改代码后重跑对比:任何差异都醒目暴露
适用场景:
· 报表/导出/序列化逻辑
· 批量处理管道
· 无法逐条断言的复杂输出
注意:golden 会"锁定 bug"——特征测试固化的是现状
所以它只是改造的跳板,改造完成后应补充真正的意图测试
四、Seams:为不可测代码开"接缝"
4.1 什么是 Seam
Seam(接缝)= 不修改代码就能改变其行为的位置
常见 Seam 类型:
· 对象接缝:方法调用可被替换(依赖注入、子类覆盖)
· 语言接缝:语言特性让你绕过(如反射、动态特性)
· 链接接缝:编译/运行期绑定可切换(库替换)
目标:找到或创造 Seam,把依赖"抠"出来,从外部替换
4.2 常见 Seam 模式
// 旧代码:内部 new 依赖 → 对象接缝
public class OrderService {
public double total(long userId, List<Item> items) {
DiscountCalc calc = new DiscountCalc(); // 内部创建
...
}
}
// ✅ 引入接缝:加一个可覆盖的工厂方法(不改变调用方)
public class OrderService {
protected DiscountCalc createCalc() { // 接缝
return new DiscountCalc();
}
public double total(long userId, List<Item> items) {
DiscountCalc calc = createCalc(); // 通过接缝拿
...
}
}
// 测试:子类覆盖接缝注入替身
class TestOrderService extends OrderService {
DiscountCalc fake;
@Override protected DiscountCalc createCalc() { return fake; }
}
ℹ️ 要点:改造分两步走——先加接缝(不改变行为),再替换依赖。每一步都可验证,比"一步重写"安全得多。
4.3 Sprout(新芽)模式
Sprout:不重写旧逻辑,而是"在旁边长出新的可测部分"
场景:旧函数里有一段逻辑需要新行为
做法:
1. 把新逻辑提炼到新函数/新类(可测)
2. 旧函数调用它
例子:legacy_order 需要增加税费计算
→ 新增 TaxCalculator.compute(total)
→ legacy_order 里调用
→ 新逻辑有测试,旧逻辑行为不变
五、依赖拆解与重构路线
5.1 重构的安全顺序
推荐路线(自底向上):
1. 特征测试固化现状(Golden Master / 特征断言)
2. 引入 Seam(不改变行为)
3. 把依赖替换为接口注入(保持测试通过)
4. 提炼纯函数(副作用外移)
5. 补充意图测试(明确"应该做什么")
6. 删除冗余的特征测试(被意图测试取代的部分)
每一步都保持绿:改造不是"推倒重来",而是"步步为营"
5.2 典型的依赖拆解
# 旧:服务直接操作数据库全局句柄
def process_payment(order_id):
conn = legacy_conn() # 全局 DB
order = conn.query("SELECT * FROM orders WHERE id=?", order_id)
if order["status"] == "PAID":
return
conn.execute("UPDATE orders SET status='PAID' WHERE id=?", order_id)
# 新:仓库注入 + 纯业务逻辑
class PaymentRepo:
def get(self, oid): ...
def mark_paid(self, oid): ...
class PaymentProcessor:
def __init__(self, repo):
self.repo = repo
def process(self, order_id):
order = self.repo.get(order_id)
if order.status == "PAID":
return
self.repo.mark_paid(order_id) # 业务逻辑纯化可测
# 测试用内存仓库替身,逻辑完全可控
def test_paid_order_skipped():
repo = InMemoryRepo(orders=[Order(id=1, status="PAID")])
PaymentProcessor(repo).process(1)
assert repo.saved == [] # 已支付不重复写
六、大函数与微服务遗留改造
6.1 大函数的拆解策略
超大函数(几百行 if 堆叠):
1. 先特征测试整个函数(输入→输出 快照)
2. 识别"组":按行为簇切分(日志、校验、计算、存储)
3. 每次提炼一个组到独立方法/类(绿保持)
4. 单元级测试逐渐上位
5. 最后函数只剩编排逻辑,可整体测试
用"行为"切分,不用"代码行"切分——保持行为语义可追溯
6.2 微服务的遗留模块改造
单体遗留 → 微服务的改造(Strangler 绞杀者模式):
阶段一:给单体补特征测试 + 契约
阶段二:从边界接口先拆(读路径 → 写路径)
阶段三:新服务用可测设计,旧逻辑通过适配器调用
阶段四:流量逐步切换(金丝雀),验证后下线旧模块
原则:
· 每次拆一个边界,全链路测试保持绿
· 特征测试跟随迁移,到新服务后转为意图测试
七、测试替身与设计权衡
7.1 替身金字塔
| 替身 | 行为 | 适用 |
|---|---|---|
| Fake | 真实轻量实现(内存仓库) | 首选:行为真实、可断言 |
| Stub | 返回预设值 | 依赖返回数据 |
| Spy | 记录调用并断言 | 验证"是否调用了" |
| Mock | 预设期望 + 校验调用 | 交互严格验证,谨慎用 |
| Dummy | 只占位,从不使用 | 构造参数填充 |
7.2 避免 Over-Mocking
Over-Mocking 症状:
· 一个测试里 5+ 个 Mock → 测的是"假人之间怎么配合"
· 实现改一行,测试要改三处 → 维护成本爆炸
· 测试与实现过度耦合,失去"意图表达"
矫正:
· 优先 Fake(真实逻辑的轻量版)
· 只 Mock"真实依赖难以替代"的(网络、时钟、文件)
· 集成测试(Testcontainers)负责真实交互
· 单元测试聚焦"我们的逻辑",不 mock 我们自己的代码
八、组织与渐进式策略
8.1 改造的优先级
怎么选"先改哪里":
· 变更频率最高 + 质量风险最大的模块(改动热区)
· 故障影响最大的核心路径
· 团队当前正被它拖累的痛点代码
· 用"改造 ROI"排序:改动多、风险高 → 优先铺测试
不要"全面铺开":一次性改整个系统注定失败
8.2 团队协作要点
· 改造是"工程债还款",要有预算(每迭代划出时间)
· 结对/评审:特征测试与重构由两人协作,防止误伤
· 变更纪律:每次 PR 只做"加测试 + 小重构",行为不变
· 度量:测试覆盖新增、模块可测性评分、缺陷率变化
· 文化:鼓励"遇到烂代码先铺安全网",而非抱怨
九、实践清单与避坑
9.1 Checklist
□ 识别不可测症状:埋依赖、副作用、大函数、全局状态
□ 先特征测试固化现状(Golden Master/特征断言)
□ 引入 Seam,再替换依赖(两步走,绿保持)
□ 纯函数与副作用分离(改造后逻辑可单测)
□ Sprout 新芽:新行为放新函数,旧逻辑不动
□ 每个 PR:行为不变 + 测试绿 + 小步重构
□ 用 Fake 优先,少用 Mock,避免 Over-Mocking
□ 大函数按"行为簇"拆解
□ 微服务改造走 Strangler + 金丝雀
□ 按"改动热区 + 风险"排序优先级
9.2 常见坑
| 坑 | 现象 | 对策 |
|---|---|---|
| 一步重写 | 行为漂移,线上事故 | 特征测试先固化 |
| 特征测试锁 bug | 想改的行为被"固化" | 改造后补意图测试 |
| 没找到 Seam 就硬拆 | 改动巨大、不可控 | 先加接缝再替换 |
| Over-Mocking | 测试脆、维护爆炸 | 优先 Fake + 集成测试 |
| 改一行崩一堆 | 耦合过高 | 依赖注入 + 拆小方法 |
| 一次性大重构 | 周期长、风险集中 | 小步 PR + 金丝雀 |
9.3 一句话原则
"代码可以不是为测试而写的,但改造必须从测试开始。"
总结:可测试性改造决策表
| 环节 | 关键动作 |
|---|---|
| 诊断 | 识别埋依赖/副作用/大函数/全局状态 |
| 铺网 | 特征测试 + Golden Master 固化现状 |
| 开缝 | Seam(对象/工厂方法)不改变行为 |
| 拆解 | 依赖注入 + 纯函数化 + Sprout |
| 替身 | Fake 优先、少 Mock、集成测试补真实交互 |
| 演进 | 小步 PR + Strangler + 金丝雀切换 |
| 组织 | 按热区/风险排序 + 改造预算 + 结对 |
可测试性是"还能继续演进"的保证。让代码可测,不只是为了跑测试,而是为了让重构可持续、让需求能落地、让团队敢动老代码。落地守住五件事:先特征测试铺安全网、两步走引入 Seam、依赖注入与纯函数化、Fake 优先避免 Over-Mocking、小步 PR 配合金丝雀演进。当团队把"改老代码"从"如履薄冰"变成"有网可依"时,技术债就从一座山变成了可以被系统偿还的账本。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。