可测试性设计与遗留代码改造:从不可测到安全重构的完整路径

深入讲解可测试性设计(Design for Testability)与遗留代码(Legacy Code)改造测试:代码为什么不可测、依赖注入/接口/纯函数等可测试性设计原则、Characterization 特征测试与 Golden Master、为不可测代码构建 Seams 的安全改造路径、依赖拆解与 Strangler 渐进式改造,以及改造中的组织与流程要点。

“这段代码我改不起,因为一动测试就崩”——这不是代码的宿命,而是可测试性缺失的症状。 遗留代码改造的第一原则是:先铺安全网,再动手术。用特征测试(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 配合金丝雀演进。当团队把"改老代码"从"如履薄冰"变成"有网可依"时,技术债就从一座山变成了可以被系统偿还的账本。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「testing」更多文章

  1. 模糊测试实战:覆盖率引导的自动化漏洞挖掘与 CI 落地
  2. 数据库测试与 Schema 变更安全网:迁移、数据层与数据管道的验证实践
  3. 并行测试执行与 Flaky Test 治理:从变慢变脆到稳定高效