编程 WireMock 3.13 回放支付接口:录下来的 stub 只匹配它自己,35 秒延迟正好复现「已批准但调用方超时」

2026-11-14 21:32:15

用 WireMock 3.13.2 录制支付接口:stub 匹配过严、状态机回放与 35 秒超时

项目信息:

  • WireMock 仓库:https://github.com/wiremock/wiremock
  • 录制/回放文档:https://wiremock.org/docs/record-playback/

本文基于 WireMock 3.13.2,mappings 在 2026-09-22 重跑验证。

录制与快照:两种生成 stub 的方式

WireMock 能把收到的请求转换成 stub mapping;配合代理,就能从现有 API「录制」。有两种做法:录制(recording)与快照(snapshotting)。Recorder UI 在 http://localhost:8080/__admin/recorder。注意:stub mapping 只在停止录制的那一刻生成,回放不需要任何额外动作。

快照是事后录制:把已经收到的请求转成 stub mapping,因此必须在产生流量之前先配好代理 mapping:

stubFor(proxyAllTo("http://examples.wiremockapi.cloud/").atPriority(1));
POST /__admin/mappings
{"priority":1,"request":{"method":"ANY"},"response":{"proxyBaseUrl":"http://examples.wiremockapi.cloud/"}}

POST /__admin/recordings/snapshot

repeatsAsScenarios 默认为 true:多个相同的请求会被做成一个 Scenario,回放时按原顺序返回同样的响应序列,序列结束后最后一个响应会一直被返回。

用 Docker 录制

docker run --rm -p 8080:8080 \
  -v "$PWD/cassettes":/home/wiremock/mappings \
  -v "$PWD/cassettes/__files":/home/wiremock/__files \
  wiremock/wiremock:3.13.2 --verbose

curl -sf -X POST http://localhost:8080/__admin/recordings/start \
  -H 'Content-Type: application/json' \
  -d '{"targetBaseUrl":"https://api.example.com","captureHeaders":{"Accept":{},"Content-Type":{}},"persist":true,"repeatsAsScenarios":true,"transformers":["response-template"]}'

# 让被测应用把流量打到 http://localhost:8080

curl -sf -X POST http://localhost:8080/__admin/recordings/stop | jq '.mappings | length'

三个参数的作用:

  • captureHeaders:限定哪些 header 会被记录成匹配条件。不设这一项,每条录制出来的 stub 都会要求浏览器发来的每个 header 精确匹配,回放就频繁 miss。
  • repeatsAsScenarios:保留 POST 后接 GET 这类请求序列,回放时按序返回。
  • persist:把录制结果落盘到 mappings 目录(配合上面的 volume 挂载即为持久化)。

另一种写法是 standalone jar:

java -jar wiremock-standalone-3.13.2.jar \
  --proxy-all="https://api.example.com" --record-mappings --root-dir ./service-mocks

录制 stub 默认匹配过严的坑

录制出来的 stub 默认匹配得过严:它把录制那一刻客户端发出的全部 header 都写进了匹配条件。浏览器或多层网关加上去的 User-Agent、Accept-Encoding、trace header 稍有变化,回放就会落到 unmatched。用 captureHeaders 把匹配条件收敛到真正有语义的几个 header(例如 Accept、Content-Type),是让录制结果能回放的前提。

支付处理器 stub:authorize → capture

教程用一个合成的支付处理器:POST /v1/authorizations 与 POST /v1/authorizations/{id}/capture,行为由 card token 选择(tok_approve、tok_soft_decline、tok_timeout、tok_capture),全程不出现卡号。

1. 批准:回显请求里的 amount 与 currency

{"request":{"method":"POST","urlPath":"/v1/authorizations",
  "headers":{"Idempotency-Key":{"matches":".+"}},
  "bodyPatterns":[{"matchesJsonPath":{"expression":"$.card_token","equalTo":"tok_approve"}}]},
 "response":{"status":201,
  "headers":{"Content-Type":"application/json"},
  "body":"{ \"id\": \"auth_1001\", \"status\": \"approved\", \"amount\": {{jsonPath request.body '$.amount'}}, \"currency\": \"{{jsonPath request.body '$.currency'}}\" }",
  "transformers":["response-template"]}}

amount 字段在 body 里是字符串是刻意的:把同样的模板放进 jsonBody 时,WireMock 3.13.2 会输出 "amount":"1999",即字符串;严格的 JSON 客户端会因此拒绝一个本应是数值的 amount。

2. 软拒

402,id 为 auth_1002,status 为 declined,decline_type 为 soft,reason 为 insufficient_funds,retry_allowed 为 true。断言点:系统记录下这次拒付、不产生 capture,并按它文档里写的重试规则行事。

3. 批准后超时

先批准,然后把响应挂住 35000 ms,并把 scenario 推进到 Approved;在该状态下重试会立刻拿回同一个授权(幂等重放,header 带 Idempotent-Replayed: true)。延迟要设得高于被测系统的客户端超时(这个值按 30 秒客户端超时取的)。

在 3.13.2 上实测:客户端 5 秒后放弃,没有拿到任何响应,而 GET /__admin/scenarios 已经显示 "state":"Approved"。也就是说,请求一匹配上 scenario 就推进了,发生在延迟结束之前。这条 stub 因此复现的是一次「丢失的批准」:处理器批准了,调用方并不知道。

4. 先授权再 capture 的三态机

Started -> Authorised -> Captured。

  • 未授权就 capture:404 authorization_not_found
  • 首次 capture:200,id 为 cap_3001
  • 二次 capture:409 already_captured(返回的是 cap_3001)

实测输出:

POST /v1/authorizations/auth_3001/capture -> 404 {"error":"authorization_not_found"}
POST /v1/authorizations (tok_capture)      -> 201 {"id":"auth_3001","status":"approved"}
POST /v1/authorizations/auth_3001/capture -> 200 {"id":"cap_3001","authorization":"auth_3001","status":"captured"}
POST /v1/authorizations/auth_3001/capture -> 409 {"error":"already_captured","capture":"cap_3001"}

5. webhook 回调

capture 的 mapping 里挂 serveEventListeners:name 为 webhook,method 为 POST,url 为 http://localhost:8081/webhooks/processor,延迟用 fixed 2000 ms,body 里带 event_id 为 evt_3001、type 为 payment.captured、amount 取自 originalRequest.body。

8081 上的接收端在 capture 刚结束时没有收到请求,3 秒后收到一个。这 2 秒的空窗正好让测试去检查那段时间:API 已经说 captured,而 ledger 里还没有 webhook。

对 stub 日志做检查

curl -X POST http://localhost:8089/__admin/scenarios/reset
curl -X DELETE http://localhost:8089/__admin/requests

scenario 是单个 WireMock 实例内的全局状态,两个并行测试都打 tok_capture 会互相破坏状态。

统计调用次数并与 ledger 对账;超时测试跑完后,期望两条带同一个 Idempotency-Key 的 tok_timeout 请求,用 requests/count 断言幂等键:

curl -X POST http://localhost:8089/__admin/requests/count \
  -H "Content-Type: application/json" \
  -d '{"method":"POST","url":"/v1/authorizations","bodyPatterns":[{"matchesJsonPath":{"expression":"$.card_token","equalTo":"tok_timeout"}}]}'
# -> {"count":2,"requestJournalDisabled":false}

每轮结束都要看 GET /__admin/requests/unmatched:一条 unmatched 请求就是一个 stub 集合没预料到的调用(没带授权 ID 就 capture、没带 Idempotency-Key 就发起授权)。

回放的纪律:不要配 --proxy-all

回放用的 compose service:镜像 wiremock/wiremock:3.13.2,不配 --proxy-all,command 为 ["--global-response-templating","--disable-banner"],volume 只读挂载。

没有代理兜底,这本身就是安全属性:配了 --proxy-all 的回放栈在录制缺失时会静默打到真实 API,结果是一个离线 demo 在办公室网络跑得通,在火车上失败。

MSW 的等价做法:不要透传,直接返回 501 和 {error: no_recording, message: No cassette for METHOD path}。

验证两件事:

curl --max-time 3 http://localhost:8080/... # 回放服务必须 3 秒内响应
grep -rniE 'authorization|bearer |api[_-]?key|set-cookie' cassettes && exit 1

录制是真实 token 流进仓库最常见的途径。

录制与手写 stub 的取舍

录制的保真度是完美的(每个 header、状态码、意料之外的 null 都在),但它表达不了没发生过的情形:并发编辑的 409、空列表、限流。手写 handler 的意图是完美的,但装不下作者不知道的细节。

有效的组合是:先录制,发现真实的报文形状;再手写场景,把录制当作 payload 结构的 source of truth。

几条配套规则:

  • stub 是测试工具,不是发布文档。
  • mappings 与 __files 一起进 git 并 review 变更。
  • 用 equalToJson / matchesJsonPath 表达消费方期望,而不是逐字比对整个 payload。
  • stub 保持精简;不要在 stub 里建模整个领域逻辑。
  • 测试之间 reset scenarios。
  • Pact/provider verification 与 stub 是互补关系,不是替代。

几个坑

  • 录制的 stub 默认匹配过严(用 captureHeaders 收窄)。
  • body 里的时间戳会让每次重录都变成全量 diff:把易变字段(requestId、traceId、generatedAt、expiresAt)归一化成占位符。
  • 没有有效期的 cassette 会变成化石:标注 recordedAt 与 API 版本,按计划重录,并在结构变化时报错。
复制全文 生成海报 WireMock 接口对接 测试 Docker

推荐文章

程序员茄子在线接单