WireMock 实战——HTTP 依赖 Mock,不再依赖外部服务做集成测试
WireMock 实战——HTTP 依赖 Mock,不再依赖外部服务做集成测试
适读人群:Java 后端开发者、微服务架构实践者 | 阅读时长:约 16 分钟 | 核心价值:用 WireMock 模拟 HTTP 外部依赖,让集成测试完全可控、可重复
做过对接第三方支付的同学,应该都有过这样的经历。
我们有一个项目需要对接微信支付、支付宝、银行卡快捷支付三个渠道。对接完成后,测试这块儿让我头疼了很久:你总不能真的发起一笔支付来测试吧?就算用沙箱环境,沙箱有时候不稳定,有时候返回格式和文档不一致,有时候限额有问题,搞得测试三天两头随机失败,完全没法放到 CI 里。
后来有一次,我们的支付宝沙箱突然挂了整整两天,整个团队的集成测试流水线全部挂起,相关功能的开发全部停摆。那两天,我深刻感受到了对外部依赖的控制有多重要。
就是那之后,我把所有 HTTP 外部依赖全部换成了 WireMock。
WireMock 的思路是:在测试环境里启动一个真实的 HTTP 服务器,你可以精确控制这个服务器对各种请求的响应。测试代码指向 WireMock 的地址,而不是真实的第三方接口。
今天这篇,把 WireMock 的完整使用方案写出来。
一、WireMock 解决的场景
- 支付、短信、推送等第三方 API 的集成测试
- 需要模拟对方服务各种响应场景(正常、超时、500错误、格式异常)
- 测试代码对网络的容错逻辑
- 微服务之间的 HTTP 调用(配合 Testcontainers 的 WireMock 模块)
二、依赖引入
<dependencies>
<!-- WireMock Standalone(Spring Boot 3 推荐用 3.x) -->
<dependency>
<groupId>org.wiremock</groupId>
<artifactId>wiremock-standalone</artifactId>
<version>3.3.1</version>
<scope>test</scope>
</dependency>
<!-- WireMock Spring Boot 集成 -->
<dependency>
<groupId>org.wiremock.integrations</groupId>
<artifactId>wiremock-spring-boot</artifactId>
<version>3.3.1</version>
<scope>test</scope>
</dependency>
<!-- Spring Boot Feign 或 RestTemplate -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
</dependencies>三、基础用法:支付回调 Mock
被测服务——支付宝支付客户端:
@FeignClient(name = "alipay", url = "${alipay.gateway-url}")
public interface AlipayClient {
@PostMapping("/gateway.do")
AlipayResponse createOrder(@RequestBody AlipayOrderRequest request);
@GetMapping("/gateway.do")
AlipayQueryResponse queryOrder(@RequestParam("out_trade_no") String outTradeNo);
}@Service
@RequiredArgsConstructor
@Slf4j
public class PaymentService {
private final AlipayClient alipayClient;
private final OrderRepository orderRepository;
public PaymentResult pay(PayRequest request) {
try {
AlipayResponse response = alipayClient.createOrder(
AlipayOrderRequest.from(request));
if ("10000".equals(response.getCode())) {
return PaymentResult.success(response.getTradeNo());
} else {
log.warn("支付宝创建订单失败: code={}, msg={}", response.getCode(), response.getMsg());
return PaymentResult.failed(response.getMsg());
}
} catch (FeignException.ServiceUnavailable e) {
log.error("支付宝网关不可用", e);
return PaymentResult.unavailable();
}
}
}WireMock 集成测试:
@SpringBootTest
@EnableWireMock({
@ConfigureWireMock(name = "alipay-mock", property = "alipay.gateway-url")
})
class PaymentServiceIntegrationTest {
@InjectWireMock("alipay-mock")
private WireMockServer alipayMock;
@Autowired
private PaymentService paymentService;
@BeforeEach
void setUp() {
alipayMock.resetAll(); // 每次测试前清理 stub
}
@Test
void 支付宝创建订单_成功响应_返回成功结果() {
// given - Mock 支付宝网关返回成功
alipayMock.stubFor(
post(urlPathEqualTo("/gateway.do"))
.withRequestBody(matchingJsonPath("$.out_trade_no"))
.willReturn(aResponse()
.withStatus(200)
.withHeader("Content-Type", "application/json")
.withBody("""
{
"alipay_trade_create_response": {
"code": "10000",
"msg": "Success",
"trade_no": "2024011522001452010502174",
"out_trade_no": "ORDER-20240115-001"
}
}
""")
)
);
// when
PayRequest request = PayRequest.builder()
.orderId("ORDER-20240115-001")
.amount(new BigDecimal("299.00"))
.subject("商品购买")
.build();
PaymentResult result = paymentService.pay(request);
// then
assertThat(result.isSuccess()).isTrue();
assertThat(result.getTradeNo()).isEqualTo("2024011522001452010502174");
// 验证请求确实发出去了
alipayMock.verify(postRequestedFor(urlPathEqualTo("/gateway.do")));
}
@Test
void 支付宝网关超时_触发降级_返回不可用状态() {
// given - Mock 超时
alipayMock.stubFor(
post(urlPathEqualTo("/gateway.do"))
.willReturn(aResponse()
.withFixedDelay(10000) // 延迟 10 秒
.withStatus(200)
)
);
// when - Feign 超时配置是 3 秒
long start = System.currentTimeMillis();
PaymentResult result = paymentService.pay(buildTestRequest());
long elapsed = System.currentTimeMillis() - start;
// then
assertThat(result.isUnavailable()).isTrue();
assertThat(elapsed).isLessThan(5000); // 应该在 3 秒超时后快速返回
}
@Test
void 支付宝返回系统繁忙_重试后成功() {
// given - 第一次返回繁忙,第二次返回成功
alipayMock.stubFor(
post(urlPathEqualTo("/gateway.do"))
.inScenario("retry-scenario")
.whenScenarioStateIs(Scenario.STARTED)
.willReturn(aResponse()
.withStatus(200)
.withBody("""
{"alipay_trade_create_response": {"code": "20000", "msg": "Service Currently Unavailable"}}
"""))
.willSetStateTo("retry-ready")
);
alipayMock.stubFor(
post(urlPathEqualTo("/gateway.do"))
.inScenario("retry-scenario")
.whenScenarioStateIs("retry-ready")
.willReturn(aResponse()
.withStatus(200)
.withBody("""
{"alipay_trade_create_response": {"code": "10000", "msg": "Success", "trade_no": "TRADE-001"}}
"""))
);
// when
PaymentResult result = paymentService.pay(buildTestRequest());
// then - 重试后应该成功
assertThat(result.isSuccess()).isTrue();
// 验证确实发了 2 次请求
alipayMock.verify(2, postRequestedFor(urlPathEqualTo("/gateway.do")));
}
private PayRequest buildTestRequest() {
return PayRequest.builder()
.orderId("ORDER-TEST-001")
.amount(new BigDecimal("100.00"))
.subject("测试商品")
.build();
}
}四、三个踩坑实录
坑 1:WireMock 端口冲突
现象: 并行运行多个测试类时,偶发 Address already in use: 8080。
原因: 多个测试类各自启动 WireMock,但都指定了固定端口 8080,并发时冲突。
解法: 使用随机端口(WireMock 的默认行为),不要指定固定端口:
// 不要这样做
new WireMockServer(8080); // 固定端口,并发时冲突
// 正确做法:随机端口
new WireMockServer(wireMockConfig().dynamicPort());
// 或者用 @EnableWireMock 注解(自动随机端口)
// 然后在 @DynamicPropertySource 里注入
registry.add("alipay.gateway-url",
() -> "http://localhost:" + wireMockServer.port());坑 2:Stub 匹配不精确导致错误响应
现象: 不同测试方法设置了不同的 Stub,但运行时匹配到了错误的 Stub,返回了不应该返回的响应。
原因: WireMock 的 Stub 是按照定义顺序匹配的,精确度低的 Stub 可能匹配了原本应该走精确 Stub 的请求。
解法: 设置 Stub 优先级,并且每次测试开始前 resetAll():
@BeforeEach
void setUp() {
wireMockServer.resetAll(); // 清理上一个测试的 Stub
}
// 设置 Stub 优先级
wireMockServer.stubFor(
post(urlPathEqualTo("/api/payment"))
.withPriority(1) // 数字越小优先级越高
.withRequestBody(matchingJsonPath("$.orderId", equalTo("ORDER-001")))
.willReturn(...)
);坑 3:HTTPS 外部服务 Mock 证书问题
现象: 真实外部服务是 HTTPS,WireMock 默认走 HTTP,切换时 Feign 客户端报证书验证失败。
原因: WireMock 支持 HTTPS,但需要额外配置,而且 Feign 的 SSL 验证默认是严格的。
解法一: WireMock 开启 HTTPS 模式:
new WireMockServer(wireMockConfig()
.dynamicPort()
.dynamicHttpsPort()
.keystorePath("test-keystore.jks")
.keystorePassword("password"));解法二(更简单): 在测试配置里关闭 SSL 验证:
@TestConfiguration
public class TestFeignConfig {
@Bean
public Client feignClient() {
// 测试环境关闭 SSL 验证
return new Client.Default(
SSLSocketFactory.getDefault() instanceof SSLSocketFactory ?
TrustAllSSLSocketFactory.create() : null,
(hostname, session) -> true // 信任所有主机名
);
}
}五、WireMock + Testcontainers:独立的 Mock 服务
对于复杂的微服务场景,可以把 WireMock 跑在 Docker 容器里:
@Container
static GenericContainer<?> wireMockContainer = new GenericContainer<>(
DockerImageName.parse("wiremock/wiremock:3.3.1"))
.withExposedPorts(8080)
.withCommand("--global-response-templating")
.withCopyFileToContainer(
MountableFile.forClasspathResource("wiremock/mappings/"),
"/home/wiremock/mappings/"
);
@DynamicPropertySource
static void configureWireMock(DynamicPropertyRegistry registry) {
registry.add("external.payment.url",
() -> "http://" + wireMockContainer.getHost()
+ ":" + wireMockContainer.getMappedPort(8080));
}六、文件中预定义 Stub(适合复杂场景)
把 Stub 定义放在 JSON 文件里,更易维护:
// src/test/resources/wiremock/mappings/alipay-success.json
{
"request": {
"method": "POST",
"urlPath": "/gateway.do",
"bodyPatterns": [
{"matchesJsonPath": "$.out_trade_no"}
]
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"jsonBody": {
"alipay_trade_create_response": {
"code": "10000",
"msg": "Success",
"trade_no": "{{randomValue type='ALPHANUMERIC' length=20}}"
}
},
"transformers": ["response-template"]
}
}WireMock 的响应模板功能(response-template)支持动态生成响应内容,非常适合需要返回动态数据的场景。
七、WireMock vs MockBean:如何选择
| 场景 | 推荐 |
|---|---|
| Spring Bean 的单元测试 | @MockBean |
| HTTP 外部服务的集成测试 | WireMock |
| 需要测试超时/网络错误 | WireMock |
| 需要测试请求的 HTTP 细节(header、body 格式) | WireMock |
| 需要测试第三方回调 | WireMock |
| 微服务间 HTTP 依赖 | WireMock 或服务虚拟化 |
一句话原则:能启动真实依赖(Testcontainers)的,用真实依赖;必须用 Mock 的 HTTP 外部服务,用 WireMock 而不是 MockBean。
那次支付宝沙箱挂掉的教训,让我们团队彻底摆脱了对外部依赖的恐惧。有了 WireMock,支付测试的通过率从 "70%(沙箱不稳定)" 提升到了 "99.9%"。
