微服务接口测试:WireMock与契约测试(CDC)
上篇咱们用RestAssured搞定了单体应用的接口测试。但微服务架构下,你的服务依赖一堆下游服务,怎么测?今天聊WireMock模拟和契约测试,这是微服务测试的两大杀器。
一、微服务测试的困境
假设你在开发订单服务,它依赖这些下游:
┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ 用户服务 │ │ 库存服务 │ │ 支付服务 │ │ user-svc │ │ stock-svc │ │ pay-svc │ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │ │ │ └───────────────────┼───────────────────┘ │ ┌──────┴──────┐ │ 订单服务 │ │ order-svc │ └─────────────┘问题1:下游服务没就绪
你在开发订单服务的"创建订单"功能,但库存服务还在开发中。怎么办?等他们做完?
问题2:下游服务不稳定
测试环境库存服务经常挂,你的测试也跟着红一片。到底是你的bug还是他们的锅?
问题3:接口变更没人通知
支付服务改了响应字段,从paymentStatus变成了status。你的代码没改,线上直接炸。
这三个问题,WireMock + 契约测试能一起解决。
二、WireMock:你的"替身演员"
WireMock的核心思想:在测试时启动一个假的HTTP服务,模拟下游的响应。
快速上手
<dependency><groupId>com.github.tomakehurst</groupId><artifactId>wiremock-jre8</artifactId><version>2.35.0</version><scope>test</scope></dependency>场景:订单服务依赖库存服务
@ExtendWith(SpringExtension.class)@SpringBootTest(webEnvironment=SpringBootTest.WebEnvironment.RANDOM_PORT)classOrderServiceIntegrationTest{@LocalServerPortprivateintorderServicePort;// 启动WireMock服务器(模拟库存服务)@RegisterExtensionstaticWireMockExtensionstockServiceMock=WireMockExtension.newInstance().options(wireMockConfig().dynamicPort()).build();@AutowiredprivateOrderServiceorderService;@DynamicPropertySourcestaticvoidconfigureProperties(DynamicPropertyRegistryregistry){// 让订单服务连上WireMock,而不是真实的库存服务registry.add("inventory.service.url",stockServiceMock::baseUrl);}@Test@DisplayName("库存充足时,订单创建成功")voidshouldCreateOrderWhenStockSufficient(){// 配置WireMock:当收到库存查询请求时,返回"有库存"stockServiceMock.stubFor(get(urlPathEqualTo("/api/stock/ITEM-001")).willReturn(aResponse().withStatus(200).withHeader("Content-Type","application/json").withBody(""" { "sku": "ITEM-001", "availableQuantity": 100, "reservedQuantity": 5 } """)));// 配置扣减库存的mock响应stockServiceMock.stubFor(post(urlPathEqualTo("/api/stock/deduct")).withRequestBody(containing("ITEM-001")).willReturn(aResponse().withStatus(200).withBody(""" { "success": true, "deductedQuantity": 2, "remainingQuantity": 98 } """)));// 执行测试OrderRequestrequest=newOrderRequest("ITEM-001",2);OrderResultresult=orderService.createOrder(request);// 验证assertThat(result.isSuccess()).isTrue();assertThat(result.getOrderId()).isNotNull();// 验证订单服务确实调了库存服务的扣减接口stockServiceMock.verify(postRequestedFor(urlPathEqualTo("/api/stock/deduct")).withRequestBody(containing("ITEM-001")));}@Test@DisplayName("库存不足时,订单创建失败")voidshouldFailWhenStockInsufficient(){// 模拟库存不足stockServiceMock.stubFor(get(urlPathEqualTo("/api/stock/ITEM-001")).willReturn(aResponse().withStatus(200).withBody(""" { "sku": "ITEM-001", "availableQuantity": 1, "reservedQuantity": 0 } """)));OrderRequestrequest=newOrderRequest("ITEM-001",5);// 要买5个,但只有1个// 期望抛出库存不足异常assertThatThrownBy(()->orderService.createOrder(request)).isInstanceOf(InsufficientStockException.class).hasMessageContaining("库存不足");}}WireMock 的核心API速查
// 匹配请求get(urlEqualTo("/api/users/1"))get(urlPathEqualTo("/api/users/1"))get(urlPathMatching("/api/users/\\d+"))post(urlEqualTo("/api/orders")).withHeader("Content-Type",containing("json")).withRequestBody(equalToJson("{\"name\":\"Alice\"}"))// 构造响应willReturn(aResponse().withStatus(200).withHeader("Content-Type","application/json").withBody("{\"id\":1}").withFixedDelay(500))// 模拟延迟500ms// 验证请求是否被调用verify(getRequestedFor(urlEqualTo("/api/users/1")))verify(2,postRequestedFor(urlEqualTo("/api/orders")))// 验证调了2次verify(0,deleteRequestedFor(anyUrl()))// 验证没调过delete三、WireMock 的进阶玩法
1. 从文件加载响应(适合大JSON)
// 把响应体放在 src/test/resources/wiremock/stock-available.jsonstockServiceMock.stubFor(get(urlPathEqualTo("/api/stock/ITEM-001")).willReturn(aResponse().withStatus(200).withBodyFile("wiremock/stock-available.json"))// 从文件加载);2. 模拟故障场景
@Test@DisplayName("库存服务超时,订单服务应该降级")voidshouldFallbackWhenStockServiceTimeout(){// 模拟超时stockServiceMock.stubFor(get(urlPathEqualTo("/api/stock/ITEM-001")).willReturn(aResponse().withStatus(200).withFixedDelay(10000))// 10秒延迟,触发超时);// 验证降级逻辑OrderResultresult=orderService.createOrder(request);assertThat(result.isSuccess()).isTrue();// 降级后仍然成功assertThat(result.isStockChecked()).isFalse();// 但跳过了库存检查}@Test@DisplayName("库存服务返回500,订单服务应该重试")voidshouldRetryWhenStockServiceError(){// 模拟500错误stockServiceMock.stubFor(get(urlPathEqualTo("/api/stock/ITEM-001")).willReturn(aResponse().withStatus(500)));// 验证重试次数assertThatThrownBy(()->orderService.createOrder(request)).isInstanceOf(StockServiceException.class);// 验证调了3次(重试2次 + 原始1次)stockServiceMock.verify(3,getRequestedFor(urlPathEqualTo("/api/stock/ITEM-001")));}3. 有状态的Mock(模拟流程)
@Test@DisplayName("库存扣减后查询,数量应该减少")voidshouldReflectDeductedStock(){// 初始状态:有100个stockServiceMock.stubFor(get(urlPathEqualTo("/api/stock/ITEM-001")).inScenario("Stock Deduction").whenScenarioStateIs(Scenario.STARTED).willReturn(aResponse().withBody("{\"availableQuantity\": 100}")).willSetStateTo("Deducted"));// 扣减后状态:剩98个stockServiceMock.stubFor(get(urlPathEqualTo("/api/stock/ITEM-001")).inScenario("Stock Deduction").whenScenarioStateIs("Deducted").willReturn(aResponse().withBody("{\"availableQuantity\": 98}")));// 先查询StockInfobefore=stockClient.queryStock("ITEM-001");assertThat(before.getAvailableQuantity()).isEqualTo(100);// 扣减stockClient.deduct("ITEM-001",2);// 再查询StockInfoafter=stockClient.queryStock("ITEM-001");assertThat(after.getAvailableQuantity()).isEqualTo(98);}四、契约测试:接口变更的"防火墙"
WireMock解决了"测试时模拟下游"的问题,但还有一个问题:下游服务改了接口,怎么及时发现?
这就是**消费者驱动契约测试(Consumer-Driven Contract, CDC)**要解决的。
核心思想
消费者(订单服务) 契约文件 提供者(库存服务) │ │ │ │ 我期望接口长这样 │ │ │ ─────────────────────> │ │ │ │ │ │ │ 验证你的实现是否符合契约 │ │ │ ─────────────────────>│ │ │ │ │ │ 符合 / 不符合 │ │ │ <─────────────────────│简单说:消费者定义"我期望你怎么响应",提供者验证"我是不是按这个实现的"。
Spring Cloud Contract 实战
1. 消费者端(订单服务)定义契约
在订单服务的src/test/resources/contracts/下创建契约文件:
// shouldReturnStockInfo.groovypackagecontracts.stock org.springframework.cloud.contract.spec.Contract.make{request{method'GET'url'/api/stock/ITEM-001'headers{contentType(applicationJson())}}response{status200headers{contentType(applicationJson())}body([sku:'ITEM-001',availableQuantity:100,reservedQuantity:5])}}2. 订单服务生成Stub并发布
<!-- pom.xml --><plugin><groupId>org.springframework.cloud</groupId><artifactId>spring-cloud-contract-maven-plugin</artifactId><version>4.0.0</version><extensions>true</extensions><configuration><baseClassForTests>com.example.BaseContractTest</baseClassForTests></configuration></plugin>运行mvn clean install,会自动:
- 根据契约文件生成测试代码
- 生成Stub JAR(包含WireMock映射)
- 发布到Maven仓库
3. 库存服务(提供者)验证契约
<!-- 库存服务引入契约依赖 --><dependency><groupId>com.example</groupId><artifactId>order-service</artifactId><version>1.0.0</version><classifier>stubs</classifier><scope>test</scope></dependency>@SpringBootTest(webEnvironment=SpringBootTest.WebEnvironment.MOCK)@AutoConfigureMessageVerifierclassStockServiceContractTest{@AutowiredprivateStockControllerstockController;// 自动加载订单服务定义的契约,验证库存服务的实现@TestvoidvalidateStockContract(){// 契约测试框架自动执行}}Pact:更轻量的契约测试方案
如果不用Spring生态,Pact是个更通用的选择。
消费者端(生成契约)
<dependency><groupId>au.com.dius.pact.consumer</groupId><artifactId>junit5</artifactId><version>4.6.0</version><scope>test</scope></dependency>@ExtendWith(PactConsumerTestExt.class)@PactTestFor(providerName="stock-service")classStockServicePactTest{@Pact(consumer="order-service")publicRequestResponsePactstockQueryPact(PactDslWithProviderbuilder){returnbuilder.given("stock exists for ITEM-001").uponReceiving("query stock for ITEM-001").path("/api/stock/ITEM-001").method("GET").willRespondWith().status(200).body(newPactDslJsonBody().stringType("sku","ITEM-001").integerType("availableQuantity",100).integerType("reservedQuantity",5)).toPact();}@PactTestFor(pactMethod="stockQueryPact")@TestvoidshouldQueryStock(MockServermockServer){// 使用Pact生成的Mock服务测试消费者代码StockClientclient=newStockClient(mockServer.getUrl());StockInfoinfo=client.queryStock("ITEM-001");assertThat(info.getSku()).isEqualTo("ITEM-001");assertThat(info.getAvailableQuantity()).isPositive();}}运行后会在target/pacts/生成契约JSON文件:
{"consumer":{"name":"order-service"},"provider":{"name":"stock-service"},"interactions":[{"description":"query stock for ITEM-001","providerState":"stock exists for ITEM-001","request":{"method":"GET","path":"/api/stock/ITEM-001"},"response":{"status":200,"body":{"sku":"ITEM-001","availableQuantity":100,"reservedQuantity":5}}}]}提供者端(验证契约)
@Provider("stock-service")@PactFolder("pacts")classStockServiceProviderVerificationTest{@TestTemplate@ExtendWith(PactVerificationInvocationContextProvider.class)voidpactVerificationTestTemplate(PactVerificationContextcontext){context.verifyInteraction();}@BeforeEachvoidbefore(PactVerificationContextcontext){context.setTarget(newHttpTestTarget("localhost",8080));}@State("stock exists for ITEM-001")voidstockExistsState(){// 准备测试数据:确保ITEM-001有库存stockRepository.save(newStock("ITEM-001",100,5));}}五、WireMock vs 契约测试:怎么选?
| 场景 | 用WireMock | 用契约测试 |
|---|---|---|
| 单元/集成测试时模拟下游 | ✅ | ❌ |
| 验证下游接口是否符合预期 | ❌ | ✅ |
| 接口变更时自动发现不兼容 | ❌ | ✅ |
| 跨团队协作,定义接口规范 | 辅助 | ✅ |
| CI流水线中验证契约 | 不行 | ✅ |
最佳实践:两者结合
- 开发时用WireMock快速验证
- 提测前用契约测试确保兼容性
- CI流水线跑契约验证,不通过不让合并
六、小结
今天咱们聊了微服务测试的两大武器:
| 工具 | 解决什么问题 | 核心能力 |
|---|---|---|
| WireMock | 下游服务没就绪/不稳定 | 模拟HTTP服务,控制响应内容、延迟、故障 |
| Spring Cloud Contract | Spring生态的契约测试 | 消费者定义契约,提供者自动验证 |
| Pact | 跨语言的契约测试 | 生成契约文件,独立验证消费者和提供者 |
一句话总结:WireMock让你在"孤岛"上也能开发测试,契约测试让团队之间的接口约定变成可执行的代码。两者结合,微服务测试就稳了。
你们微服务测试怎么做的?用WireMock还是直接连测试环境?欢迎聊聊。