Mock 是一把双刃剑:用对了让测试快十倍,用错了让测试骗你一辈子。 当你的单元测试里 mock 了五个依赖、断言了十个调用次数,重构时测试全红却抓不住任何真实缺陷——这不是测试,这是对实现的复述。本文要解决的核心问题是:在哪个层级该 Mock、该用哪种测试替身、如何让虚拟服务不偏离真实服务,把服务虚拟化从"随手 mock 一下"升级为一套有边界、有契约、有治理的工程实践。
一、Mock 的收益与代价
1.1 为什么要隔离依赖
真实依赖带来的问题:
· 慢:一次网络调用 50~500ms,几百个测试累积成分钟级
· 不稳定:第三方服务限流、超时、偶发 5xx
· 不可控:无法构造"超时""余额不足""库存为 0"等边界场景
· 不可得:依赖尚未开发完成(前后端并行)、已下线(旧系统)
· 有成本:调用真实支付/短信/地图 API 要花钱
· 有副作用:真实写库、真实发消息、真实扣款
1.2 过度 Mock 的代价
| 代价 | 表现 | 后果 |
|---|---|---|
| 测试与实现耦合 | 改内部函数名测试就红 | 重构阻力,测试变成负担 |
| 假通过 | mock 行为与真实服务不一致 | 生产事故,测试给虚假安全感 |
| 断言实现而非行为 | verify(mock).called(3) | 无法感知真实缺陷 |
| 维护成本爆炸 | 每个测试 20 行 mock 设置 | 写测试比写代码累 |
| 掩盖设计问题 | 依赖过多才需要大量 mock | 错过重构信号 |
一句话:Mock 的合理性判断标准是"这个依赖我是否真的无法在测试里用真实的"——能用真实的就用真实的(Testcontainers),实在不行才虚拟化。
二、测试替身谱系与语义边界
2.1 五种替身的准确定义
| 替身类型 | 提供什么 | 是否断言 | 典型用途 |
|---|---|---|---|
| Dummy | 占位,永不使用 | 否 | 填充必填参数 |
| Stub | 预设返回值 | 否 | 让被测代码能跑下去 |
| Spy | 记录调用 + 可包装真实对象 | 是(间接) | 验证副作用被触发 |
| Mock | 预设期望,未满足即失败 | 是 | 验证交互契约 |
| Fake | 轻量真实实现 | 否 | 内存数据库、内存队列 |
关键区别:Stub 是"我告诉你返回什么",Mock 是"我要求你被这样调用"。
Martin Fowler 的忠告:
"Mock 用得越多,测试与实现耦合越紧。"
→ 优先 Stub / Fake,谨慎 Mock 的严格期望。
判断口诀:
断言"结果对不对" → 用 Stub / Fake(状态验证)
断言"有没有通知别人" → 用 Mock / Spy(行为验证)
行为验证只在"通知别人"本身就是核心职责时使用。
2.2 各测试层级该虚拟化什么
单元测试(纯逻辑)
· 虚拟化:所有外部依赖(DB、HTTP、时钟、随机数)
· 手段:Stub / Fake,函数参数注入
· 禁止:真实网络、真实文件系统
组件测试(单个服务 + 真实基础设施)
· 虚拟化:只虚拟化"本服务之外的其它服务"
· 手段:Testcontainers 起真实 DB/Redis + WireMock 桩掉第三方
· 原则:自己的数据库用真的,别人的服务用桩的
集成测试(多服务协作)
· 虚拟化:尽量少,只桩掉"无法在测试环境部署"的外部依赖
· 手段:WireMock / Mountebank + 真实消息中间件
· 原则:能真则真,Mock 只用于外部世界
E2E 测试
· 虚拟化:第三方支付、短信、邮件等外部 SaaS
· 手段:厂商提供的 Sandbox 优先,其次服务虚拟化
· 原则:内部服务绝不 Mock,否则 E2E 失去意义
一句话:Mock 的边界应当与团队的所有权边界重合——自己团队维护的东西用真实的,别人的东西才虚拟化。
三、Mock 的边界:什么不该 Mock
3.1 不该 Mock 的清单
✗ 你不拥有的类型的第三方库内部(Mock 库的内部实现)
✗ 数据结构 / DTO / 值对象(直接用真实构造)
✗ 自己的领域模型与业务逻辑(那是在测 mock 不是在测代码)
✗ 语言标准库(时间、集合、字符串)
✗ 同一进程内的纯函数(直接调用)
✓ 应该 Mock 的:
✓ 跨进程/跨网络的远程调用
✓ 不可控的第三方付费服务
✓ 非确定性来源(时间、UUID、随机数、当前用户)
✓ 有严重副作用的操作(发邮件、扣款、删数据)
✓ 尚未实现或已下线的依赖
3.2 时间与非确定性的处理
// ✗ 反例:mock 全局 Date,脆弱且影响其它测试
vi.spyOn(global, 'Date').mockReturnValue(new Date('2026-10-04'));
// ✓ 正例一:注入 Clock 抽象
interface Clock { now(): Date }
class SystemClock implements Clock { now() { return new Date(); } }
class FixedClock implements Clock {
constructor(private fixed: Date) {}
now() { return this.fixed; }
}
// ✓ 正例二:Vitest 假定时器(可控、自动清理)
import { vi, beforeEach, afterEach } from 'vitest';
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-10-04T10:00:00+08:00'));
});
afterEach(() => vi.useRealTimers());
// ✓ 正例三:测试库内建时间旅行(Java)
// java.time.Clock.fixed(instant, zone) 注入到被测对象
四、WireMock:HTTP 层服务虚拟化
4.1 为什么选 HTTP 层而不是代码层
代码层 Mock(如 Mockito / unittest.mock)
· 与语言/框架绑定,跨语言服务无法复用
· 只能被"同一个进程"的测试使用
· 无法被手工联调、演示环境复用
HTTP 层虚拟化(WireMock / Mountebank)
· 桩以 JSON 声明,语言无关,前后端共享
· 可以被真实进程调用(本地开发、联调、演示)
· 支持录制-回放,从真实流量生成桩
· 支持有状态场景(先 POST 创建,再 GET 查到)
4.2 WireMock 桩定义
{
"request": {
"method": "GET",
"urlPathPattern": "/api/inventory/.*",
"headers": { "Authorization": { "matches": "Bearer .+" } }
},
"response": {
"status": 200,
"headers": { "Content-Type": "application/json" },
"jsonBody": { "sku": "SKU-9", "available": 42, "warehouse": "SHA-01" },
"fixedDelayMilliseconds": 120
}
}
{
"scenarioName": "订单状态流转",
"requiredScenarioState": "Started",
"newScenarioState": "已支付",
"request": { "method": "POST", "url": "/api/orders" },
"response": { "status": 201, "jsonBody": { "id": "ord_1", "status": "PENDING" } }
}
4.3 有状态场景(Stateful Scenario)
场景:模拟"下单 → 支付 → 查询已支付"的完整流转
Step 1 POST /api/orders requiredState=Started newState=已创建
Step 2 POST /api/orders/1/pay requiredState=已创建 newState=已支付
Step 3 GET /api/orders/1 requiredState=已支付 → 返回 PAID
价值:让依赖方测试能走完"多步交互"的真实路径,
而不是每个请求都返回同一个固定响应。
4.4 故障注入
// JUnit 5 + WireMock 的测试写法
import static com.github.tomakehurst.wiremock.client.WireMock.*;
class InventoryClientTest {
@RegisterExtension
static WireMockExtension wm = WireMockExtension.newInstance()
.options(wireMockConfig().dynamicPort()).build();
@Test
void 库存服务超时时应降级为默认值() {
wm.stubFor(get(urlPathMatching("/api/inventory/.*"))
.willReturn(aResponse().withFixedDelay(3000)
.withStatus(200).withBody("{}")));
var client = new InventoryClient(wm.baseUrl(), Duration.ofMillis(500));
assertThat(client.available("SKU-9")).isEqualTo(0); // 降级值
}
@Test
void 库存服务返回500时应抛出可识别异常() {
wm.stubFor(get(urlPathMatching("/api/inventory/.*"))
.willReturn(aResponse().withStatus(500)));
var client = new InventoryClient(wm.baseUrl(), Duration.ofSeconds(1));
assertThatThrownBy(() -> client.available("SKU-9"))
.isInstanceOf(UpstreamException.class);
}
}
一句话:
withFixedDelay与withStatus(500)是服务虚拟化最有价值的能力——你能用一行桩代码制造真实服务永远不敢制造的故障。
4.5 录制-回放生成桩
# 1. 以代理模式启动 WireMock,指向真实服务
java -jar wiremock-standalone.jar --port 8080 --proxy-all=https://api.real.com --record-mappings
# 2. 让测试/流量打到 localhost:8080,WireMock 转发并记录
curl http://localhost:8080/api/inventory/SKU-9
# 3. mappings/ 目录生成可复用的桩文件,纳入版本控制
ls mappings/
# get-api-inventory-SKU-9.json
五、Mountebank 与多协议虚拟化
5.1 为什么需要多协议
WireMock 覆盖 HTTP/HTTPS。但真实系统的依赖远不止 HTTP:
· gRPC(内部微服务主流) · TCP 裸协议(数据库、缓存、私有协议)
· SMTP(邮件) · AMQP / Kafka(消息)
· SOAP / WSDL(老系统)
Mountebank 的价值:一套工具虚拟化多协议,用 imposter 概念统一管理。
5.2 Mountebank 的 HTTP 与 TCP 桩
{
"port": 4545,
"protocol": "http",
"name": "payment-gateway",
"stubs": [
{
"predicates": [
{ "equals": { "method": "POST", "path": "/charge" } },
{ "contains": { "body": "\"amount\":500" } }
],
"responses": [
{ "is": { "statusCode": 200,
"body": { "txnId": "txn_1", "status": "APPROVED" },
"headers": { "Content-Type": "application/json" } } }
]
},
{
"predicates": [{ "contains": { "body": "\"amount\":99999" } }],
"responses": [
{ "is": { "statusCode": 402,
"body": { "code": "INSUFFICIENT_FUNDS" } } }
]
}
]
}
{
"port": 8500,
"protocol": "tcp",
"name": "legacy-socket-service",
"mode": "text",
"stubs": [
{
"predicates": [{ "startsWith": { "data": "PING" } }],
"responses": [{ "is": { "data": "PONG\n" } }]
}
]
}
5.3 服务虚拟化选型对照
| 工具 | 协议支持 | 状态化 | 录制回放 | 部署形态 | 适用场景 |
|---|---|---|---|---|---|
| WireMock | HTTP/HTTPS | 支持 scenario | 支持 | JVM / Docker | HTTP 依赖主流选择 |
| Mountebank | HTTP/TCP/SMTP | 支持 | 有限 | Node / Docker | 多协议、非 HTTP |
| MSW | HTTP(浏览器/Node) | 有限 | 无 | 库(进程内) | 前端 / Node 测试 |
| Hoverfly | HTTP | 支持 | 强 | Go 二进制 | 流量录制回放 |
| MockServer | HTTP/HTTPS | 支持 | 支持 | JVM / Docker | 与 WireMock 类似 |
| Testcontainers | 任意(跑真容器) | 真实 | 不适用 | Docker | 有真实现时的首选 |
一句话:有真实现可用时,Testcontainers 永远优先于服务虚拟化——虚拟化的正当理由只有"真实依赖不可得、不可控、或有成本"。
六、契约协同:避免 Mock 偏离真实
6.1 纯 Mock 的致命缺陷
场景:支付服务团队改了响应字段名 amount → totalAmount
· 消费方单元测试:mock 返回 { amount: 500 } → 依然通过 ✓
· 消费方集成测试:mock 返回 { amount: 500 } → 依然通过 ✓
· 生产环境:真实响应是 { totalAmount: 500 } → 解析失败 ✗
根因:桩是"消费方想象的服务",从未与真实服务对齐。
解法:让桩由契约生成 / 由契约验证。
6.2 契约与桩的三种协同方式
方式一:契约生成桩(推荐)
Pact / Spring Cloud Contract 从契约文件自动生成 WireMock 桩
→ 桩必然与契约一致,契约由 Provider 验证
→ 桩漂移在源头被消除
方式二:契约验证桩
桩独立维护,但 CI 中用契约对桩做一致性校验
→ 桩与契约不符即失败
方式三:录制真实流量生成桩(次优)
从测试环境录制真实响应生成桩
→ 快,但真实服务的错误行为也会被固化
→ 需配合"桩过期检测"(录制时间 + 服务版本标记)
# Spring Cloud Contract:契约自动生成 WireMock 桩 + Provider 验证
# src/test/resources/contracts/payment/charge.groovy
# Contract.make { request { ... } response { body([txnId: $(anyNonBlankString()),
# status: 'APPROVED']) } }
# 构建后自动产出:
# build/stubs/META-INF/.../mappings/charge.json ← 消费方直接用
# build/generated-test-sources/.../ChargeTest.java ← Provider 自动验证
一句话:服务虚拟化解决"测试跑得起来",契约测试解决"桩没有说谎"——两者是互补关系,缺一不可。
七、Stub 漂移与治理
7.1 漂移的四种形态
| 漂移形态 | 例子 | 检测手段 |
|---|---|---|
| 字段漂移 | 真实服务加了必填字段,桩没有 | 契约验证 |
| 状态码漂移 | 真实返回 201,桩返回 200 | 契约验证 |
| 时序漂移 | 真实有 200ms 延迟,桩瞬时返回 | 定期录制比对 |
| 行为漂移 | 真实分页,桩返回全量 | 集成测试 + 契约 |
7.2 治理机制
机制一:桩纳入版本控制并标记来源
mappings/charge.json 中写入元数据:
"_meta": { "source": "contract", "serviceVersion": "payment@1.7.2",
"recordedAt": "2026-09-20" }
机制二:桩过期告警
桩的 serviceVersion 与线上实际版本差异 > 2 个 minor → CI 告警
recordedAt 超过 90 天未更新 → 提醒重新录制
机制三:契约门禁
Provider 每次构建验证契约 → 契约变更立即通知消费方
消费方 CI 用最新契约重新生成桩 → 漂移在合并前暴露
机制四:定期"真桩比对"
每日定时任务:对同一请求,分别打真实服务与桩,
比对状态码 / 结构 / 关键字段 → 差异即告警
7.3 桩的命名与组织
目录约定:
mocks/
├── payment-service/
│ ├── mappings/ # 桩定义
│ ├── __files/ # 大响应体(二进制/长 JSON)
│ └── README.md # 负责人、真实服务地址、更新方式
└── sms-gateway/
└── ...
治理规则:
· 一个桩目录 = 一个真实服务,禁止跨服务混放
· 每个目录必须有 README(谁负责、何时更新)
· 桩必须能在 CI 中一键启动(docker-compose 或 testcontainers)
· 桩变更走 PR 评审,与契约变更同等对待
八、常见陷阱
| 陷阱 | 现象 | 规避 |
|---|---|---|
| 断言 mock 调用次数 | 重构即红,无缺陷价值 | 只断言最终行为与结果 |
| 桩与真实服务不一致 | 测试全绿,生产报错 | 契约生成桩 + 契约验证 |
| 每层都 Mock | 集成测试失去意义 | Mock 边界 = 团队所有权边界 |
| Mock 自己的领域模型 | 测的是 mock 不是代码 | 值对象直接用真实构造 |
| 桩无版本标记 | 无法判断是否过期 | 元数据 + 过期告警 |
| 只测 happy path 桩 | 错误处理零覆盖 | 每个桩配 5xx / 超时 / 空响应 |
| 桩写死在测试代码里 | 无法被联调/演示复用 | 桩独立成目录,多方共享 |
| 用 Mock 掩盖设计问题 | 依赖过多却视而不见 | 把"mock 数量"当重构信号 |
| 真实依赖可用却仍 Mock | 假通过风险 | Testcontainers 优先 |
九、总结
服务虚拟化的本质是在"依赖不可得"与"验证要真实"之间找平衡。测试替身谱系给出了清晰的语义边界——Stub 给返回值、Fake 给轻量实现、Mock 给严格期望,而绝大多数场景应当用前两者做状态验证,只在"通知别人"本身是核心职责时才用行为验证。虚拟化的位置应当与团队所有权边界重合:自己团队的东西用 Testcontainers 起真的,别人的服务才用 WireMock / Mountebank 桩掉。HTTP 层虚拟化(而非代码层)让桩语言无关、可跨团队复用、可支持手工联调,而 withFixedDelay、withStatus(500)、stateful scenario 让它能制造真实服务不敢制造的故障。但桩最大的风险是说谎——契约生成桩、契约验证桩、定期真桩比对,三道防线才能保证"桩不漂移"。落地记住五件事:Mock 边界等于所有权边界、优先 Stub/Fake 而非严格 Mock、桩由契约生成、桩纳入版本控制带元数据、能跑真容器就别虚拟化。当你的桩不再需要人工维护与真实服务对齐时,服务虚拟化才真正从"权宜之计"变成了"工程资产"。
延伸阅读:https://plumephp.com/contract-testing/ 了解 Pact 与消费者驱动契约如何为桩提供真实性保证,https://plumephp.com/integration-testing/ 了解 Testcontainers 与真实依赖的集成测试策略,https://plumephp.com/test-data-management/ 了解测试数据的构造与隔离。更多测试工程实践见 /posts/testing/。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。