WireMock 契约与 fixtures:让测试替身既稳定又不脱离真实接口
支付客户端的回归测试连续数月保持绿色,真实环境切换接口版本后却全部收到 401。排查发现,测试替身只匹配了 URL,既没有检查 Authorization,也没有检查消费者声明的 X-Contract-Version;响应 fixture 仍是很久以前从日志里复制的成功 JSON。客户端后来漏掉签名头,WireMock 依旧命中宽泛 stub 并返回 200,测试因此稳定地证明了一个错误世界。
这类假阳性通常同时包含三层问题:请求匹配过宽,让错误请求进入成功分支;fixture 没有版本和 owner,真实接口改变后无人维护;团队把“Mock 返回了预期 JSON”误当成“提供者仍兼容消费者”。WireMock 能稳定制造 HTTP 交互、记录收到的请求并注入故障,但它不会自动读取业务契约,也不会访问真实 provider 证明兼容性。可靠链路必须同时约束请求、响应、状态、数据来源和漂移证据。
先分清 stub、fixture 与契约证据
stub mapping 是“什么请求触发什么响应”的规则;fixture 是可重复使用的请求体、响应体或初始化数据;契约证据则回答消费者依赖的行为是否仍由真实提供者满足。三者可以协作,但不能互相冒充。
一个 mapping 至少经历以下决策:WireMock 先用 method、URL、查询参数、header、cookie 和 body 等条件计算匹配;多个 stub 都命中时,较小的 priority 数字优先,同优先级下后加入的规则优先;选中后再返回内联 body、__files 下的 body 文件,或执行 Scenario 状态迁移、延迟和 fault。未命中默认得到 404,请求 journal 保存的请求还能用于 verification、unmatched 和 near miss 诊断。
因此“成功响应能返回”只是最低层证据。工程上还要证明:
错 method、漏 header、错版本和不合法 body 都不能进入成功分支。每个测试从已知 Scenario 状态开始,结束后不把状态留给下一用例。fixture 的来源、版本、消费者、敏感等级和废弃条件可以追踪。
OpenAPI、消费者契约或 provider 验证会在真实接口变化时让流水线失败。
当前稳定落地可采用 WireMock 3.13.2。官方下载安装页同时提供 3.x 与 4.x beta,4.x 的模块拆分和 API 仍可能发生破坏性变化;团队可以单独试验 beta,但不应让普通依赖更新自动把稳定测试基线切到 beta。WireMock 的安装页列出了 Java 依赖、standalone JAR 和 Docker 三类入口。
三种运行入口对应三种隔离边界
standalone JAR 适合跨语言联调
standalone uber JAR 已包含运行依赖,适合前端、移动端、脚本和多个进程共同访问同一个 Mock。下载后先核对制品来源与校验值,再使用动态端口或当前任务独占端口启动:
java -jar wiremock-standalone-3.13.2.jar \
--port 18080 \
--root-dir ./wiremock \
--verbose--root-dir 下至少包含 mappings/ 和 __files/。mappings 保存规则,__files 保存较大的响应体。--verbose 适合本机排障,不宜长期用于并发 CI;详细请求和响应可能扩大日志量,也可能暴露测试数据。
JUnit Jupiter 适合测试拥有完整生命周期
Java 项目直接引入稳定的 test dependency:
<dependency>
<groupId>org.wiremock</groupId>
<artifactId>wiremock</artifactId>
<version>3.13.2</version>
<scope>test</scope>
</dependency>JUnit Jupiter 扩展默认使用随机 HTTP 端口。static 扩展在测试类首个方法前启动、最后一个方法后停止,并在每个测试方法前重置 stubs 和请求;非 static 扩展则按测试方法启停。程序化注册允许控制文件根目录、request journal 和 unmatched 行为:
import static com.github.tomakehurst.wiremock.client.WireMock.*;
import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig;
import com.github.tomakehurst.wiremock.junit5.WireMockExtension;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
class CatalogClientContractTest {
@RegisterExtension
static WireMockExtension wm = WireMockExtension.newInstance()
.options(wireMockConfig()
.dynamicPort()
.usingFilesUnderClasspath("wiremock"))
.failOnUnmatchedRequests(true)
.build();
@Test
void readsAContractFixture() throws Exception {
wm.stubFor(get(urlPathEqualTo("/v1/books/b-100"))
.withHeader("X-Contract-Version", equalTo("catalog-v1"))
.willReturn(okJson("{\"id\":\"b-100\",\"status\":\"AVAILABLE\"}")));
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(wm.baseUrl() + "/v1/books/b-100"))
.header("X-Contract-Version", "catalog-v1")
.GET()
.build();
HttpResponse<String> response = HttpClient.newHttpClient()
.send(request, HttpResponse.BodyHandlers.ofString());
org.junit.jupiter.api.Assertions.assertEquals(200, response.statusCode());
wm.verify(1, getRequestedFor(urlPathEqualTo("/v1/books/b-100"))
.withHeader("X-Contract-Version", equalTo("catalog-v1")));
}
}动态端口消除了并行任务争抢 8080 的问题,wm.baseUrl() 又避免把端口写死进应用配置。多实例测试优先调用 wm.stubFor()、wm.verify() 这类实例 DSL;静态 DSL 依赖全局配置,在并行类或多个 WireMock 实例之间更容易误连。JUnit 扩展默认让 unmatched 请求导致测试失败,不要为了消除波动统一关闭 failOnUnmatchedRequests,否则最有价值的错误请求会重新变成假阳性。
容器适合语言无关且运行时固定的流水线
官方 Docker 镜像以 /home/wiremock 为文件根目录。开发机可把端口只绑定到 loopback,并只读挂载 fixtures:
docker run -d --rm \
--name wiremock-contract-lab \
-p 127.0.0.1:18080:8080 \
--mount type=bind,src="$PWD/wiremock",dst=/home/wiremock,readonly \
wiremock/wiremock:3.13.2 \
--verbose
curl -fsS http://127.0.0.1:18080/__admin/mappings生产化 CI 应把 tag 解析成经过批准的 image digest,避免同名 tag 指向不同字节。若测试和 WireMock 都在同一容器网络中,让测试通过服务名访问 http://wiremock:8080,无需发布宿主机端口。WireMock 容器本身不需要 Docker socket;只有外层编排器或 Testcontainers 需要访问容器运行时。把 /var/run/docker.sock 挂进 WireMock 既无功能收益,又等同于把宿主机级控制权交给测试服务。
用精确 mapping 跑通第一个正反实验
建立以下目录:
wiremock/
├── mappings/
│ └── catalog-v1-get-book.json
└── __files/
└── catalog/v1/book-b-100.json响应 fixture 不含真实姓名、手机号或 token:
{
"id": "b-100",
"status": "AVAILABLE",
"owner": "synthetic-user-100"
}mapping 同时锁定 method、path 和消费者声明的版本 header,并用 metadata 保存治理信息:
{
"id": "6ec3d740-7e03-4c45-9ec9-d91cf9dd21a4",
"name": "catalog-v1-get-book-b-100",
"priority": 1,
"request": {
"method": "GET",
"urlPath": "/v1/books/b-100",
"headers": {
"X-Contract-Version": {
"equalTo": "catalog-v1"
}
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "catalog/v1/book-b-100.json"
},
"metadata": {
"fixtureVersion": "catalog-v1",
"owner": "catalog-client",
"dataClass": "synthetic"
}
}Request Matching还支持 query、form、cookie、JSON、XML、multipart 等条件。选择条件时围绕消费者真正依赖的语义:认证方案是否存在、内容协商是否正确、幂等键是否复用、必填字段是否符合约束。不要匹配随机 trace ID、时间戳和 JSON 字段顺序,也不要退化成只匹配 URL。
启动容器后,正向请求应返回 200 和固定 fixture:
curl -i \
-H 'X-Contract-Version: catalog-v1' \
http://127.0.0.1:18080/v1/books/b-100关键证据应包含:
HTTP/1.1 200 OK
Content-Type: application/json
{"id":"b-100","status":"AVAILABLE","owner":"synthetic-user-100"}反向请求故意漏掉版本 header:
curl -sS -o wiremock-negative-response.txt -w '%{http_code}\n' \
http://127.0.0.1:18080/v1/books/b-100预期状态码为 404,而不是同样得到成功 fixture。随后查询未命中和 near miss:
curl -fsS http://127.0.0.1:18080/__admin/requests/unmatched
curl -fsS http://127.0.0.1:18080/__admin/requests/unmatched/near-missesunmatched 记录应出现 /v1/books/b-100,near miss 应把缺失的 X-Contract-Version 指向最接近的 mapping。若团队增加一个低优先级 catch-all 并让它返回自定义 JSON,这次请求会被视为“已匹配”,从 unmatched 视图消失;此时必须另设 verification 或明确的失败状态,否则诊断便利会削弱漂移门禁。
匹配规则要在“过宽”和“过拟合”之间建立不变量
精确并不等于把整条 HTTP 报文逐字复制。请求匹配应分三层:
路由身份由 method 与 urlPath 决定,query 参数单独匹配,避免参数顺序改变导致失败。协议语义匹配必需 header、媒体类型、版本和幂等键,不匹配代理自动追加的 hop-by-hop header。body 使用 equalToJson、JSONPath 或 XPath 表达业务约束;只依赖少数字段时不要锁死所有可选字段。
例如订单创建可以要求 customerId 和正数金额,同时允许 provider 后续增加可选字段:
{
"request": {
"method": "POST",
"urlPath": "/v1/orders",
"headers": {
"Content-Type": { "contains": "application/json" },
"Idempotency-Key": { "matches": "idem-[a-z0-9-]+" }
},
"bodyPatterns": [
{ "matchesJsonPath": "$[?(@.customerId == 'synthetic-c-100')]" },
{ "matchesJsonPath": "$[?(@.amount > 0)]" }
]
},
"response": {
"status": 202,
"jsonBody": { "orderId": "o-100", "state": "PENDING" },
"headers": { "Content-Type": "application/json" }
}
}只写 "urlPath": "/v1/orders" 会接受负金额和漏幂等键;用 equalToJson 锁死完整请求则可能把无关可选字段变成破坏性变化。评审 mapping 时必须逐项回答“消费者为什么依赖这个条件”,而不是追求条件数量。
Scenario 把有状态交互变成显式状态机
轮询、重试和异步任务不能用同一个静态响应证明。WireMock Stateful Behaviour把 Scenario 定义为状态机,初始状态固定为 Started。下面两个 mapping 让第一次查询返回 PENDING,第二次返回 APPROVED:
{
"scenarioName": "payment-p-100",
"requiredScenarioState": "Started",
"newScenarioState": "PENDING_RETURNED",
"request": {
"method": "GET",
"url": "/v1/payments/p-100"
},
"response": {
"status": 200,
"jsonBody": { "paymentId": "p-100", "state": "PENDING" },
"headers": { "Content-Type": "application/json" }
}
}{
"scenarioName": "payment-p-100",
"requiredScenarioState": "PENDING_RETURNED",
"newScenarioState": "APPROVED_RETURNED",
"request": {
"method": "GET",
"url": "/v1/payments/p-100"
},
"response": {
"status": 200,
"jsonBody": { "paymentId": "p-100", "state": "APPROVED" },
"headers": { "Content-Type": "application/json" }
}
}按顺序请求并读取状态:
curl -fsS http://127.0.0.1:18080/v1/payments/p-100
curl -fsS http://127.0.0.1:18080/v1/payments/p-100
curl -fsS http://127.0.0.1:18080/__admin/scenarios预期先得到 PENDING,再得到 APPROVED,Admin API 中状态为 APPROVED_RETURNED。第三次业务请求将因没有对应状态的 mapping 而返回 404,这能暴露客户端失控轮询。每个测试结束后重置 Scenario:
curl -fsS -X POST http://127.0.0.1:18080/__admin/scenarios/reset共享一个 WireMock 实例并行执行相同 Scenario 时,状态属于服务实例,不属于线程;两个用例会竞争同一状态机。解决办法是每个测试任务独占实例,或为每个测试生成唯一的 Scenario 和资源 ID。不要用重试掩盖竞争,也不要依赖测试执行顺序。
WireMock 的“重置”不是一个含义。按测试真正拥有的状态选择端点,避免为了清 request journal 把运行时注册的 stubs 一并删掉:
# 所有 Scenario 回到 Started,不改 mappings 和请求记录
curl -fsS -X POST http://127.0.0.1:18080/__admin/scenarios/reset
# 只清 request journal,不改 mappings
curl -fsS -X DELETE http://127.0.0.1:18080/__admin/requests
# mappings 恢复为启动时从文件加载的默认集合
curl -fsS -X POST http://127.0.0.1:18080/__admin/mappings/reset
# mappings 恢复默认集合,同时清 request journal
curl -fsS -X POST http://127.0.0.1:18080/__admin/resetDELETE /__admin/mappings 的语义又不同:它删除当前全部 stubs,但保留 request journal。测试若通过 Admin API 动态注册 mapping,应明确是要“清空”还是“恢复启动默认值”;两者混用会产生只在第二个用例出现的假故障。WireMock stubbing 的 reset 说明给出了这些边界。
延迟与 fault 要对应客户端的具体失败阶段
返回 500 只能证明客户端处理了 HTTP 错误,不能证明连接池、读取超时和结果未知语义正确。Simulating Faults提供 fixed/random delay、chunked dribble 和连接级 fault。先用确定延迟验证 deadline:
{
"request": {
"method": "GET",
"url": "/v1/risk/slow"
},
"response": {
"status": 200,
"fixedDelayMilliseconds": 1500,
"jsonBody": { "decision": "ALLOW" },
"headers": { "Content-Type": "application/json" }
}
}curl --max-time 0.4 -sS http://127.0.0.1:18080/v1/risk/slow
echo $?常见 curl 结果是超时并返回退出码 28。应用测试还要断言总 deadline、重试次数和连接池恢复,而不只断言抛出某种异常。再增加响应体中途损坏:
{
"request": {
"method": "GET",
"url": "/v1/risk/broken-body"
},
"response": {
"fault": "MALFORMED_RESPONSE_CHUNK"
}
}CONNECTION_RESET_BY_PEER 依赖底层 socket 行为,官方说明其在 Windows 上可能表现为挂起而不是 reset。跨平台流水线应优先选择可稳定复现的 fixed delay、empty response 或 malformed chunk,并分别保存 Linux 与 Windows 的实际异常证据。故障注入后还要证明连接被回收、后续健康请求成功、积压没有持续增长。
录制只是原料采集,不是可直接提交的契约
Record and Playback可以代理真实 API,把收到的交互生成 mappings。录制期间流量会被转发,但 stub 只在调用 stop 时生成;停止后新 mapping 立即参与回放。默认 persist: true 会把结果写入 mappings 存储,默认 repeatsAsScenarios: true 还会把相同请求的多个响应串成 Scenario,并在序列耗尽后持续返回最后一个响应。这个能力适合快速取得候选样本,却有三类危险:
请求 header、query 和 body 可能包含 token、Cookie、个人信息与生产标识。某次偶然响应会把时间戳、随机 ID、分页游标和非确定顺序固化成 fixture。录制得到的是一次实际交互,不代表所有消费者依赖,也不证明错误分支和兼容边界。
录制只能在隔离的测试上游和专用凭证下进行。Admin API 的最小启动动作如下:
curl -fsS -X POST http://127.0.0.1:18080/__admin/recordings/start \
-H 'Content-Type: application/json' \
-d '{"targetBaseUrl":"http://approved-test-upstream:8080","persist":false,"repeatsAsScenarios":false,"captureHeaders":{"Accept":{},"Content-Type":{}}}'
curl -fsS http://127.0.0.1:18080/v1/catalog/b-100
curl -fsS -X POST http://127.0.0.1:18080/__admin/recordings/stop这里用 persist: false 让候选 mapping 只存在于当前进程,并由 stop 响应返回;它不会自动完成脱敏,也不会形成可提交文件。评审后若确实要保存,应通过受控流程写入新目录,而不是把 recorder 的默认持久化目录直接交给 Git。提交之前必须删除认证 header 和 Cookie,把真实主体替换为 synthetic ID,归一化时间与随机字段,收紧 request matcher,并补写未授权、超时和不合法 body 等反例。录制目标还应使用 WireMock 的 proxy target allowlist 或外层网络策略限制 hostname/IP,防止代理指向 metadata service、内网管理面或生产地址。
上游 TLS 也属于录制证据的一部分。WireMock proxying说明反向代理为了便利会忽略目标证书不可信或 hostname 不匹配,而 browser proxying 默认会验证目标证书;因此“录制成功”不能证明上游证书链正确。受控录制应提供批准的 truststore 与目标 allowlist,禁止用 --trust-all-proxy-targets 建立 CI 基线。browser proxying 为解密 HTTPS 会持有本地 CA 私钥并接触明文流量,该 keystore 只能用于隔离测试环境,不能复用企业根 CA,也不能作为普通 artifact 上传。
扩展点会改变请求处理链,必须像生产插件一样审查
内建 matcher 和响应配置无法表达特殊协议时,WireMock 可以加载自定义扩展,但“只是测试代码”不是放宽供应链和权限审查的理由。WireMock 扩展说明列出的关键入口处在不同阶段:RequestMatcherExtension 参与是否命中,RequestFilterV2 及其 Stub/Admin 子接口可修改或拒绝请求,ResponseDefinitionTransformerV2 与 ResponseTransformerV2 改写响应定义或最终响应,ServeEventListener 接收交互事件,AdminApiExtension 还能增加新的 __admin 路由。把它们混成一个通用 transformer,会模糊谁能读取凭据、谁能改变匹配结果、谁能向外发送日志。
扩展 JAR 属于可执行代码,而不是 fixture 数据。standalone 通过 classpath、--extensions 或 Java ServiceLoader 装载扩展时,扩展拥有 WireMock 进程的文件、网络和环境变量权限;容器内尤其不能把云凭据、Docker socket 或宿主目录顺手暴露给它。稳定落地至少锁定扩展制品的版本与 digest,生成依赖清单并做漏洞和许可证检查,在隔离环境验证启动与停止生命周期,再批准进入共享 runner。WireMock 3.6.0 起扩展有 start()/stop() 生命周期;stop() 可以释放线程、连接和文件,但进程被强杀时仍需要作业级清理器。
自定义 matcher 必须保持确定、快速且无副作用。不要在匹配阶段访问真实数据库或远端认证服务,否则一次 near miss 诊断就可能产生多次外呼、延迟和成本;matcher 只读取请求中与契约相关的字段,并为未命中返回可脱敏的诊断。filter 若承担 Admin API 认证,正向实验要证明授权身份能读取 mappings,反向实验要证明无凭据身份稳定得到 401/403,还要验证 stub 路由没有被误拦、Admin 路由没有漏拦。listener 和 transformer 不得把 Authorization、Cookie、完整 body 或上游地址写入日志与 sub-event。
升级扩展时建立两组反证:第一组故意发送本应拒绝的请求,确认 matcher/filter 没有因 API 变化而 fail-open;第二组停止 WireMock,确认扩展线程与连接归零,进程能在 deadline 内退出。WireMock 4.x 仍是 beta,核心数据类不可变、依赖拆分和 Jetty 基线均有变化;3.x 扩展不能未经编译和行为验证直接挂到 4.x。需要同时试验两个大版本时,分别维护制品、镜像和 fixture 结果,不让同一 classpath 猜测兼容性。
fixture 需要像代码一样版本化
一个长期可维护的 fixture 仓库不按“成功、失败”随意堆文件,而按 provider、契约版本和场景组织:
src/test/resources/wiremock/
├── mappings/
│ └── catalog/
│ ├── v1-get-book.json
│ ├── v1-create-order.json
│ └── v1-payment-poll.json
├── __files/
│ └── catalog/v1/
│ ├── book-b-100.json
│ ├── order-pending.json
│ └── order-rejected.json
└── fixture-manifest.jsonmanifest 记录 owner、provider schema 版本、消费者、数据等级和废弃信号:
{
"fixtureSet": "catalog-v1",
"owner": "catalog-client-team",
"providerSchema": "openapi/catalog-v1.yaml",
"dataClass": "synthetic-only",
"consumers": ["checkout-service"],
"deprecationSignal": "provider-removes-v1-after-consumer-migration"
}版本升级采用并存迁移:先增加 v2 mappings 和 fixtures,让消费者在同一流水线分别验证 v1 与 v2;切换应用配置后观察 provider verification;所有消费者退出 v1 才删除旧集合。直接覆盖 v1 fixture 会让旧提交在新数据上运行,历史构建失去可重现性。
敏感数据治理从数据生成开始。优先用 synthetic generator 生成格式正确但不可还原的人、卡号、地址和 token;录制数据即使做了字段遮盖,也要检查自由文本、嵌套对象、header、文件附件和 request journal。fixture 仓库执行 secret scan 和 PII pattern scan,扫描命中不能用“只是测试数据”自动豁免。失败日志和 CI artifact 同样受保留期与访问控制约束。
用静态门禁阻止坏 fixture 进入测试
下面的 Node 脚本可放入项目的 scripts/check-wiremock-fixtures.mjs。它检查 JSON 可解析、mapping 名称和 ID 唯一、bodyFileName 存在,并拒绝常见敏感字段的直接值:
import { existsSync, readFileSync, readdirSync } from "node:fs";
import { isAbsolute, join, relative, resolve, sep } from "node:path";
const root = resolve("src/test/resources/wiremock");
const mappingsRoot = resolve(root, "mappings");
const filesRoot = resolve(root, "__files");
const mappingFiles = [];
function walk(dir) {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const path = join(dir, entry.name);
if (entry.isDirectory()) walk(path);
else if (entry.name.endsWith(".json")) mappingFiles.push(path);
}
}
walk(mappingsRoot);
const ids = new Set();
const names = new Set();
const forbidden = /"(?:authorization|cookie|password|accessToken|refreshToken)"\s*:\s*"(?!__SYNTHETIC__)/i;
for (const path of mappingFiles) {
const source = readFileSync(path, "utf8");
if (forbidden.test(source)) throw new Error(`sensitive literal: ${path}`);
const mapping = JSON.parse(source);
if (!mapping.id || !mapping.name) throw new Error(`missing id/name: ${path}`);
if (ids.has(mapping.id)) throw new Error(`duplicate id: ${mapping.id}`);
if (names.has(mapping.name)) throw new Error(`duplicate name: ${mapping.name}`);
ids.add(mapping.id);
names.add(mapping.name);
const body = mapping.response?.bodyFileName;
if (body) {
const bodyPath = resolve(filesRoot, body);
const rel = relative(filesRoot, bodyPath);
if (isAbsolute(rel) || rel.split(sep).includes("..")) {
throw new Error(`body file escapes __files: ${body} for ${path}`);
}
if (!existsSync(bodyPath)) {
throw new Error(`missing body file ${body} for ${path}`);
}
}
}
console.log(`wiremock-fixtures-ok mappings=${mappingFiles.length}`);这段检查只保证仓库内部引用和最小数据规则,不验证响应是否符合 OpenAPI,也不证明 provider 兼容。它应该在 WireMock 启动之前运行,让拼写错误和缺失 body file 尽早失败。
Mock 漂移必须由三条独立证据暴露
Mock 漂移有两种方向:fixture 仍能满足旧客户端,但真实 provider 已改变;或 fixture 被随意改成新形状,却没有证明消费者和 provider 同时接受。仅靠 WireMock 自测无法发现第一种,也无法阻止第二种。
第一条证据由 WireMock 提供:消费者必须发出正确请求,能处理成功、业务拒绝、超时和损坏响应,unmatched 数量为零。第二条证据由 OpenAPI/JSON Schema lint 或 breaking-change 工具提供:所有 fixture 都能被当前 schema 校验,删除必填字段、收窄枚举和改变状态码会失败。第三条证据来自 provider:用 Pact provider verification、provider 自身契约测试,或隔离环境 smoke 回放关键消费者交互。
定期录制只能作为漂移侦测输入。自动任务可以从批准的测试环境采样、脱敏后与 Git fixture 做结构 diff;发现新增字段通常只是提醒,删除字段、类型变化、状态码变化和认证要求变化才进入阻断判断。任务不得自动覆盖主分支 fixture,因为“录到什么就接受什么”会绕过兼容性审查。
CI 的端口、状态和运行时必须逐任务隔离
并行流水线最常见的波动不是 WireMock 算法错误,而是资源 owner 不明确:两个 job 固定映射 8080,一组测试重置了另一组 mapping,Scenario 状态交叉,或者共享容器残留旧 fixture。隔离策略按强度递增:
单 JVM 测试使用 JUnit 动态端口,每个测试类或需要状态隔离的方法拥有独立扩展实例。多语言 job 使用单独容器网络,以服务名访问,不发布宿主机端口。必须发布端口时让运行时随机分配,并从 docker port 或编排器输出注入应用配置。
每个 job 使用唯一容器名、网络名和 artifact 目录,清理动作放在 finally 或 CI 的 always-run 阶段。
一个容器式 job 的生命周期应保持对称:
set -eu
NETWORK="wiremock-${CI_JOB_ID:?}"
CONTAINER="wiremock-${CI_JOB_ID}"
cleanup() {
docker rm -f "$CONTAINER" >/dev/null 2>&1 || true
docker network rm "$NETWORK" >/dev/null 2>&1 || true
}
trap cleanup EXIT
docker network create "$NETWORK"
node scripts/check-wiremock-fixtures.mjs
docker run -d --name "$CONTAINER" \
--network "$NETWORK" \
--mount type=bind,src="$PWD/wiremock",dst=/home/wiremock,readonly \
"wiremock/wiremock@${WIREMOCK_IMAGE_DIGEST:?}"
READY=0
for ATTEMPT in $(seq 1 30); do
if docker run --rm --network "$NETWORK" \
"curlimages/curl@${CURL_IMAGE_DIGEST:?}" \
-fsS "http://${CONTAINER}:8080/__admin/health" >/dev/null; then
READY=1
break
fi
sleep 1
done
[ "$READY" -eq 1 ] || { docker logs "$CONTAINER"; exit 1; }
# 测试容器加入同一网络,通过 http://wiremock-${CI_JOB_ID}:8080 访问。镜像拉取失败要区分 DNS、代理、仓库认证、rate limit 和 digest 不存在。CI 使用只读 registry credential,并把代理配置交给 Docker daemon 或 runner;把 HTTP_PROXY 盲目传进测试进程可能让对 WireMock 的本地请求绕到企业代理。显式配置 NO_PROXY,包含容器服务名、127.0.0.1 和实际测试网段。
Admin API、request journal 与日志都是敏感面
standalone 的 __admin 能列出 mappings、请求、near misses 和 Scenario,也能重置、录制、代理和关闭服务。WireMock 3 standalone 提供 --admin-api-basic-auth username:password 和 --admin-api-require-https,但前者把凭据作为进程参数传入,可能被进程列表、容器配置或流水线日志看见;它也只是单组 Basic Auth,不是多租户 RBAC。不能把真实长期密码直接写进仓库命令。开发机优先只绑定 loopback;共享 runner 使用任务私有网络和网络策略;跨主机联调再由受控 secret 注入短期凭据,并配合 HTTPS、来源限制和审计。即使启用 Basic Auth,也不要通过公网 ingress 直接暴露 Admin API;若需要细粒度身份、审计和多租户隔离,应在受控网关实现并保持 WireMock 私网不可直达,不能把 Basic Auth 描述成完整授权系统。
WireMock 配置文档说明 request journal 是 verification 的基础,关闭后相关验证会报错。高并发或大 body 测试中,journal 也会占用 JVM heap;可以限制最大条数和记录的响应体大小,但门禁必须同步调整:
wireMockConfig()
.dynamicPort()
.maxRequestJournalEntries(java.util.Optional.of(500))
.maxLoggedResponseSize(64 * 1024)
.asynchronousResponseEnabled(true)
.asynchronousResponseThreads(16);这些数字只是演示值。实际值由并发请求数、单请求体积、延迟分布和可用 heap 测得;journal 截断或条目淘汰后,verify 看到的不是完整历史。负载测试若关闭 journal,就用客户端侧计数、服务指标或独立探针承担证据,不能继续声称“所有请求均已验证”。
日志中避免输出 Authorization、Cookie、完整 body 和真实 upstream URL。CI 失败 artifact 设置短保留期和最小读取权限,下载与删除动作进入审计。测试凭证只访问隔离环境、短时有效,并与生产凭证使用不同信任域;fixture 仓库不接受任何真实 token,即使它已经失效。
排障要从第一份证据定位到匹配阶段
| 现象 | 第一份证据 | 常见根因 | 修复方向 |
|---|---|---|---|
请求得到 404 | __admin/requests/unmatched 与 near misses | path、header、body 或 Scenario 状态不匹配 | 比较实际请求与最近 mapping,不先放宽规则 |
| 命中错误响应 | __admin/requests 中的 stub ID 与 mapping priority | 规则重叠、默认优先级、后加载覆盖 | 给具体规则更高优先级,删除模糊 catch-all |
| 本机通过、CI 连接拒绝 | job 网络、实际端口、容器日志 | 固定端口冲突、错误使用 localhost、服务未就绪 | 动态端口或服务 DNS,并加 readiness |
| 第二个测试随机失败 | __admin/scenarios、请求顺序 | Scenario 或 mappings 被并发共享 | 每任务独占实例,测试前后重置 |
| verification 偶发少请求 | journal 配置与条目数量 | journal 关闭、上限过小、测试提前结束 | 恢复完整 journal 或改用有界外部证据 |
| 录制文件包含真实数据 | Git diff、secret/PII scan | 从共享或生产流量录制,未做脱敏 | 撤销凭证、清理历史、改用 synthetic 数据重新生成 |
| fault 在不同 OS 表现不同 | 客户端异常、socket 状态、WireMock 日志 | 底层 TCP 行为不同 | 用跨平台 fault 建主门禁,平台特定实验分开维护 |
排障顺序固定为:先确认客户端实际访问的 host/port,再查 unmatched 和 Scenario,随后看匹配到的 stub ID 与 priority,最后才怀疑客户端解析逻辑。直接把 matcher 改成 ANY 或增加无限重试,只会删除证据。
架构选型取决于谁拥有 Mock 生命周期
JUnit 扩展适合单 JVM、快速反馈和测试级隔离;standalone 适合多个本地进程或非 JVM 消费者共享;容器适合固定运行时、语言无关和 CI service;Testcontainers 适合由测试代码编排容器、动态网络和等待策略。选择标准不是团队偏好的启动命令,而是 owner、隔离强度、启动成本和证据回收方式。
WireMock 也不替代 Pact 或 OpenAPI。WireMock 擅长控制 HTTP 行为和失败分支;Pact 擅长让消费者契约在 provider 上回放;OpenAPI/JSON Schema 擅长描述和检查结构边界。核心服务通常三者组合:WireMock 提供快速且可控的消费者测试,schema 门禁检查 fixture 结构,provider verification 关闭真实兼容性环路。低风险内部脚本可以只使用 WireMock,但仍需至少一条真实 provider smoke,避免替身永久脱离现实。
成本主要来自并行容器计算、镜像拉取、fixture 评审、失败 artifact 存储和漂移任务,而不是 stub 文件数量。减少成本应依靠镜像缓存、按受影响模块选择 fixture 集、短保留期和稳定的本地依赖缓存;不要让多个 job 共享可变 WireMock 实例换取表面上的资源节省。
清理与长期治理形成最后一道门禁
本地容器实验结束时删除容器、临时响应和可能包含请求内容的日志:
docker rm -f wiremock-contract-lab
rm -f wiremock-negative-response.txt合并前的判断应落到可观察事实:mapping 和 body 引用完整;正向请求命中唯一 stub;关键反向请求稳定未命中或返回明确业务错误;Scenario 可重置且并发不共享;fault 后客户端资源恢复;fixtures 通过 schema、secret 与 PII 检查;provider 侧验证覆盖消费者真正依赖的字段和状态;CI 使用动态端口、私有网络、固定镜像 digest 与总能执行的清理动作。
每个 fixture 集必须有 owner。owner 负责处理 provider schema 变化、录制数据脱敏、废弃版本清理和失败门禁;消费者团队负责声明依赖并维护正反断言;平台团队负责镜像、runner、代理、凭证与 artifact 保留。连续多轮构建后,容器、网络和临时文件数量应回到稳定基线,unmatched 必须为零或全部对应有意验证的反例,fixture 版本不能在无审查情况下自动漂移。达到这些条件,WireMock 才是测试架构中的可验证边界,而不是一个永远返回成功的 JSON 服务器。
