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 重构血案回放
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 核心洞察:
"当你觉得测试写不出来时,你实际上是在告诉自己:代码耦合太紧。"
换句话说:代码的可测试性 = 代码的解耦度 = 代码的设计质量。
为什么这样?
疑惑:为什么"能测"就等于"设计好"?
论证:
- 能被测意味着能被隔离运行——这要求依赖是显式的、可替换的。
- 依赖显式可替换 = 单一职责 + 依赖倒置 + 接口分离——这是 SOLID 原则。
- 反向验证:God Class 通常测试不了——因为 God Class 违反了单一职责,测试要同时构造 20 个依赖。
- 数据佐证:Google 2016 《Testing at Google》——可测性评分与软件缺陷密度呈负相关 -0.68。
- 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?
论证:
- Mock 只是"预设行为"——测试用例改一次,Mock 就要改一次——脆弱。
- Fake 是"真实但简化"——行为契约不变,测试用例只依赖行为——健壮。
- Google 内部指南:"优先用 Fake,然后 Stub,最后才 Mock"——因为 Fake 的变更成本最低。
- 反向验证:订单案例的 IdempotentStore——一开始用 Mock(
when(store.containsKey))——测试改了 30 次;改成 Fake(InMemoryIdempotentStore)——改动次数为 0。 - 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?
论证:
- H2 与 Postgres 有 SQL 方言差异——
SELECT ... FOR UPDATE SKIP LOCKED在 H2 上不 work、RETURNING语法在 H2 部分兼容。 - 测试通过 ≠ 生产会通过——过去订单案例有一次库存超卖,就是 H2 上的
SELECT FOR UPDATE生效但 Postgres 上锁行为不同。 - Testcontainers 启动只需 3-5 秒——一次进程启动,多个测试复用容器,摊薄下来单个测试 <1 秒。
- 数据佐证: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 混合式——理由:
- Classical 测试更稳定、更不容易 Flaky(Khorikov 数据:Classical Flaky 率 <2%,Mockist >8%)。
- Mockist在新代码 TDD 时有优势——边写边设计。
- 混合式兼顾存量代码保命和新代码演进——订单案例是存量为主,混合式最合适。
# 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
治理流程:
- CI 检测——同一测试同一 commit 两次运行结果不一致 → 打标
@Flaky。 - 自动隔离——
@Flaky测试不再计入 CI 结果,但每晚报告。 - 限期修复——每周责任人 review 一次,超过 30 天未修的 Flaky 直接删除。
- 禁止新增
@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 只外部 · 测试 = 一等公民。
练习题:找你项目里最"不敢改"的一段代码,回答:
- 它有几个硬依赖?(
new/ static / 单例 / ThreadLocal) - 找一下"接缝"——哪些依赖能抽象成接口?
- 写 3 个特征测试——不要求完美,要求能抓变异。
- 用 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 核心要点串联
- 测试是设计的镜子:难测即设计有病
- 特征测试保命:无理解也能兜底
- 替身要清楚:不明白就不要 Mock
- 速度即生命:慢测试终将被抛弃
- 文化托底:'先测试再重构'是红线
# 10.7 常见误区汇总
- ❌ 追求 100% 覆盖率而无断言
- ❌ 过度 Mock,把实现细节测死
- ❌ 测试和被测代码强耦合
- ❌ 测试运行超过 2 分钟不优化
- ❌ 事故后不补测试
# 10.8 带走清单总表
- [ ] 重构前必写特征测试
- [ ] T07-T12 表贴到 CR 模板
- [ ] 五种替身选型进 CR 检查
- [ ] 单元测试运行时长纳入门禁
- [ ] 事故复盘必产出至少 1 条测试
📌 [本篇一句话] 测试是重构的麻醉剂 · 五替身按 Meszaros 选 · Testcontainers 拒绝 H2 幻觉 · Flaky SLO <1%——没有测试的重构就是命悬一线。
上一篇 ← 第 10 篇 · 重构十八招式详解 | 下一篇 → 第 12 篇 · 代码审查文化建设