API、契约与 WireMock:让接口变化有可执行的兼容判断
订单客户端把 totalCents: 1250 解释为 12.50 元。提供者若把字段改成 amount: 12,HTTP 状态仍可能是 200,JSON 也仍然合法,但调用方已经无法按原协议工作。接口测试需要观察请求怎样发出、响应怎样解释,以及提供者是否兑现消费者依赖的格式和语义。
把 HTTP 交互拆成可以匹配的条件
请求与响应各检查什么
创建订单的请求可以分成以下部分:
POST /orders
├── 方法:创建动作使用 POST
├── 路径:/orders
├── Accept:application/json,客户端期望的响应表示
├── Content-Type:application/json,请求体的表示格式
└── JSON body
├── operationId:OP-1,本次业务操作标识
└── totalCents:1250,整数金额,单位为分
响应
├── HTTP status:成功、拒绝或服务错误
├── Content-Type:返回内容如何解码
└── JSON body
├── id:订单标识
├── totalCents:金额与单位
└── state:客户端需要理解的状态方法、路径、query、header、cookie 和 body 都可能参与匹配。Query 的顺序通常不应影响业务,适合按参数名匹配;签名原文、下载二进制或某些代理协议则可能要求字节完全相同。选择哪一种比较,取决于协议允许哪些差异。
JSON 对象成员顺序一般没有业务意义。equalToJson 以 JSON 结构比较,JSONPath 适合检查特定字段;是否允许额外字段、数组重排,需要显式决定。把整个动态 ID、时间和签名都写成固定字符串会导致无关变化频繁报错,把所有字段都放宽又可能漏掉金额单位和必要身份信息。WireMock 请求匹配
对响应,先区分 HTTP 状态处理、正文解码和业务状态解释。例如 400 需要保留稳定错误信息,200 配合坏 JSON 应当在解码阶段失败,202 则通常要求消费者继续查询或接收通知。关于方法、状态与表示的规范定义见 HTTP Semantics。
运行客户端与提供者实验
下载 HTTP 与契约测试工程。使用 Java 17 编译目标、JUnit 6.0.3、WireMock 3.13.2、Pact JVM 4.7.5,测试可在 Java 17 和 25 执行。WireMock 与契约服务器在测试 JVM 中监听随机端口,不需要数据库或 Docker socket。
src/main/java/example/testing/
├── OrdersClient.java JDK HttpClient、状态分类、Jackson 解码
└── OrdersProvider.java 实际 HTTP handler 与订单状态
src/test/java/example/testing/
├── WireMockClientTest.java 请求匹配、状态、延迟、拒绝、坏 JSON
├── OrdersConsumerTest.java 用真实客户端生成 Pact
└── OrdersProviderIT.java 回放 Pact 验证提供者Linux 宿主准备 Docker CLI 与 unzip,普通用户能访问开发用 daemon:
unzip testing-http-contract-lab.zip
cd testing-http-contract
mkdir -p .m2
LAB_DIR="$(pwd -P)"
MAVEN_IMAGE='maven:3.9.12-eclipse-temurin-25'
docker version
docker pull "$MAVEN_IMAGE"
docker run --rm --user "$(id -u):$(id -g)" \
-e HOME=/tmp -e MAVEN_CONFIG=/tmp/.m2 \
--mount "type=bind,src=$LAB_DIR,dst=/work" \
--mount "type=bind,src=$LAB_DIR/.m2,dst=/m2" \
--workdir /work "$MAVEN_IMAGE" \
mvn -B -ntp -Duser.home=/tmp -Dmaven.repo.local=/m2 clean verify源码与 Maven 缓存由当前 UID/GID 写入。HOME、MAVEN_CONFIG 和 user.home 避免镜像尝试写入 root 用户目录。有本机 JDK/Maven 时直接运行同一 Maven 目标即可;内网环境通过组织维护的 Maven settings/mirror 准备依赖,不能用跳过契约测试替代依赖下载。
正常构建分两段:Surefire 执行 8 次测试,包含 7 个 WireMock 场景与 1 个消费者测试;随后 Failsafe 执行 1 次提供者交互验证。两个报告目录分别为 target/surefire-reports 和 target/failsafe-reports。消费者写入的契约位于 target/pacts/checkout-consumer-orders-provider.json。
Pact 4.7.5 的扩展在 JUnit 6 下可能输出旧 CloseableResource 接口的兼容警告。应将这类依赖兼容提示与测试失败分别处理,升级时同时核对交互发现数、服务器关闭和提供者验证结果。JUnit 扩展资源生命周期说明见 JUnit Extension Model。
使用 WireMock 验证客户端的实际行为
先精确匹配一个成功请求
每个 WireMockClientTest 方法创建自己的 WireMockServer(options().dynamicPort()),启动后读取 baseUrl() 注入 OrdersClient,结束时调用 stop()。客户端真实经过 socket、HTTP 编码与 JSON 解码,测试独立控制服务响应。
server.stubFor(post(urlPathEqualTo("/orders"))
.withHeader("Content-Type", containing("application/json"))
.withRequestBody(equalToJson(
"{\"totalCents\":1250,\"operationId\":\"OP-1\"}"))
.willReturn(okJson(
"{\"id\":\"ORD-1\",\"totalCents\":1250,\"state\":\"READY\"}")));
var result = client.create("OP-1", 1250);
assertEquals(new OrdersClient.Order("ORD-1", 1250, "READY"), result);
server.verify(1, postRequestedFor(urlEqualTo("/orders"))
.withRequestBody(matchingJsonPath("$.operationId", equalTo("OP-1"))));桩决定怎样响应,verify 从请求记录中查询实际发生的交互。检查返回对象可以发现客户端解码错误;检查 operationId 与请求次数可以发现参数丢失和额外创建。对于会产生业务副作用的请求,这两类观察都有价值。
WireMock 的 request journal 记录请求,供计数、未匹配查询和 near miss 诊断使用。响应正确但调用次数错误时,应查看是否发生自动重试、重复订阅或客户端初始化请求。关闭 journal 可以降低某些长时间运行场景的内存消耗,但依赖请求记录的验证也会随之受限。WireMock 请求验证
未匹配、优先级与状态
当桩只允许 state=READY,实际请求却带 state=WAITING 时,默认未匹配响应为 404。工程检查状态码、未匹配请求数量和诊断正文;这样可以区分“业务特意返回 404”与“测试根本没有命中预设响应”。
多个桩同时匹配时,显式优先级较小的数字优先。工程使用 priority 1 的具体订单桩覆盖 priority 10 的通用路径桩。没有必要的优先级时尽量让规则互斥,以免后来新增的宽匹配意外改变原场景。WireMock Stubbing
Scenario 用状态机表达多次调用的不同响应:
Scenario order
Started -- GET /orders/ORD-1 --> 返回 PENDING,状态改为 ready
ready -- GET /orders/ORD-1 --> 返回 READY
resetScenarios() --> 回到 Started,保留桩和已有请求记录工程依次断言 PENDING、READY,reset 后再次得到 PENDING,总调用数为 3。Scenario 状态属于服务实例中的命名场景,多测试共享同名场景时会相互影响。按方法创建服务器可以直接隔离;共享服务器则需要明确 reset 的时机和范围。WireMock Stateful Behaviour
请求 journal、桩配置和 Scenario 状态是不同数据。清空请求记录不会自动把业务场景退回 Started,重置场景也不会删除已经注册的映射。排查“第二次调用才错”时,把这三种状态分别检查。
延迟、坏响应与重试
故障场景应对应客户端真正需要处理的情况:
| 服务端行为 | 工程中的客户端结果 | 继续检查 |
|---|---|---|
| 延迟 2 秒,客户端请求预算 250 毫秒 | HttpTimeoutException | 取消、重试策略与整次调用的总预算 |
| 返回 200,但正文为不完整 JSON | IOException 解码失败 | 是否错误缓存成功结果或吞掉异常 |
| 返回 400 和稳定错误信息 | StatusException,调用次数 1 | 永久拒绝是否被无条件重试 |
| 返回合法 JSON,但缺必需字段 | INVALID_ORDER_RESPONSE | 字段类型、单位与缺省策略 |
HttpClient 的连接超时与单次请求超时是不同配置。连接可以已建立但响应迟迟未到,连接预算无法替代请求预算。测试用明显分开的延迟和 deadline 制造结果,不对毫秒级执行耗时作脆弱的精确断言。
更深入的网络故障可使用分块延迟、连接重置或其他 Fault。平台与客户端对断连的异常包装可能不同,应围绕“调用失败、是否重试、是否留下未知结果”设计断言,而不是硬编码所有底层异常字符串。WireMock 故障模拟
超时后服务端可能已经完成操作。涉及创建订单和扣款时,重试需要沿用业务操作 ID,并由服务端按协议处理重复动作;测试应同时覆盖首次结果未知、再次查询或提交、最终只形成一次业务效果。单纯给失败请求配置三次重试会改变业务语义。
用 curl 查看服务器收到什么
Java 测试之外,可以复用 Maven 已下载的 standalone JAR 启动一个只绑定宿主回环端口的 WireMock。沿用前面的目录和变量:
docker run -d --rm --name test16-wiremock-curl \
--user "$(id -u):$(id -g)" -p 127.0.0.1:18630:18630 \
--mount "type=bind,src=$LAB_DIR/.m2,dst=/m2,readonly" \
"$MAVEN_IMAGE" java -jar \
/m2/org/wiremock/wiremock-standalone/3.13.2/wiremock-standalone-3.13.2.jar \
--port 18630 --root-dir /tmp --disable-banner先用 docker logs test16-wiremock-curl 确认启动完成,再向管理 API 添加桩。管理端点用于开发测试,应保持在受控网络中。WireMock Standalone
curl -q --noproxy '*' --fail-with-body --max-time 5 -sS \
-X POST http://127.0.0.1:18630/__admin/mappings \
-H 'Content-Type: application/json' \
--data '{"request":{"method":"GET","url":"/orders/ORD-1"},"response":{"status":200,"headers":{"Content-Type":"application/json"},"jsonBody":{"id":"ORD-1","totalCents":1250,"state":"READY"}}}'
curl -q --noproxy '*' --fail-with-body --max-time 5 -sS \
http://127.0.0.1:18630/orders/ORD-1第一次返回已创建的映射及其 ID,第二次返回订单 JSON。-q 必须放在第一个选项以禁用默认 curl 配置;--noproxy '*' 禁用该请求的代理路径;成功请求用 --fail-with-body 让 HTTP 错误进入失败状态。curl 官方手册
故意访问未注册路径时,分别保存传输结果、HTTP 状态和正文:
set +e
status="$(curl -q --noproxy '*' --max-time 5 -sS \
-o missing.txt -w '%{http_code}' \
http://127.0.0.1:18630/orders/MISSING)"
transport=$?
set -e
test "$transport" -eq 0
test "$status" = 404
grep -F 'Request was not matched' missing.txt
curl -q --noproxy '*' --fail-with-body --max-time 5 -sS \
http://127.0.0.1:18630/__admin/requests/unmatched
docker stop test16-wiremock-curl这里的 404 是预期实验结果,不使用 --fail-with-body 混淆传输成功与业务状态检查。若 transport 非零,先检查进程、端口和网络。最后一条命令停止并删除带 --rm 的临时服务器;Java 测试启动的随机端口服务器与它相互独立。
让消费者契约由真实提供者验证
Schema、消费者契约与业务规则
OpenAPI 可以描述路径、参数、请求/响应结构、安全要求等,适合文档、生成客户端和 schema 验证;消费者契约记录特定消费者真正依赖的交互。两者都需要谨慎填写金额单位、枚举含义和错误语义。标准字段与 Schema 定义见 OpenAPI Specification。
WireMock 桩由测试编写者准备,能够验证客户端在这些响应下的行为。Pact 进一步把消费者依赖保存成可交换文件,让提供者执行相同交互。整个过程分三个阶段:
消费者测试
真实 OrdersClient → Pact mock server
│ 请求符合预期、消费者断言成立
▼
checkout-consumer-orders-provider.json
│ 交互、provider state、匹配规则
▼
提供者验证
准备真实 provider state → 请求实际 OrdersProvider → 比较响应消费者文件生成成功后,仍要执行提供者验证。提供者如果只启动另一个照抄契约响应的 mock,验证不到自己的路由、序列化和应用数据。
由真实客户端生成契约
工程的消费者定义一条待验证交互:
@Pact(consumer = "checkout-consumer", provider = "orders-provider")
RequestResponsePact readyOrder(PactDslWithProvider builder) {
return builder.given("ORD-1 is ready with totalCents 1250")
.uponReceiving("read the ready order")
.path("/orders/ORD-1").method("GET")
.headers("Accept", "application/json")
.willRespondWith().status(200)
.headers(Map.of("Content-Type", "application/json"))
.body("{\"id\":\"ORD-1\",\"totalCents\":1250,\"state\":\"READY\"}")
.toPact();
}测试方法取得 Pact 提供的 MockServer 地址,调用实际 OrdersClient.get("ORD-1"),并比较解码后的 Order。Pact Extension 负责请求验证与文件写出。文件输出目录通过测试 JVM 的 pact.rootDir 指定;工程使用 overwrite 与 clean 控制这次实验的输出,不把旧交互无意累加到本轮契约中。Pact JVM 消费者测试
这个示例故意使用明确的金额值 1250,连同 provider state 固定金额语义。通用契约可以使用类型、正则和集合匹配规则,以允许不影响消费者的合法变化;但只有整数类型约束时,12 与 1250 都可能合法,不能靠字段类型发现单位缩放错误。
提供者准备状态并回放
OrdersProviderIT 从 target/pacts 加载契约。在每次验证前启动真实 HTTP handler,将 HttpTestTarget 指向其随机端口;@State 方法把订单放入该提供者的实际存储:
@BeforeEach
void start(PactVerificationContext context) throws Exception {
provider = new OrdersProvider(Boolean.getBoolean("provider.broken"));
context.setTarget(new HttpTestTarget("127.0.0.1", provider.port(), "/"));
}
@State("ORD-1 is ready with totalCents 1250")
void ready() {
provider.prepare("ORD-1", 1250);
}
@TestTemplate
@ExtendWith(PactVerificationInvocationContextProvider.class)
void verifyInteraction(PactVerificationContext context) {
context.verifyInteraction();
}生产项目通常把 state preparation 接到测试数据库、业务 Fixture 或受控测试接口。每条交互应能独立准备前置状态,避免依赖上一条交互先创建订单。状态准备的方法名不是接口生产协议,也不应向公共网络开放随意改库能力。Pact JVM 提供者验证
正常报告显示状态码和正文匹配成功。随后在 Maven 命令中加入 -Dprovider.broken=true:实际提供者会将 totalCents 改为 amount,Pact 应报告缺少必需字段。
set +e
docker run --rm --user "$(id -u):$(id -g)" \
-e HOME=/tmp -e MAVEN_CONFIG=/tmp/.m2 \
--mount "type=bind,src=$LAB_DIR,dst=/work" \
--mount "type=bind,src=$LAB_DIR/.m2,dst=/m2" \
--workdir /work "$MAVEN_IMAGE" \
mvn -B -ntp -Duser.home=/tmp -Dmaven.repo.local=/m2 \
-Dprovider.broken=true verify > provider-broken.log 2>&1
status=$?
set -e
test "$status" -ne 0
grep -F 'missing the following keys: totalCents' provider-broken.log失败来自 Failsafe 的提供者验证,消费者和 WireMock 测试仍可能全部成功。移除该属性,重新运行最初的 clean verify,恢复正常的 8 次消费者侧执行和 1 次提供者验证。
管理兼容变化并定位失败
变化要对照真实消费者需求
新增可选响应字段通常比删除字段容易兼容,但还要考虑消费者是否拒绝未知字段;新增枚举值可能让穷举 switch 失败;数值从整数变成字符串、分页排序改变、默认时区变化,都可能影响既有调用。请求端则要检查新增必填项、默认值、认证要求和错误响应。
契约应包含成功与重要拒绝场景,并保留消费者实际使用的匹配规则。不要为了让新提供者通过,先把消费者需要的字段从契约中删掉;应先协调新的读取逻辑和过渡期。存在独立部署与回退时,提供者需要同时服务仍在线的消费者版本。
多个仓库可以通过 Pact Broker 保存消费者版本、契约内容和提供者验证结果,并查询某个具体版本组合能否部署。can-i-deploy 的回答取决于已记录的验证矩阵和目标环境,不应只查询模糊的 latest 标签。Pact 部署兼容查询
本地工程使用文件交接,不要求部署 Broker。接入团队现有 Broker 后,再将不可变构建版本与实际部署环境关联,保留旧版本的验证结果以支持回退判断。
按失败位置缩小问题
| 失败位置 | 先检查 | 常见处理 |
|---|---|---|
| WireMock 未匹配 | 方法、路径/query、header、JSON、Scenario 当前状态 | 查看 unmatched 与 near miss,再收紧或纠正对应桩 |
| 返回正确但次数错误 | 客户端重试、重复触发、journal 是否跨测试保留 | 明确一次业务操作的调用策略并隔离记录 |
| 偶发 timeout | 延迟与预算间隔、CPU 饱和、服务是否启动完成 | 先分清预期故障与基础设施慢,再调整等待条件 |
| 消费者文件为空 | 测试是否执行、交互是否命中、输出路径与覆盖方式 | 重新运行消费者测试并检查实际文件 |
| Provider state 不存在 | state 名称和处理方法、Fixture 准备范围 | 为每个 state 独立准备可重复数据 |
| 提供者正文不匹配 | 实际响应、必需字段、matcher、单位和序列化配置 | 修复提供者或协商兼容迁移,重跑消费者和提供者 |
| Broker 无法给出兼容答案 | 精确应用版本、分支、环境与验证结果是否已登记 | 补齐对应版本验证,保留未知状态 |
测试日志中的响应正文、认证 header 和契约样例都可能带业务数据。使用明确的实验身份与数据,分享失败报告前进行脱敏。录制线上流量再回放时,还要处理令牌、签名、个人信息、动态 ID 和状态准备;原始抓包不能直接成为长期公共 Fixture。
接口兼容测试主要回答通信双方能否继续按既有约定协作。数据库原子性、消息重投和并发写入,需要继续检查实际状态变化,见 数据库、消息与并发测试。
权威资料与规范地址
HTTP 与 WireMock
- HTTP Semantics:https://www.rfc-editor.org/rfc/rfc9110.html
- WireMock 请求匹配:https://wiremock.org/docs/request-matching/
- 请求验证:https://wiremock.org/docs/verifying/
- 桩与优先级:https://wiremock.org/docs/stubbing/
- 有状态行为:https://wiremock.org/docs/stateful-behaviour/
- 故障模拟:https://wiremock.org/docs/simulating-faults/
- Standalone JAR:https://wiremock.org/docs/standalone/java-jar/
- curl 手册:https://curl.se/docs/manpage.html
契约与执行
- OpenAPI:https://spec.openapis.org/oas/latest.html
- Pact JVM 消费者:https://docs.pact.io/implementation_guides/jvm/consumer/junit5
- Pact JVM 提供者:https://docs.pact.io/implementation_guides/jvm/provider/junit5
- Pact can-i-deploy:https://docs.pact.io/pact_broker/can_i_deploy
- JUnit 扩展生命周期:https://docs.junit.org/6.0.3/extensions/overview.html
