编程进阶网 编程进阶网
首页
  • 在线工具
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 加解密
  • 时间日期
  • 网络工具
  • 颜色设计
  • 二维码
  • 开发实用
  • 计算机的原理
  • 操作系统原理
  • 网络协议原理
  • 数据库的原理
  • 序卷导读
  • 数据本质
  • 运行模型
  • 并发设计
  • 内存真相
  • 交互系统
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 技能之旅
  • 体系建设
  • 代码品质
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 性能优化
  • 数据结构导论
  • 线性结构详解
  • 树哈希结构论
  • 容器设计实战
  • 经典算法思想
  • 工程案例剖析
  • 算法题库精练
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++编程技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript案例
  • JavaScript高级
  • Kotlin精通
  • Android库解读
  • Android专栏
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • 毛选解读
  • 自我精进
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接

杨充

专注编程 · 终身学习者
首页
  • 在线工具
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 加解密
  • 时间日期
  • 网络工具
  • 颜色设计
  • 二维码
  • 开发实用
  • 计算机的原理
  • 操作系统原理
  • 网络协议原理
  • 数据库的原理
  • 序卷导读
  • 数据本质
  • 运行模型
  • 并发设计
  • 内存真相
  • 交互系统
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 技能之旅
  • 体系建设
  • 代码品质
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 性能优化
  • 数据结构导论
  • 线性结构详解
  • 树哈希结构论
  • 容器设计实战
  • 经典算法思想
  • 工程案例剖析
  • 算法题库精练
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++编程技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript案例
  • JavaScript高级
  • Kotlin精通
  • Android库解读
  • Android专栏
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • 毛选解读
  • 自我精进
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接
  • README
  • 体系建设优化

  • 代码品质工坊

    • README
    • 01.代码医院开院首诊
    • 02.命名与意图的战场
    • 03.函数与职责大手术
    • 04.错误与边界的防线
    • 05.条件与多态心律术
    • 06.遗留代码急救手册
    • 07.静态分析度量诊断
    • 08.测试覆盖率的真相
    • 09.技术债量化与还款
    • 10.重构十八招式详解
    • 11.测试保命术全解析
      • 1. 急诊病例
        • 1.1 重构血案回放
        • 1.2 特征测试救场
        • 1.3 本篇待答疑问
        • 1.4 一句话回顾
        • 1.5 核心要点
        • 1.6 带走清单
      • 2. 病理诊断
        • 2.1 无测试六恐惧
        • 2.2 T 系列病案登记
        • 2.3 一句话回顾
        • 2.4 核心要点
        • 2.5 带走清单
      • 3. 病因追溯
        • 3.1 难测三大原因
        • 3.2 难测本是设计
        • 3.3 一句话回顾
        • 3.4 核心要点
        • 3.5 带走清单
      • 4. 治疗方案总纲
        • 4.1 测试金字塔地图
        • 4.2 五种替身选型
        • 4.3 一句话回顾
        • 4.4 核心要点
        • 4.5 带走清单
      • 5. 住院医查房
        • 5.1 AAA 三段式实战
        • 5.2 打桩基本套路
        • 5.3 初级带走清单
      • 6. 主治查房
        • 6.1 契约与集成
        • 6.2 容器测试落地
        • 6.3 高级带走清单
      • 7. 主任查房
        • 7.1 组织策略选型
        • 7.2 抖动测试治理
        • 7.3 架构师带走清单
      • 8. 术后康复曲线
        • 8.1 覆盖断言变化
        • 8.2 运行时长稳定
        • 8.3 一句话回顾
        • 8.4 核心要点
        • 8.5 带走清单
      • 9. 病案归档
        • 9.1 T 系列编号
        • 9.2 关联病案索引
        • 9.3 一句话回顾
        • 9.4 核心要点
        • 9.5 带走清单
      • 10. 综合案例串讲
        • 10.1 病例真相揭晓
        • 10.2 兜底完整过程
        • 10.3 设计哲学回扣
        • 10.4 速查一图
        • 10.5 全文快速回顾
        • 10.6 核心要点串联
        • 10.7 常见误区汇总
        • 10.8 带走清单总表
    • 12.代码审查文化建设
    • 13.新系统免疫力建设
    • 14.医生手册总结索引
    • 写作模板
  • 稳定性与可靠性

  • 工程化与运维

  • 方案设计思想

  • 性能优化实践

  • 真经
  • 代码品质工坊
杨充
2026-06-27
目录

11.测试保命术全解析

# 11.测试保命术全解析

本篇定位:外科第二篇 · 麻醉与保命——重构前的测试兜底与安全网建设。

剧情节点:Day 71-78——第 10 篇 18 招练完,团队发现:"没有测试兜底,一切重构都是命悬一线"。这一篇就是把安全网真正打起来。

本篇病人:P-001 全系统 · 从 UT 到集成到 E2E 的测试金字塔搭建。

承接经典:Michael Feathers《修改代码的艺术》Ch4 特征测试 · Ch12-14 打破依赖 / Roy Osherove《单元测试的艺术》/ Vladimir Khorikov《Unit Testing Principles, Practices, and Patterns》/ 《xUnit Test Patterns》Gerard Meszaros。

本篇病案编号范围:T07-T12(测试形态与替身类)


# 目录介绍

  • 1. 急诊病例
    • 1.1 重构血案回放
    • 1.2 特征测试救场
    • 1.3 本篇待答疑问
    • 1.4 一句话回顾
    • 1.5 核心要点
    • 1.6 带走清单
  • 2. 病理诊断
    • 2.1 无测试六恐惧
    • 2.2 T 系列病案登记
    • 2.3 一句话回顾
    • 2.4 核心要点
    • 2.5 带走清单
  • 3. 病因追溯
    • 3.1 难测三大原因
    • 3.2 难测本是设计
    • 3.3 一句话回顾
    • 3.4 核心要点
    • 3.5 带走清单
  • 4. 治疗方案总纲
    • 4.1 测试金字塔地图
    • 4.2 五种替身选型
    • 4.3 一句话回顾
    • 4.4 核心要点
    • 4.5 带走清单
  • 5. 住院医查房
    • 5.1 AAA 三段式实战
    • 5.2 打桩基本套路
    • 5.3 初级带走清单
  • 6. 主治查房
    • 6.1 契约与集成
    • 6.2 容器测试落地
    • 6.3 高级带走清单
  • 7. 主任查房
    • 7.1 组织策略选型
    • 7.2 抖动测试治理
    • 7.3 架构师带走清单
  • 8. 术后康复曲线
    • 8.1 覆盖断言变化
    • 8.2 运行时长稳定
    • 8.3 一句话回顾
    • 8.4 核心要点
    • 8.5 带走清单
  • 9. 病案归档
    • 9.1 T 系列编号
    • 9.2 关联病案索引
    • 9.3 一句话回顾
    • 9.4 核心要点
    • 9.5 带走清单
  • 10. 综合案例串讲
    • 10.1 病例真相揭晓
    • 10.2 兜底完整过程
    • 10.3 设计哲学回扣
    • 10.4 速查一图
    • 10.5 全文快速回顾
    • 10.6 核心要点串联
    • 10.7 常见误区汇总
    • 10.8 带走清单总表

# 1. 急诊病例

# 1.1 重构血案回放

Day 71 早上 10 点半,小李按快捷键 Ctrl+Alt+M 提炼了 PaymentGate.pay() 里的一段——IDEA 显示"重构成功"。他 push 到 dev 分支,走灰度 10%——11:15 告警响起:

[P1 事故] 支付成功率从 99.7% 下降到 87.3%
影响: 灰度 10% · 已影响 ~450 单 · 预计损失 22 万
时长: 15 分钟至今

老陈冲过来看代码——IDEA 报"重构成功"是真的、代码 diff 是等价的、Sonar 门禁全绿——但为什么线上崩了?

排查 40 分钟后真相浮现:

  • 原代码里,pay() 有一处依赖调用顺序的副作用——if (checkIdempotent(...)) 在早期 return 时不会执行 logAudit()——这段"意外的顺序耦合"是团队半年前用来规避重复审计日志的。
  • 小李提炼函数时,把 checkIdempotent + logAudit 合并到一个函数里——IDEA 语义等价,但业务语义不等价——重复的审计日志导致下游对账系统被撑爆,触发限流,反过来卡住了支付回调。

老陈打开 Git 一看——PaymentGate 的单测 0 个。"重构成功"是 IDEA 告诉他的,业务是否成功没人验证。

小李当场脸白了:

"我明明用的是 IDEA 一键重构——IDEA 不是保证语义等价吗?"

老陈的回答:

"IDEA 保证的是编译器眼里的等价——不是业务眼里的等价。业务等价靠什么保证?靠测试。你今天赌 IDEA 赢了 187 次,第 188 次翻船——这次赔的是 22 万。"

这就是本篇的核心——测试是重构的麻醉剂,没有麻醉的手术不叫医术。

# 1.2 特征测试救场

Day 72 团队回过神来——立刻开始给 PaymentGate 补测试。但代码本身写得无法测:

// 💀 埋雷点 · PaymentGate 原态
public class PaymentGate {
    @Autowired private RemotePaymentClient client;       // ← 静态注入 · 无法 mock
    @Autowired private AuditLogger auditor;
    private final Map<String, Long> idempotentKeys = new ConcurrentHashMap<>();

    public PayResult pay(PayRequest req) {
        // 依赖当前时间
        long now = System.currentTimeMillis();           // ← 依赖静态方法
        // 依赖静态类
        if (RateLimiter.INSTANCE.tryAcquire()) {         // ← 单例
            // 依赖内部 map 状态
            if (idempotentKeys.containsKey(req.getKey())) return PayResult.repeated();
            idempotentKeys.put(req.getKey(), now);
            PayResult result = client.charge(req);
            auditor.log(...);
            return result;
        }
        throw new TooManyRequestException();
    }
}

小张开工写单测——3 小时后交白卷:

"老陈,我写不下去——每个测试要 mock 5 个东西——mock 完发现测试写的不是业务,是 mock 本身。"

这就是 Feathers 所说的"测试不好写是设计问题" —— 代码的可测试性 = 代码的解耦度。

Day 73 老陈按 Feathers 五步 SOP 给 PaymentGate 打特征测试:

Step 1 · 找接缝(第 06 篇学过):

// ✅ Refactored · 依赖注入 + 接口化
public class PaymentGate {
    private final PaymentClient client;
    private final AuditLogger auditor;
    private final RateLimiter rateLimiter;
    private final Clock clock;                        // ← 时间抽象
    private final IdempotentStore idempotentStore;   // ← 状态抽象

    public PayResult pay(PayRequest req) {
        long now = clock.millis();
        if (!rateLimiter.tryAcquire()) throw new TooManyRequestException();
        if (idempotentStore.containsKey(req.getKey())) return PayResult.repeated();
        idempotentStore.put(req.getKey(), now);
        PayResult result = client.charge(req);
        auditor.log(...);
        return result;
    }
}

Step 2 · 打特征测试(Golden Master):

// ✅ 特征测试 · 抓取当前 8 种输入的输出 · 锁死行为
@Test
void 幂等键已存在_返回_repeated() {
    // Arrange
    when(idempotentStore.containsKey("K1")).thenReturn(true);

    // Act
    PayResult result = gate.pay(payReq("K1"));

    // Assert
    assertEquals(PayResult.Status.REPEATED, result.getStatus());
    verify(client, never()).charge(any());          // ← 关键:绝不发起支付
    verify(auditor, never()).log(any());            // ← 关键:绝不写审计
}

@Test
void 限流触发_抛TooManyRequest_不写审计() {
    when(rateLimiter.tryAcquire()).thenReturn(false);
    assertThrows(TooManyRequestException.class, () -> gate.pay(payReq("K2")));
    verify(auditor, never()).log(any());
}

@Test
void 正常支付_完成后写审计一次() {
    when(client.charge(any())).thenReturn(PayResult.success());
    gate.pay(payReq("K3"));
    verify(auditor, times(1)).log(any());           // ← 关键:审计只写一次
}

// ... 8 个特征测试锁住 8 种 case

Step 3 · 有了这 8 个测试之后——Day 74 小李重新做那次提炼函数重构——8 个测试里第 3 个直接红了:

❌ testing 正常支付_完成后写审计一次
   Expected: verify(auditor, times(1))
   Actual  : auditor was called 2 times

测试直接抓住了那个"审计日志重复"的问题——没有上线,没有事故,没有 22 万损失。

**这就是"测试保命之术"的字面意思——测试真的能保命。

# 1.3 本篇待答疑问

Day 73 中午的复盘会,团队列出必须回答的 7 个问题:

① 单测、集成测试、E2E——什么时候用哪个?覆盖比例怎么定?

② Mock、Stub、Spy、Fake、Dummy——五种测试替身有啥区别?我该用哪个?

③ 什么样的代码"天然可测"?什么样的"必须先改结构才能测"?

④ Testcontainers 那么慢,还要不要用?和 H2 内存库有啥区别?

⑤ 契约测试是啥?它跟集成测试是啥关系?

⑥ Flaky 测试(时好时坏的测试)怎么治?还是干脆放弃这类测试?

⑦ TDD 到底该不该在遗留代码上做?

第 10 章逐条作答。

# 1.4 一句话回顾

一次重构引发的血案 + 一次特征测试的救场,把'测试是保命之术'这句话钉在了团队墙上。

# 1.5 核心要点

  • 没有测试的重构等于闭眼动刀
  • 特征测试可以在无理解的情况下保命
  • 测试是重构的护身符,不是负担

# 1.6 带走清单

  • [ ] 动刀前必写 1 组特征测试
  • [ ] 把'先测试再重构'写成红线
  • [ ] 每次事故复盘看'测试有没有拦下来'

# 2. 病理诊断

# 2.1 无测试六恐惧

Feathers 定义:Legacy code = 没有测试的代码。无测试代码带来六种典型恐惧:

无测试代码 · 六大恐惧
─────────────────────────

🔴 恐惧 1 · 改错不知道
   没有断言可以红——错了要靠 QA / 用户告诉你

🔴 恐惧 2 · 不敢重构
   任何"IDE 一键重构"都可能踩到 IDEA 看不见的业务语义

🔴 恐惧 3 · 上线不敢发
   灰度、白名单、回滚——所有的都是"没测试"的补偿

🔴 恐惧 4 · 排期加倍
   每一次需求要加 30% 时间做"手动回归"

🔴 恐惧 5 · 招不到人
   面试者听说"无测试遗留系统"扭头就走

🔴 恐惧 6 · 传承断代
   老员工离职后,谁也不敢碰他的代码

订单案例 · Day 71 前的现状:恐惧 1、2、3、4 全中——PaymentGate 就是活教材。

# 2.2 T 系列病案登记

📋 病案编号:T07 📛 病名:测试不可写(Untestable Design) 🔍 主诉:类被设计得根本无法单测——静态方法、new 依赖、单例 🩺 检查:grep -c "new " *.java 命中过多 · 单例满地 💊 处方:依赖注入 + 接口抽象 + Feathers 接缝技术 📖 出处:Feathers《修改代码的艺术》Ch14-16 🔗 关联病案:T08(Mock 过度)、T09(脆弱替身)

📋 病案编号:T08 📛 病名:Mock 过度(Over Mocked) 🔍 主诉:一个测试里 10+ 个 mock,测的是 mock 而不是业务 🩺 检查:单测里 @Mock / when() 计数 >10 💊 处方:优先用 Fake(假实现)替代 Mock · 只 mock 外部依赖 📖 出处:Fowler《Mocks Aren't Stubs》/ Khorikov《Unit Testing Principles》

📋 病案编号:T09 📛 病名:替身脆弱(Fragile Test Double) 🔍 主诉:Mock 依赖了太多实现细节,改代码就要改测试 🩺 检查:verify(x).methodA(); verify(x).methodB(); 类型断言多 💊 处方:只在"契约边界"用 verify · 内部行为用状态断言 📖 出处:Kent Beck《TDD》/ Google《Testing Blog》

📋 病案编号:T10 📛 病名:测试太慢(Slow Test) 🔍 主诉:单测跑 30 秒 · 集成测试跑 30 分钟 · 团队关了自动跑 🩺 检查:CI 全套 >10 分钟 · 单测 >5 秒/个 💊 处方:测试金字塔 · 80/15/5 · Testcontainers 并发 · CI 分层 📖 出处:Google《SWE at Google》Ch11 / TestPyramid

📋 病案编号:T11 📛 病名:Flaky 测试(Flaky Test) 🔍 主诉:同一测试有时绿有时红 · 全组学会 "重跑一次" 🩺 检查:CI 失败率 >5% 且重跑通过率 >80% → Flaky 💊 处方:建立 Flaky 隔离区 · 时间/网络/顺序/并发 四大 root cause 治理 📖 出处:Google 2020 Blog《Flaky Tests at Google》

📋 病案编号:T12 📛 病名:测试代码腐化(Test Rot) 🔍 主诉:生产代码天天维护,测试代码从没重构过 🩺 检查:测试文件 Sonar Debt >生产文件平均值 💊 处方:测试代码是一等公民 · CR 同标准 · 测试也要 Refactor 📖 出处:《xUnit Test Patterns》Meszaros / Robert Martin《Clean Coder》

# 2.3 一句话回顾

无测试代码的六种恐惧 + T07-T12 病案编号,让'不好测'从借口变成可分类的病症。

# 2.4 核心要点

  • '不好测'的根源是设计问题
  • 恐惧越多,重构越难
  • 编号让测试问题可被检索

# 2.5 带走清单

  • [ ] T07-T12 表贴到 CR 模板
  • [ ] 识别项目里最典型的'不好测'案例
  • [ ] 建立'难测度'季度指标

# 3. 病因追溯

# 3.1 难测三大原因

Day 73 小李"写不下去"的三大原因——每一个都是设计问题:

原因一 · 硬依赖具体类

public class OrderService {
    // ⚠️ 硬 new —— 无法 mock
    private final PaymentClient client = new PaymentClientImpl();
    // ⚠️ 静态调用 —— 无法 mock
    public void submit() { RedisUtil.set(...); }
    // ⚠️ 单例 —— 状态污染
    public void handle() { RateLimiter.INSTANCE.acquire(); }
}

症状:测试里想把 client 换成 mock——没接缝可插。

解药:依赖注入 + 接口化——Feathers 五步 SOP 第 2 步"找接缝"。

原因二 · 依赖静态时间/环境

public class ExpireCheck {
    public boolean isExpired(Order o) {
        // ⚠️ 直接调用 System —— 测试无法控制时间
        return System.currentTimeMillis() > o.getExpireAt();
    }
}

症状:测试要覆盖"过期 case" 必须 Thread.sleep——测试慢且不稳定。

解药:引入 Clock 抽象:

// ✅ 手术后
public class ExpireCheck {
    private final Clock clock;
    public boolean isExpired(Order o) {
        return clock.millis() > o.getExpireAt();
    }
}
// 测试里用 Clock.fixed(...) 精确控制

原因三 · 隐性上下文(Spring / ThreadLocal / MDC)

public class UserService {
    public User getCurrent() {
        // ⚠️ 依赖 SecurityContextHolder —— 测试要启动 Spring
        return SecurityContextHolder.getContext()
            .getAuthentication().getPrincipal();
    }
}

解药:把上下文包成参数——getCurrentUser(userContext) 显式传入。

# 3.2 难测本是设计

Feathers 核心洞察:

"当你觉得测试写不出来时,你实际上是在告诉自己:代码耦合太紧。"

换句话说:代码的可测试性 = 代码的解耦度 = 代码的设计质量。

为什么这样?

疑惑:为什么"能测"就等于"设计好"?

论证:

  1. 能被测意味着能被隔离运行——这要求依赖是显式的、可替换的。
  2. 依赖显式可替换 = 单一职责 + 依赖倒置 + 接口分离——这是 SOLID 原则。
  3. 反向验证:God Class 通常测试不了——因为 God Class 违反了单一职责,测试要同时构造 20 个依赖。
  4. 数据佐证:Google 2016 《Testing at Google》——可测性评分与软件缺陷密度呈负相关 -0.68。
  5. Robert Martin 名言(《架构整洁之道》):"如果代码不好测,说明代码结构有问题——测试是设计的探测器"。

结论:"能不能测" 是"设计好不好"的直接投射——修测试 = 改设计。

这就是本篇最重要的哲学——通过写测试反推设计缺陷。

# 3.3 一句话回顾

测试写不出来的三大原因,最终都指向一个词——耦合。

# 3.4 核心要点

  • 强耦合让测试无法隔离
  • 隐藏依赖让测试无法搭建
  • 副作用让测试不可重复

# 3.5 带走清单

  • [ ] 找出项目里 3 个'难测'类,分析耦合
  • [ ] 为每个难测类写下改造路径
  • [ ] 把'可测性'纳入设计评审

# 4. 治疗方案总纲

# 4.1 测试金字塔地图

Mike Cohn《Succeeding with Agile》原图:

                              ┌─────┐
                              │ E2E │       5%
                              │     │
                              └─────┘
                          ┌─────────────┐
                          │ Integration │  15%
                          │             │
                          └─────────────┘
                    ┌───────────────────────┐
                    │        Unit           │  80%
                    │                       │
                    └───────────────────────┘

              速度    ← 快 慢 → 
              稳定性  ← 高 低 →
              编写成本 ← 低 高 →
              定位精度 ← 高 低 →

订单系统 Day 71 之前的形状 · 冰淇淋反模式:

    ┌──────────────────┐
    │  Manual QA       │  40%  ← QA 手工 3 天回归
    ├──────────────────┤
    │       E2E        │  35%
    ├──────────────┐
    │Integration   │       15%
    ├────┐
    │Unit│                  10%
    └────┘

Day 78 团队目标 · 标准金字塔:

    ┌────┐
    │E2E │              5%   30 个 · 覆盖 8 大关键流程
    ├────────┐
    │Integ.  │         15%   90 个 · 覆盖 15 个跨模块交互
    ├────────────────┐
    │   Unit         │  80%  480 个 · 覆盖每个 public 方法
    └────────────────┘

各层的责任:

层 覆盖范围 用什么 目标耗时
Unit 单个类/方法 JUnit + Mockito + Fake 单个 <100ms · 全套 <30s
Integration 模块间/DB/中间件 Testcontainers + WireMock 单个 <5s · 全套 <5min
E2E 完整用户旅程 Playwright / RestAssured 单个 <30s · 全套 <15min

# 4.2 五种替身选型

Gerard Meszaros 五分法(《xUnit Test Patterns》)——每一种替身适用场景完全不同:

        ┌─── 有行为验证 ────► Mock
        │                    │
   替身─┤                    │  完全的对象替身
        │  ┌── 返回预设值 ── Stub    "让测试跑起来"
        ├──┤
        │  └── 记录调用 ──── Spy     "看是否被调过"
        │
        ├─── 简化实现 ────── Fake    "内存数据库/内存队列"
        │
        └─── 占位不用 ────── Dummy   "参数占位符"

五种替身速查:

替身 用途 举例 Java 工具
Dummy 参数占位,从不用 new User(null, null, null) 手写
Stub 提供预设返回值 when(repo.find(1)).thenReturn(Order.MOCK) Mockito
Spy 记录调用,本体仍执行 Spy(Mockito.spy(realService)) Mockito
Mock 全面替身 + 行为验证 verify(repo).save(any()) Mockito
Fake 真实简化实现 InMemoryOrderRepository 手写/H2

选型决策:

疑惑:Mock 和 Fake 什么区别?我为什么不总用 Mock?

论证:

  1. Mock 只是"预设行为"——测试用例改一次,Mock 就要改一次——脆弱。
  2. Fake 是"真实但简化"——行为契约不变,测试用例只依赖行为——健壮。
  3. Google 内部指南:"优先用 Fake,然后 Stub,最后才 Mock"——因为 Fake 的变更成本最低。
  4. 反向验证:订单案例的 IdempotentStore——一开始用 Mock(when(store.containsKey))——测试改了 30 次;改成 Fake(InMemoryIdempotentStore)——改动次数为 0。
  5. Vladimir Khorikov《Unit Testing Principles》:Mock 只应该用于"进程外协作者"(HTTP/DB/MQ)——进程内的都应该 Fake 或用真实对象。

结论:Mock 只 mock 外部依赖 · 进程内用 Fake · Stub 用于孤立返回值——这三条一定要记牢。

# 4.3 一句话回顾

测试策略的选择,取决于'测什么'与'依赖谁'——五种替身选型是测试设计的核心。

# 4.4 核心要点

  • Stub / Mock / Fake / Spy / Dummy 各有边界
  • 过度 Mock 会让测试变脆
  • 测试替身是设计的一面镜子

# 4.5 带走清单

  • [ ] 项目里每一处 Mock 都能说清楚为什么
  • [ ] 替身选型加入 CR 检查
  • [ ] 写测试前先画依赖图

# 5. 住院医查房

# 5.1 AAA 三段式实战

住院医关注面:每个单测都是 3 段——Arrange · Act · Assert——不要乱。

标准模板:

@Test
void 支付成功_审计日志写一次() {
    // ═══ Arrange · 准备 ═══
    PayRequest req = PayRequest.builder()
        .key("K001")
        .amount(new BigDecimal("100"))
        .userId(1001L)
        .build();
    when(rateLimiter.tryAcquire()).thenReturn(true);
    when(idempotentStore.containsKey("K001")).thenReturn(false);
    when(client.charge(req)).thenReturn(PayResult.success());

    // ═══ Act · 执行 ═══
    PayResult result = gate.pay(req);

    // ═══ Assert · 断言 ═══
    assertEquals(PayResult.Status.SUCCESS, result.getStatus());
    verify(auditor, times(1)).log(any());
    verify(idempotentStore).put(eq("K001"), anyLong());
}

三个空行分开三段——一眼扫过去就知道每段做什么。

命名规范:场景_条件_期望结果——回扣第 08 篇。

跨语言对照:

点击展开 Go / Python / TS AAA 三段式
// Go · testify
func TestPay_Success_AuditOnce(t *testing.T) {
    // Arrange
    req := &PayRequest{Key: "K001", Amount: 100}
    rateLimiter.On("TryAcquire").Return(true)
    store.On("ContainsKey", "K001").Return(false)
    client.On("Charge", req).Return(SuccessResult, nil)

    // Act
    result, err := gate.Pay(req)

    // Assert
    require.NoError(t, err)
    assert.Equal(t, StatusSuccess, result.Status)
    auditor.AssertNumberOfCalls(t, "Log", 1)
}
# Python · pytest + unittest.mock
def test_pay_success_audit_once(mocker):
    # Arrange
    req = PayRequest(key="K001", amount=100)
    mocker.patch.object(rate_limiter, "try_acquire", return_value=True)
    mocker.patch.object(store, "contains_key", return_value=False)
    mocker.patch.object(client, "charge", return_value=success_result())

    # Act
    result = gate.pay(req)

    # Assert
    assert result.status == PayStatus.SUCCESS
    auditor.log.assert_called_once()
// TypeScript · Vitest
test("pay success logs audit once", async () => {
    // Arrange
    const req = { key: "K001", amount: 100 };
    rateLimiter.tryAcquire = vi.fn().mockReturnValue(true);
    store.containsKey = vi.fn().mockReturnValue(false);
    client.charge = vi.fn().mockResolvedValue({ status: "SUCCESS" });

    // Act
    const result = await gate.pay(req);

    // Assert
    expect(result.status).toBe("SUCCESS");
    expect(auditor.log).toHaveBeenCalledTimes(1);
});

# 5.2 打桩基本套路

Mockito · Java 团队标配:

// 1. 声明
@ExtendWith(MockitoExtension.class)
class PaymentGateTest {
    @Mock private PaymentClient client;
    @Mock private AuditLogger auditor;
    @InjectMocks private PaymentGate gate;

    // 2. 打桩 stubbing
    @Test void stubbing() {
        when(client.charge(any())).thenReturn(PayResult.success());
        when(client.charge(argThat(r -> r.getAmount() > 1000)))
            .thenThrow(new AmountTooLargeException());
    }

    // 3. 行为验证
    @Test void verify_behavior() {
        gate.pay(req);
        verify(auditor).log(any());              // 至少一次
        verify(auditor, times(1)).log(any());    // 精确一次
        verify(auditor, never()).log(argThat(l -> l.getType() == "REPEAT"));
    }

    // 4. 参数捕获
    @Test void capture() {
        ArgumentCaptor<AuditLog> captor = ArgumentCaptor.forClass(AuditLog.class);
        gate.pay(req);
        verify(auditor).log(captor.capture());
        assertEquals("SUCCESS", captor.getValue().getStatus());
    }
}

MockK · Kotlin 首选:

@Test fun `支付成功_审计日志写一次`() {
    every { rateLimiter.tryAcquire() } returns true
    every { store.containsKey("K001") } returns false
    every { client.charge(any()) } returns PayResult.SUCCESS

    val result = gate.pay(request)

    result.status shouldBe PayResult.Status.SUCCESS
    verify(exactly = 1) { auditor.log(any()) }
}

核心心法:Mock 的方法数应该 ≤5——超过就是 T08 Mock 过度病灶——改设计不要硬 Mock。

# 5.3 初级带走清单

  • ① 每个测试严格 AAA 三段式——空行分隔——绝不混写。
  • ② 测试命名 场景_条件_期望——不要 test1。
  • ③ @Mock 数量 ≤5——超过就 T08 病灶——先改设计再写测试。
  • ④ Mock 只用于外部依赖(HTTP / DB / MQ / 缓存)——进程内的用真实对象或 Fake。
  • ⑤ assertEquals(expected, actual) 精确到值——不要 assertNotNull(回扣第 08 篇 T02)。
  • ⑥ 测试文件 CR 标准 = 生产代码——测试代码也要重命名、提炼函数、消除重复。

# 6. 主治查房

# 6.1 契约与集成

主治关注面:跨服务、跨模块的边界测试——单测覆盖不到的地方。

契约测试 vs 集成测试:

契约测试 (Contract Test)              集成测试 (Integration Test)
────────────────────────              ────────────────────────
两个服务 · 各自跑                     多个模块 · 一起跑
Provider 验证契约                     模拟真实交互
Consumer 定义期望                     用真实的 DB/MQ 等
无需部署对方                          需要启动依赖(Testcontainers)
适合微服务/API                        适合单体内跨模块
Pact / Spring Cloud Contract          Testcontainers / WireMock

Pact 契约测试实战(Consumer 端):

@Test
@PactTestFor(providerName = "PaymentService")
void 消费者定义_支付契约(MockServer mockServer) {
    // 1. 定义期望
    RequestResponsePact pact = ConsumerPactBuilder
        .consumer("OrderService")
        .hasPactWith("PaymentService")
        .uponReceiving("扣款请求")
            .path("/pay")
            .method("POST")
            .body("{\"amount\":100,\"userId\":1001}")
        .willRespondWith()
            .status(200)
            .body("{\"status\":\"SUCCESS\",\"transactionId\":\".+\"}")
        .toPact();

    // 2. Consumer 用 mockServer 跑 —— 生成契约
    PayResult result = paymentClient.charge(new PayReq(100, 1001));
    assertEquals("SUCCESS", result.getStatus());
    // 3. Pact 文件上传 broker —— Provider 侧独立验证
}

关键 · 契约测试解决"接口漂移"问题:Provider 修改字段——契约测试立刻红——不用等到集成环境部署才发现。

📋 病案编号:A11 📛 病名:接口漂移(Interface Drift) 🔍 主诉:Provider 悄悄改了字段名,Consumer 上线时才发现 🩺 检查:过去 3 个月因"字段/协议不一致"导致的事故数量 💊 处方:Pact 契约测试 · Provider 每次改动跑一次契约验证 📖 出处:Ian Robinson《Consumer-Driven Contracts》Blog

# 6.2 容器测试落地

Testcontainers · Java 8+ 集成测试神器——用 Docker 容器提供真实 DB / MQ / Redis:

@Testcontainers
class OrderRepositoryTest {
    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15")
        .withDatabaseName("test")
        .withUsername("test")
        .withPassword("test");

    @DynamicPropertySource
    static void props(DynamicPropertyRegistry r) {
        r.add("spring.datasource.url", postgres::getJdbcUrl);
    }

    @Test
    void 保存订单_能被查回() {
        Order order = new Order("K001", 100);
        repo.save(order);
        Order loaded = repo.findByKey("K001");
        assertEquals(100, loaded.getAmount());
    }
}

为什么用 Testcontainers 不用 H2 内存库?

疑惑:H2 更快,为什么还要用真实 Postgres?

论证:

  1. H2 与 Postgres 有 SQL 方言差异——SELECT ... FOR UPDATE SKIP LOCKED 在 H2 上不 work、RETURNING 语法在 H2 部分兼容。
  2. 测试通过 ≠ 生产会通过——过去订单案例有一次库存超卖,就是 H2 上的 SELECT FOR UPDATE 生效但 Postgres 上锁行为不同。
  3. Testcontainers 启动只需 3-5 秒——一次进程启动,多个测试复用容器,摊薄下来单个测试 <1 秒。
  4. 数据佐证:Netflix 2019 报告——切换到 Testcontainers 后生产环境 DB 相关事故数下降 62%。

结论:Testcontainers 是集成测试的正解——真实中间件优先,避免"测试环境幻觉"。

Testcontainers 常用组件:

组件 Container 类
PostgreSQL / MySQL PostgreSQLContainer / MySQLContainer
Redis GenericContainer("redis:7")
Kafka KafkaContainer
Elasticsearch ElasticsearchContainer
MongoDB MongoDBContainer

# 6.3 高级带走清单

  • ① 五种测试替身按 Meszaros 五分法选——Mock 只 mock 外部依赖。
  • ② 契约测试放在服务边界——Provider 改字段先跑契约。
  • ③ 集成测试用 Testcontainers——拒绝 H2 幻觉。
  • ④ 集成测试和单测在 CI 里分层跑——单测在 pre-commit、集成在 merge 时、E2E 在 dev 部署后。
  • ⑤ 拒绝 "只测公共 API 不测 DB" 的单测策略——这是把 Bug 全部挤到集成测试。
  • ⑥ 集成测试也要 AAA + 断言精确——慢不代表可以水。

# 7. 主任查房

# 7.1 组织策略选型

主任关注面:测试不是"每个人自己写"——是"团队策略"决定"整体质量"。

测试策略地图 · 沈总在 Day 76 拉出来的:

                       ┌─────────────────────┐
                       │  组织级测试策略      │
                       └─────────┬───────────┘
                                 │
        ┌────────────────────────┼────────────────────────┐
        │                        │                        │
   ┌────▼─────┐             ┌────▼─────┐            ┌────▼─────┐
   │ 测试语言 │             │ 测试架构 │            │ 测试文化 │
   ├──────────┤             ├──────────┤            ├──────────┤
   │· 全 Java │             │· 金字塔  │            │· TDD 试点 │
   │  or Kotlin│             │  比例    │            │· CR 卡测试│
   │· Groovy  │             │· 5种替身 │            │· 变异测试 │
   │  Spock?  │             │  选型    │            │  Q2 试点  │
   └──────────┘             │· 契约    │            │· 无测试   │
                             │  测试    │            │  代码 CR 拒│
                             │· TC 覆盖│            │           │
                             └──────────┘            └──────────┘

核心策略决策 · 三选一:

策略 A · Classical / Detroit School:

  • 只在外部依赖用 Mock
  • 内部用真实对象或 Fake
  • 测试"结果状态"

策略 B · Mockist / London School:

  • 每一层都 Mock
  • 测试"交互行为"(verify)

策略 C · 混合式:

  • 单测用 Classical
  • 集成用真实对象 + Testcontainers
  • E2E 用 Playwright

沈总的选择:策略 C 混合式——理由:

  1. Classical 测试更稳定、更不容易 Flaky(Khorikov 数据:Classical Flaky 率 <2%,Mockist >8%)。
  2. Mockist在新代码 TDD 时有优势——边写边设计。
  3. 混合式兼顾存量代码保命和新代码演进——订单案例是存量为主,混合式最合适。

# 7.2 抖动测试治理

Flaky 测试是团队最大的隐性成本——据 Google 2020 数据:

  • 大型代码库里 1.5% 的测试是 Flaky——看起来不多。
  • 但Flaky 触发的重跑消耗了 CI 总时间的 16%——每天几千小时机器成本。
  • 更致命的:Flaky 会让团队学会"忽略红"——真 Bug 也被当成 Flaky 无视。

Flaky 的四大 root cause · 分类治理:

Cause 症状 治理
时间依赖 Thread.sleep(1000) / System.currentTimeMillis() 引入 Clock 抽象 · 用 Awaitility 显式等待
顺序依赖 testB 依赖 testA 先跑 每个测试自 Arrange · 用 @DirtiesContext
并发依赖 多线程测试断言竞态 用 CountDownLatch · 拒绝 sleep
外部依赖 网络/DNS/时钟不稳 Testcontainers · WireMock · 隔离外部

Flaky SLO 建设:

SLI · Flaky Rate = Flaky Test / Total Test
SLO · <1%(滚动 30 天)
Error Budget · 每月允许 20 个 Flaky

治理流程:

  1. CI 检测——同一测试同一 commit 两次运行结果不一致 → 打标 @Flaky。
  2. 自动隔离——@Flaky 测试不再计入 CI 结果,但每晚报告。
  3. 限期修复——每周责任人 review 一次,超过 30 天未修的 Flaky 直接删除。
  4. 禁止新增 @Ignore——想 Ignore 必须走 EXCEPTION 表。

📋 病案编号:A12 📛 病名:Flaky 累积(Flaky Accumulation) 🔍 主诉:Flaky 测试越积越多,团队集体学会"重跑一次" 🩺 检查:CI 重跑率 >20% · @Ignore 的测试 >50 个 💊 处方:建立 Flaky SLO · 自动隔离 · 定期清理 📖 出处:Google Blog 2020《Flaky Tests at Google》

# 7.3 架构师带走清单

  • ① 组织级测试策略在架构 RFC 里写死——新项目直接遵循,不再讨论。
  • ② Flaky SLO 独立考核——测试稳定性是团队 SRE 指标之一。
  • ③ Testcontainers 作为集成测试标配——H2/嵌入式 DB 只用于最简单的 CRUD 演示。
  • ④ 契约测试作为跨团队边界规范——接口变更必须先跑契约。
  • ⑤ TDD 只在新模块试点,遗留代码走"特征测试 + 逐步补测"——回扣第 06 篇。
  • ⑥ 测试代码的 CR 严格程度不低于生产代码——T12 测试腐化立杀。
  • ⑦ 建立"测试基础设施团队"——专人维护 Test Utils · Fixture · Container 镜像 · WireMock 桩。

📋 病案编号:A13 📛 病名:测试基础设施缺位(Test Infra Absent) 🔍 主诉:每个开发者自己写测试脚手架,重复且质量不稳 🩺 检查:跨项目相似的 TestUtils 代码 · 无共享 Fixture 库 💊 处方:建立公司级 Test Kit · 共享 Fixture · WireMock Stubs · Testcontainers Templates 📖 出处:Google 内部《Testing on the Toilet》系列


# 8. 术后康复曲线

# 8.1 覆盖断言变化

Day 71(PaymentGate 事故)vs Day 78(本篇结束):

维度 Day 71 Day 78 变化
PaymentGate 单测数 0 24 从 0 到 1
PaymentGate 分支覆盖率 0% 91% +91% ⬆
PaymentGate 变异杀死率 0% 87% +87% ⬆
全项目分支覆盖率 68% 74% +6% ⬆
全项目变异杀死率 63% 71% +8% ⬆
Testcontainers 集成测试 0 42 从 0 到 1
Pact 契约测试 0 18 从 0 到 1
Flaky 测试比例 未统计 0.7% 达到 SLO
测试运行总时长 单测 12min 单测 3min + 集成 5min ★重排
Mock 数(平均/测试) 8.2 2.4 -70% ⬇(切 Fake)

关键收益:

  • 1 个救回的 22 万事故(Day 74 那次 IDEA 重构,测试直接抓住重复审计问题)。
  • 团队"敢改代码"信心从 7 分升到 9 分(自评,10 分制)。
  • QA 人工回归时间从 3 天降到 1 天(其余部分被自动化替代)。

# 8.2 运行时长稳定

测试运行时长的极致优化:

Day 71 · CI 全套 22 分钟
─────────────────────────
- 单测 12 分钟(大量 Spring 启动)
- 集成 6 分钟(H2 内存库)
- E2E 4 分钟

Day 78 · CI 全套 9 分钟  ↓ 59%
─────────────────────────
- 单测 3 分钟(切 Mockito · 不启 Spring)
- 集成 5 分钟(Testcontainers 复用容器)
- E2E 1 分钟(只跑冒烟)

优化手段:

  • 单测拆离 Spring——@ExtendWith(MockitoExtension.class) 代替 @SpringBootTest——单测启动从 6 秒 → 40 毫秒。
  • Testcontainers 单例模式——一次 CI 复用同一 Postgres 实例——启动时间从 5 秒 × 42 次 → 5 秒 × 1 次。
  • 并行测试——JUnit 5 @Execution(ParallelExecution.CONCURRENT) + maven-surefire -T 4——总时长 -50%。

测试稳定性 · Flaky 治理:

Day 71 · Flaky 率未统计 · 团队猜大约 8%
Day 78 · Flaky 率 0.7% · 达成 SLO <1%

被隔离并修复:
- 8 个"时间依赖"Flaky → 引入 Clock 抽象
- 5 个"顺序依赖"Flaky → 加 @DirtiesContext
- 3 个"并发"Flaky → CountDownLatch 替 sleep

# 8.3 一句话回顾

覆盖率与断言密度双升 + 运行时长稳定,才是测试真正保命的证据。

# 8.4 核心要点

  • 覆盖率不是目标,断言密度才是
  • 运行时长决定测试能否被频繁执行
  • 慢测试等于没测试

# 8.5 带走清单

  • [ ] 记录覆盖率 + 断言密度趋势
  • [ ] 单元测试单次运行不超过 60s
  • [ ] 每月复盘慢测试并优化

# 9. 病案归档

# 9.1 T 系列编号

编号 病名 病理 处方
T07 测试不可写 类设计得根本无法单测 DI + 接口抽象 + 找接缝
T08 Mock 过度 10+ mock,测的是 mock 优先 Fake,只 Mock 外部
T09 替身脆弱 Mock 依赖实现细节 verify 只用于契约边界
T10 测试太慢 全套 30 分钟无人跑 金字塔 + Testcontainers + 并发
T11 Flaky 测试 时好时坏 分类治理 · SLO 化
T12 测试代码腐化 测试没重构 测试 = 一等公民 · CR 同标准
A11 接口漂移 字段悄悄变导致事故 Pact 契约测试
A12 Flaky 累积 学会重跑忽略红 SLO + 自动隔离 + 定期清理
A13 测试基础设施缺位 各自写脚手架 公司级 Test Kit

# 9.2 关联病案索引

  • 回扣:T01-T06(第 08 篇覆盖率真相)——本篇是"如何写好测试"的续篇。
  • 回扣:F01 超长函数 · C02 God Class(第 03 篇)——测试写不出的根因都是函数/类的设计问题。
  • 回扣:Feathers 五步 SOP(第 06 篇)——打特征测试就是五步 SOP 的具象化。
  • 回扣:R03 提炼函数 · R08 提炼类(第 10 篇)——"能测"是"能重构"的前提。
  • 预告:R01-R08 代码审查病案(第 12 篇)——如何在 CR 层面把测试卡住。

# 9.3 一句话回顾

T07-T12 病案编号 + 关联病案索引,就是团队测试文化的最小执行合集。

# 9.4 核心要点

  • 每个编号都对应一类可修复的问题
  • 编号让测试评审有据可依
  • 归档是团队测试资产的复利

# 9.5 带走清单

  • [ ] T07-T12 表进 CR 模板
  • [ ] 每季度补充新编号
  • [ ] 编号命中率纳入季度指标

# 10. 综合案例串讲

# 10.1 病例真相揭晓

回到 1.3 的 7 个疑问,逐条作答:

① 单测/集成/E2E 比例? —— 金字塔 80/15/5——Unit 数量多、速度快、定位准;E2E 数量少、慢但真实;Integration 居中。冰淇淋反模式(E2E 太多)是团队测试策略最大的失败。

② 五种替身怎么选? —— Meszaros 五分法:Mock 只 mock 外部依赖(HTTP/DB/MQ);Fake 优先于 Mock——因为 Fake 依赖行为契约,Mock 依赖预设值——Fake 更耐重构。Stub 只做"孤立返回值";Spy 用于"本体仍执行时看是否被调过";Dummy 只做参数占位。

③ 什么代码天然可测? —— 依赖显式、无静态调用、无隐性上下文的代码天然可测——这些特性本质就是好设计(SOLID)。"测试不好写"是设计问题的最强信号——Robert Martin 名言:测试是设计的探测器。

④ Testcontainers 慢,还用不用? —— 要用。启动 3-5 秒 + 单例复用摊薄后单测 <1 秒。H2 与 Postgres 方言差异会让"测试通过 ≠ 生产通过"——Netflix 切换后 DB 相关事故 -62%——真实中间件优先于虚假的"快"。

⑤ 契约测试是什么? —— 服务边界的"接口契约验证"——Consumer 定义期望 · Provider 独立验证——不需要部署对方就能验证 API 变更兼容性。契约测试 = 集成测试的进化版,专门解决"接口漂移"(A11)问题。

⑥ Flaky 怎么治? —— 四大 root cause 分类治理:时间(用 Clock 抽象)、顺序(@DirtiesContext)、并发(CountDownLatch)、外部(Testcontainers)。建立 Flaky SLO <1% · 自动隔离 · 30 天不修就删。"重跑一次"是团队测试文化的癌症开端。

⑦ TDD 该不该在遗留代码上做? —— 不该。TDD 是"边设计边测"——遗留代码没有设计空间——应该走"特征测试 + 五步 SOP"(第 06 篇)。TDD 只在新模块试点(NextDaySvc,第 13 篇)。

# 10.2 兜底完整过程

用 Day 73 给 PaymentGate 补测试作为完整流程:

Day 73 · 08:00 · Step 1 · 识别 change points
  PaymentGate 有 4 个即将重构的方法:
  - pay(PayRequest)
  - refund(RefundRequest)  
  - checkStatus(String)
  - cancel(String)

Day 73 · 08:30 · Step 2 · 找接缝
  4 个硬依赖:
  - static System.currentTimeMillis() → 引入 Clock
  - RateLimiter.INSTANCE 单例 → 引入 RateLimiter 接口
  - new PaymentClientImpl() → 引入 @Autowired
  - internal Map<> → 引入 IdempotentStore 接口

Day 73 · 10:00 · Step 3 · 依赖注入重构
  用 R07 搬家 + R08 提炼类 + R15 委托取代继承
  4 个硬依赖变成 4 个 @Autowired

Day 73 · 12:00 · Step 4 · 打特征测试
  每个方法 6 个 case,共 24 个特征测试:
  - 正常 case × 4
  - 异常 case × 4  
  - 边界 case × 4
  - 幂等 case × 4
  - 限流 case × 4
  - 副作用(审计写入)case × 4

Day 73 · 15:00 · Step 5 · 跑 PIT 变异测试
  存活变异体 3 个:
  - RETURN_VALUE_NULL 存活 → 补断言 assertEquals(Status.SUCCESS,...)
  - BOUNDARY 存活 → 补边界测试
  - CONDITION 存活 → 补反向条件测试
  重跑 PIT:变异杀死率 62% → 87%

Day 73 · 17:00 · Step 6 · Testcontainers 集成测试
  用真实 Postgres 跑幂等 store 集成测试
  发现 H2 版通过但 Postgres 上 SELECT FOR UPDATE 行为不同
  → 修 SQL 语法 → 5 个集成测试稳定

Day 73 · 19:00 · 完成
  - 24 个单测 + 5 个集成测试
  - 分支覆盖 0% → 91%
  - 变异杀死率 0% → 87%
  - Flaky 率 0
  - Day 74 提炼函数 IDE 一键重构 —— 3 个测试 fail —— 事故拦截

这就是一次完整的"给遗留代码打特征测试"过程——从 0 测试到 87% 变异杀死率,一天完成——换来的是 Day 74 22 万事故的拦截。

# 10.3 设计哲学回扣

从本篇沉淀出跨篇适用的两条设计哲学:

哲学 · 测试即麻醉

手术不打麻醉是酷刑,重构没有测试是赌博。IDEA 保证的是编译器眼里的等价,不是业务眼里的等价——只有测试能承诺"业务行为不变"。没有测试的重构,是在没有安全网的高空走钢丝——你走过 100 次不代表第 101 次不摔。

哲学 · 测试即设计探测器

"测试写不出来"从来不是"测试问题"——是"设计问题"。代码能不能被隔离测试 = 依赖是否显式 = SOLID 是否遵守。当你觉得测试难写时,你实际上在告诉自己:代码耦合太紧、职责太多、依赖太隐性。修测试,本质是修设计——Robert Martin 全书的核心洞察。

# 10.4 速查一图

测试金字塔速查:

层 比例 用什么 单个耗时
Unit 80% Mockito <100ms
Integration 15% Testcontainers <5s
E2E 5% Playwright <30s

五种替身速查(Meszaros):

替身 用途
Dummy 参数占位不用
Stub 提供预设返回值
Spy 记录调用+本体执行
Mock 全替身+行为验证
Fake 简化真实实现

T07-T12 病案速查:

症状 病名 处方
测试不可写 T07 DI + 接缝
10+ mock T08 Fake 优先
verify 太多 T09 只在契约边界
CI 太慢 T10 金字塔+并发
时好时坏 T11 Flaky SLO
测试没重构 T12 一等公民

Flaky 四大根因:时间、顺序、并发、外部——分类治理。

测试黄金五守则:AAA 分段 · 精确断言 · 场景命名 · Mock 只外部 · 测试 = 一等公民。


练习题:找你项目里最"不敢改"的一段代码,回答:

  1. 它有几个硬依赖?(new / static / 单例 / ThreadLocal)
  2. 找一下"接缝"——哪些依赖能抽象成接口?
  3. 写 3 个特征测试——不要求完美,要求能抓变异。
  4. 用 PIT 跑一下——变异杀死率多少?

下集预告:Day 79 早会,团队发现了一个可怕的现象——过去一周的 PR 里,87% 通过时只有一句评论:"lgtm"(Looks Good To Me)——沈总把这数据摔在桌上:"没有真正的 CR,一切品质工程都是墙上的挂图。"**第 12 篇《代码审查文化》即将开始——外科第三篇:康复训练——如何把品质从"个人英雄"变成"团队默认"。

# 10.5 全文快速回顾

  • 第 1 章:重构血案 + 特征测试救场
  • 第 2 章:无测试代码的六种恐惧 + T 系列
  • 第 3 章:不好测本质是设计问题
  • 第 4 章:五种测试替身选型
  • 第 5-7 章:三视角测试策略
  • 第 8 章:覆盖率 / 断言密度 / 运行时长
  • 第 9 章:T07-T12 归档

# 10.6 核心要点串联

  1. 测试是设计的镜子:难测即设计有病
  2. 特征测试保命:无理解也能兜底
  3. 替身要清楚:不明白就不要 Mock
  4. 速度即生命:慢测试终将被抛弃
  5. 文化托底:'先测试再重构'是红线

# 10.7 常见误区汇总

  • ❌ 追求 100% 覆盖率而无断言
  • ❌ 过度 Mock,把实现细节测死
  • ❌ 测试和被测代码强耦合
  • ❌ 测试运行超过 2 分钟不优化
  • ❌ 事故后不补测试

# 10.8 带走清单总表

  • [ ] 重构前必写特征测试
  • [ ] T07-T12 表贴到 CR 模板
  • [ ] 五种替身选型进 CR 检查
  • [ ] 单元测试运行时长纳入门禁
  • [ ] 事故复盘必产出至少 1 条测试

📌 [本篇一句话] 测试是重构的麻醉剂 · 五替身按 Meszaros 选 · Testcontainers 拒绝 H2 幻觉 · Flaky SLO <1%——没有测试的重构就是命悬一线。

上一篇 ← 第 10 篇 · 重构十八招式详解 | 下一篇 → 第 12 篇 · 代码审查文化建设

上次更新: 2026/07/16, 11:32:10
10.重构十八招式详解
12.代码审查文化建设

← 10.重构十八招式详解 12.代码审查文化建设→

最近更新
01
14.给3年前自己的一封信
07-21
02
13.技术债与遗产系统治理
07-21
03
12.技术团队建设能力
07-21
更多文章>
Theme by Vdoing | Copyright © 2019-2026 杨充 | MIT License | 鄂ICP备2024073355号-1 | 鄂ICP备2024073355号
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式