返回自动化工程模块
Automation / Tutorial 13

Mock 与测试桩实战教程

控制暂时不可用或不可预测的依赖,稳定复现真实业务中的成功、失败和异常。

10 个章节库存 + 优惠 + 支付依赖pytest + HTTP Stub + Playwright
01

真实依赖让异常场景难以重复

先解决不可控

你要测试的下单链路

订单服务需要调用库存、优惠和支付服务。正常下单很容易验证,但你还需要稳定复现库存超时、支付重复回调、优惠服务返回错误字段等情况。等待真实依赖偶然出错,既慢也不可重复。

订单服务依赖三个外部结果才能完成下单
用户提交商品与地址
库存服务是否可以扣减
优惠服务实付金额
支付服务确认与回调
订单结果状态与数据

依赖还没开发完

先用约定好的请求和响应完成订单服务开发与测试,不必一直等待。

异常很难制造

通过测试桩固定返回超时、500 或畸形数据,让同一问题可以重复验证。

真实调用有代价

短信、支付和外部平台可能收费或产生真实副作用,测试必须隔离。

测试替身解决的是“依赖不可控”,不是证明真实集成一定正确。替身测试通过后,仍需要真实服务、沙箱或契约测试兜底。
02

先分清 Stub、Mock、Fake 和 Spy

不要都叫 Mock
测试替身不是一种工具,而是四种不同职责
01Stub

控制返回

02Mock

验证交互

03Fake

简化实现

04Spy

记录调用

四类测试替身承担不同任务

类型主要行为下单案例你要解决的问题
Stub(桩)预先返回指定结果库存服务固定返回“库存不足”控制输入,让场景稳定出现
Mock除了返回结果,还验证调用行为确认支付接口只调用一次检查是否按约定调用依赖
Fake用简化实现代替真实组件内存优惠券仓库提供可运行但非生产级的实现
Spy保留真实行为并记录调用记录订单仓库 save 的参数观察调用次数、顺序和参数

一句话判断

  • 只需要固定返回值:用 Stub。
  • 还要确认依赖被怎样调用:用 Mock。
  • 需要一个简化但可运行的实现:用 Fake。
  • 想保留行为并观察调用:用 Spy。
日常交流中把测试替身统称为 Mock 很常见,但设计测试时必须清楚你是在控制返回、验证交互,还是替换实现。
03

只替换本次不需要验证的边界

先确定测试目标
先确定验证目标,再决定替换范围
验证目标这次要证明什么
真实依赖是否可控且安全
替换边界只替换非目标
保留回归真实集成仍验证

商城下单中的替换选择

依赖为什么考虑替换本次选择仍需保留什么
第三方支付费用、限流、不可控且不应真实扣款支付沙箱或接口桩上线前仍要完成真实沙箱联调
库存服务需要稳定复现库存不足、超时和 500HTTP Stub保留一组真实集成回归
优惠计算函数只验证订单服务如何处理返回值函数 Stub优惠规则本身应有独立真实测试
订单数据库本次需要验证真实事务和落库不要替换使用隔离测试库和专用数据
页面调用订单接口验证前端对异常响应的提示Playwright 路由拦截主流程仍调用真实测试接口

决定之前问三个问题

  1. 本次真正要验证的是哪个组件和业务规则?
  2. 真实依赖能否稳定、安全、低成本地提供所需场景?
  3. 替换后会不会把本来应该发现的集成问题一起隐藏?
如果本次目标是验证订单数据库事务,就不能把数据库替换成内存 Fake;如果目标只是验证页面错误提示,拦截订单接口反而更直接。
04

用函数 Stub 固定优惠结果

pytest monkeypatch
函数 Stub 把不可控调用替换为固定结果
订单逻辑调用优惠计算
monkeypatch替换目标函数
超时 Stub稳定抛出异常
业务断言订单安全失败
固定优惠服务返回值
def test_coupon_timeout_does_not_create_order(monkeypatch):
    def timeout_stub(*args, **kwargs):
        raise TimeoutError("coupon service timeout")

    monkeypatch.setattr(
        "order_service.coupon_client.calculate",
        timeout_stub,
    )

    result = create_order(user_id="u-1", sku_id="sku-1")

    assert result.code == "COUPON_SERVICE_UNAVAILABLE"
    assert result.order_id is None

这个 Stub 控制了什么

  • 无论什么时候执行,都稳定抛出优惠服务超时。
  • 用例可以只关注订单服务怎样处理超时。
  • 测试没有调用真实优惠服务,也不会被网络状态影响。
  • 优惠服务自身是否正确,需要在它自己的测试中验证。
05

用 HTTP 测试桩代替远程服务

服务边界
HTTP 测试桩站在真实服务的网络位置
订单服务发送真实 HTTP
请求匹配方法、路径、字段
测试桩选择预设场景
固定响应成功、错误、延迟
订单断言结果和副作用
支付服务桩响应
{
  "request": {
    "method": "POST",
    "urlPath": "/payments/confirm",
    "bodyPatterns": [
      { "matchesJsonPath": "$.order_id" }
    ]
  },
  "response": {
    "status": 200,
    "jsonBody": {
      "payment_id": "pay-test-001",
      "status": "SUCCESS"
    },
    "fixedDelayMilliseconds": 800
  }
}

请求匹配

  • 方法、路径和必要请求头正确。
  • 订单号、金额和币种符合约定。
  • 不匹配时明确失败,避免桩无条件返回成功。
  • 每条场景使用独立映射和测试标识。

响应控制

  • 返回固定业务状态和字段。
  • 增加延迟复现慢响应和超时。
  • 返回 4xx、5xx 或连接中断。
  • 按调用次数返回不同结果,复现重试和恢复。
好的测试桩应该像真实服务一样校验请求。如果任何请求都返回成功,调用方即使传错字段,测试也不会失败。
06

用 Playwright 拦截页面接口

验证前端异常体验
浏览器拦截只替换页面与接口之间的响应
页面操作用户提交订单
route 拦截匹配请求
构造响应库存不足
页面处理提示与恢复
拦截创建订单接口
test('当库存不足时,页面提示用户修改数量', async ({ page }) => {
  await page.route('**/api/orders', async (route) => {
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify({
        code: 'OUT_OF_STOCK',
        message: '库存不足'
      })
    });
  });

  await page.goto('/checkout');
  await page.getByRole('button', { name: '提交订单' }).click();

  await expect(page.getByText('库存不足')).toBeVisible();
  await expect(page.getByRole('button', { name: '修改数量' })).toBeVisible();
});

浏览器拦截适合验证

  • 页面怎样展示业务错误。
  • 超时后按钮是否恢复可点击。
  • 服务错误时是否保留用户已经填写的内容。
  • 畸形响应是否触发安全兜底。
  • 不同响应状态下页面是否走向正确分支。
页面拦截验证的是前端行为,不证明真实订单接口会返回正确数据。核心下单主流程仍应调用真实测试接口完成 E2E 回归。
07

把难以复现的异常变成固定用例

异常场景库
异常场景从五个方向建立
业务失败

库存不足

技术失败

500 与断连

时间问题

延迟与超时

数据问题

缺字段和错类型

时序问题

重复与乱序

下单依赖的异常测试桩

场景桩返回或行为关键预期
库存不足200 + OUT_OF_STOCK提示库存不足,不创建订单
依赖超时连接超过约定时间提示稍后重试,不重复提交
服务错误500 + INTERNAL_ERROR安全失败,不展示内部堆栈
畸形响应缺少 amount 或类型错误前端或服务拒绝错误数据并记录异常
重复回调同一 payment_id 返回两次只更新一次订单,不重复扣减
乱序回调失败通知晚于成功通知到达状态不允许从成功退回失败

每个异常用例继续检查

  • 用户看到的提示是否明确且可操作。
  • 订单、库存、优惠券和支付状态是否保持一致。
  • 重试会不会产生重复订单或重复扣减。
  • 日志和指标是否记录依赖、耗时和业务 ID。
  • 恢复后能否继续完成原来的业务。
08

防止测试桩和真实接口越走越远

契约漂移
契约让消费者、测试桩和真实提供方保持一致
消费者预期记录请求与响应
测试桩按契约模拟
提供方验证流水线执行契约
变更反馈不兼容立即失败

测试桩必须跟随的接口契约

契约部分支付接口示例检查方式
请求方法和路径POST /payments/confirm桩配置与真实接口一致
请求字段order_id、amount、currency名称、类型和必填保持一致
成功响应200 + payment_id + status代码示例和提供方文档一致
错误响应业务码、message 和可重试标识异常用例不会依赖虚构字段
版本变化新增字段或枚举值契约测试及时发现桩已经过期

消费者提供预期

订单服务记录自己依赖的请求与响应结构,通过消费者契约测试确认调用代码和桩都符合预期。

提供方验证契约

支付服务在自己的流水线中执行这些契约,接口变更时及时发现哪些消费者会受到影响。

测试桩长期不校验契约,会变成“永远正确的假服务”:测试全部通过,真实联调却因为字段、枚举或错误码变化而失败。
09

把测试替身放进分层策略

替换越少越接近真实
越接近发布,越少依赖测试替身
真实沙箱联调验证最终集成
E2E少量受控异常
服务测试HTTP Stub 与 Fake
单元测试Stub、Mock、Spy

不同测试层级怎样使用替身

层级替身方式主要价值使用比例
单元测试函数 Stub、Mock、Spy大量规则和异常快速反馈
服务测试HTTP Stub、Fake 依赖订单服务独立验证异常与重试
集成测试尽量使用真实数据库、缓存和服务验证组件真实协作
E2E 测试主流程真实,少量异常使用路由拦截验证用户完整体验
发布前联调真实沙箱和真实契约发现测试替身无法暴露的问题不使用替身代替

发现这些信号就要减少 Mock

  • 一个用例需要配置十几个 Mock 才能运行。
  • 内部方法重命名就导致大量测试失败。
  • Mock 返回的数据比真实接口文档还复杂。
  • 自动化一直通过,真实环境却频繁联调失败。
  • 团队已经没人能说明每个替身对应哪个真实版本。
底层测试可以大量使用替身换取速度和可控性;越接近用户和发布,越应该减少替身,增加真实组件、真实数据结构和真实依赖验证。
10

完成一套可维护的支付测试桩

从场景到交付

练习:控制支付依赖完成订单服务测试

  1. 画出订单服务与库存、优惠、支付和数据库的依赖关系。
  2. 说明本次验证目标,并选择哪些依赖替换、哪些保留真实。
  3. 分别为成功、失败、超时、重复回调和畸形响应建立测试桩。
  4. 让支付桩校验 order_id、amount 和 currency,而不是无条件返回成功。
  5. 使用 Mock 确认同一订单只调用一次支付确认接口。
  6. 使用 Playwright 路由拦截验证页面在支付超时后的提示和恢复。
  7. 建立请求与响应契约,并设计桩版本过期时的失败机制。
  8. 保留一组真实支付沙箱集成测试,说明它与桩测试分别保护什么。

替换边界正确

  • 验证目标明确
  • 真实组件没有误替换
  • 副作用得到隔离
  • 仍保留真实联调

场景可以信任

  • 请求匹配严格
  • 响应符合契约
  • 异常可以重复
  • 状态和数据有断言

长期可以维护

  • 桩有版本负责人
  • 契约变化会失败
  • 过期映射会清理
  • 失败证据可定位

掌握测试替身后,继续用 Playwright 把真实主流程和可控异常组合成稳定的 Web E2E 回归。

继续学习 Playwright 自动化