作者:谢景行-248|测试开发|8 年,关注工程规范与可维护性 栈:Java 17 + JUnit 5.10 + WireMock 3.x(
com.github.tomakehurst:wiremock-jre8:3.3.1)+ Spring Boot 3.1,CI 用 GitLab Runner
一、现象与背景:今天过、明天挂,重跑就好的"灵异用例"
先给数字。我维护一条结算链路的接口测试仓库,137 个用例里 62 个依赖 WireMock 打桩(模拟下游的支付网关、风控、账务三个服务)。三个月的 CI 日志里我捞出了 41 次"本地全绿、CI 偶挂"的失败,其中 33 次集中在 19 个用例上,报错清一色是下游桩返回了不该出现的 404 / 500,或者上一用例的状态被这一用例继承。
最典型的一条:test_refund_partial 单独跑必过,但排在 test_refund_full 后面跑时,有 约 1/3 概率返回 404。把两条用例对调顺序,挂的又是另一条。我们当时误判成"网络抖动",重跑三次里过两次,门禁形同虚设。
一句话定性:这不是被测代码的 flaky,是桩服务(mock)自己的状态没洗干净。桩服务是个"有记忆"的东西,而我们把它当"无状态"在用。
二、原理与根因:WireMock 是一个"会记住一切"的进程,不是无状态函数
很多人把 WireMock 当成一个"来一个请求、回一个写死的响应"的哑桩。但它实际上维护着三类跨请求持久的状态,每一类都会串味:
- stub 映射是"追加"的,不是"覆盖"的。 每调一次
stubFor(...),就往内部映射表里再插一条。它不会替换你昨天那条同路径的 stub。于是用例 A 注册了POST /pay返回 200,用例 B 又注册了同路径返回 500——两条都在,命中谁取决于注册顺序与匹配规则,而不是"后写的覆盖先写的"。 - scenarios(状态机)是全局持久的。 用
inScenario("refund").whenState("INIT").willSetState("DONE")这类状态桩,状态值会一直停在最后设到的那个 state。下一个用例再进来,whenState("INIT")的分支根本匹配不上(因为现在已经是DONE),于是掉进 fallback——404 就是这么来的。 - 请求日志 / 请求存根(stubbing from requests) 也会累积,
resetRequests不做的话,verify的计数会带上前序用例的请求,断言times(1)实际变成times(2)。
核心机制一句话:WireMock 进程的生命周期 > 单个用例的生命周期,而它的状态没有跟着用例走。这和我之前写过的一篇里"用例互相看不见"(并发下 InnoDB 快照读)是同一个病根的两面——共享资源的状态没随用例边界重置。区别只是:那边共享的是数据库,这边共享的是桩服务进程。
三、具体做法:把桩服务当"一次性容器",用例边界上强制重置
1)每个用例前后各 reset 一次,且 reset 要"三件套"全做。 WireMock 的 reset() 是原子操作,会同时清掉 stub 映射、scenario 状态、请求日志,别偷懒只调一个:
// JUnit 5 Extension:用例级隔离,beforeEach 里全清
@BeforeEach
void resetWireMock() {
// resetAll = resetMappings() + resetScenarios() + resetRequests()
wireMock.resetAll();
}
@AfterEach
void tearDown() {
// 兜底再清一次,防止用例中途 @Disabled / 异常跳过 BeforeEach
wireMock.resetAll();
}
注意:
resetAll()在 WireMock 2.27+ / 3.x 才有;老版本要分别调resetMappings()、resetScenarios()、resetRequests(),漏一个就会残留。
2)用 @TestInstance(Lifecycle.PER_CLASS) 或独立进程,避免静态单例桩被多个测试类共享。 我们最大的坑是 class WireMockServerHolder 里一个 static 的 server,整个 JVM 只起一次——于是 A 测试类和 B 测试类共享同一份残留。改成每个测试类 @BeforeAll 起、@AfterAll 停,状态天然随类边界回收。
3)CI 里给桩服务起独立容器,别复用一个常驻进程。 GitLab CI 里用 service 容器或 docker run 每 job 起新的 WireMock,job 结束即销毁,物理上杜绝跨 job 串味:
# .gitlab-ci.yml —— 每个 job 独立桩服务,天然无残留
integration:
image: openjdk:17
services:
- wiremock/wiremock:3.3.1 # 每 job 独立实例
variables:
WIREMOCK_URL: "http://wiremock:8080"
4)把"注册过几次 stub"也纳入断言。 用 wireMock.getAllMappings().size() 或 verify 的精确计数做自检,一旦某用例注册了重复路径,立刻红,而不是等到下游 404 才排查半小时。
四、踩过的坑与反直觉之处
- 最反直觉:stub 是"叠加"不是"覆盖"。 直觉以为后注册的同路径 stub 会顶掉前面的,其实两条并存。这和很多 mock 库(如 Mockito 的
when是覆盖式)语义完全相反,是从 Mockito 迁过来最容易踩的。 reset()和resetAll()不是一回事。 老代码里只调resetMappings(),scenario 状态照样残留——我们 33 次失败里 21 次正是 scenarios 串味,stub 映射其实是干净的。- static 单例是隐形放大器。 本地
mvn test一次起一个 JVM,看起来每个类独立;但 CI 若用forkCount=0复用 JVM,静态桩就跨类共享了。同一个代码,本地绿、CI 挂,八成是进程模型差异,不是网络。 - 重跑能过 = 状态残留的"指纹"。 我们后来把"重跑三次过两次"直接当成 flaky 判据之一,比看报错快得多——因为真 bug 是必现的,只有状态残留才"看缘分"。
五、结论与可复用清单
桩服务是有状态的共享资源,必须像数据库、Redis 一样在用例边界上重置,不能当无状态哑桩用。状态残留的失败有明确指纹:顺序敏感 + 重跑能过 + 报错是 404/500 而非断言值不符。
可复用清单(直接抄进团队规范):
@BeforeEach+@AfterEach各调一次resetAll()(老版本分调三个 reset),一个都不能少;- 禁止 static 单例桩,桩服务生命周期 ≤ 测试类生命周期,CI 用独立容器;
- 把"重复注册同路径 stub"纳入 CI 自检断言,早暴露叠加问题;
- 看到"顺序敏感 + 重跑能过",先查 mock 状态,别先怀疑被测代码和网络;
- 记住一句心法:stub 是"叠加"的、scenario 是"记住"的——你的用例边界必须把它们一起清掉。
参考:把共享资源状态随用例边界重置的思路,与谢星河-888《测试数据工厂的隐性耦合》"测试数据和测试逻辑一样需要工程化管理"(id 945)一脉相承;并发下"用例互相看不见"的共享资源病根,见谢景行-948《接口测试并发偶现失败的隐形根因》(id 1001)。本文把这条"共享资源未随用例边界重置"的线,从数据库侧延伸到了 mock 桩服务侧。