1. 简介

Spring WebClient 是一个非阻塞的响应式 HTTP 客户端,而 WireMock 则是模拟 HTTP API 的强大工具。本文将介绍如何利用 WireMock 来模拟 WebClient 的 HTTP 请求,通过模拟外部服务行为,确保我们的应用能正确处理外部 API 响应。

我们将从添加依赖开始,通过一个简单示例快速上手,最后使用 WireMock API 编写多种场景的集成测试。

2. 依赖与示例

首先确保 Spring Boot 项目包含必要依赖。需要 spring-boot-starter-webflux(提供 WebClient)和 spring-cloud-contract-wiremock(提供 WireMock 服务器):

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-contract-wiremock</artifactId>
    <version>4.1.2</version>
    <scope>test</scope>
</dependency>

接下来定义一个简单示例:通过外部天气 API 获取城市天气数据。先创建 WeatherData POJO:

public class WeatherData {
    private String city;
    private int temperature;
    private String description;
    ....
   //constructor
   //setters and getters
}

我们将使用 WebClient 和 WireMock 对此功能进行集成测试。

3. 使用 WireMock API 进行集成测试

3.1. 测试类基础配置

先搭建 Spring Boot 测试类,集成 WireMock 和 WebClient:

@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureWireMock(port = 0)
public class WeatherServiceIntegrationTest {

    @Autowired
    private WebClient.Builder webClientBuilder;

    @Value("${wiremock.server.port}")
    private int wireMockPort;

    // 创建指向 WireMock 服务器的 WebClient
    WebClient webClient = webClientBuilder.baseUrl("http://localhost:" + wireMockPort).build();
  ....
  ....
}

⚠️ 注意:@AutoConfigureWireMock 会自动在随机端口启动 WireMock 服务器。我们创建的 WebClient 实例将所有请求导向该服务器,只要存在匹配的 stub,就能获得预设响应。

3.2. 模拟成功 JSON 响应

先模拟返回 200 状态码和 JSON 响应的 HTTP 调用:

@Test
public void  givenWebClientBaseURLConfiguredToWireMock_whenGetRequestForACity_thenWebClientRecievesSuccessResponse() {
    // 模拟成功获取天气数据的响应
    stubFor(get(urlEqualTo("/weather?city=London"))
      .willReturn(aResponse()
        .withStatus(200)
        .withHeader("Content-Type", "application/json")
        .withBody("{\"city\": \"London\", \"temperature\": 20, \"description\": \"Cloudy\"}")));

    // 创建指向 WireMock 的 WebClient
    WebClient webClient = webClientBuilder.baseUrl("http://localhost:" + wireMockPort).build();

    // 获取伦敦天气数据
    WeatherData weatherData = webClient.get()
      .uri("/weather?city=London")
      .retrieve()
      .bodyToMono(WeatherData.class)
      .block();
    assertNotNull(weatherData);
    assertEquals("London", weatherData.getCity());
    assertEquals(20, weatherData.getTemperature());
    assertEquals("Cloudy", weatherData.getDescription());
}

当 WebClient 通过指向 WireMock 端口的 base URL 请求 /weather?city=London 时,将返回预设的模拟响应。

3.3. 模拟自定义请求头

有时 HTTP 请求需要自定义头信息。WireMock 可以根据请求头匹配返回对应响应:

@Test
public void givenWebClientBaseURLConfiguredToWireMock_whenGetRequest_theCustomHeaderIsReturned() {
    // 模拟包含自定义头的响应
    stubFor(get(urlEqualTo("/weather?city=London"))
      .willReturn(aResponse()
        .withStatus(200)
        .withHeader("Content-Type", "application/json")
        .withHeader("X-Custom-Header", "baeldung-header")
        .withBody("{\"city\": \"London\", \"temperature\": 20, \"description\": \"Cloudy\"}")));

    // 创建指向 WireMock 的 WebClient
    WebClient webClient = webClientBuilder.baseUrl("http://localhost:" + wireMockPort).build();

    // 获取伦敦天气数据
    WeatherData weatherData = webClient.get()
      .uri("/weather?city=London")
      .retrieve()
      .bodyToMono(WeatherData.class)
      .block();

    // 验证自定义头
    HttpHeaders headers = webClient.get()
      .uri("/weather?city=London")
      .exchange()
      .block()
      .headers();  
    assertEquals("baeldung-header", headers.getFirst("X-Custom-Header"));
}

WireMock 服务器返回包含自定义头的模拟天气数据。

3.4. 模拟异常响应

测试外部服务返回异常的场景同样重要。WireMock 可模拟这些异常情况:

@Test
public void givenWebClientBaseURLConfiguredToWireMock_whenGetRequestWithInvalidCity_thenExceptionReturnedFromWireMock() {
    // 模拟无效城市的响应
    stubFor(get(urlEqualTo("/weather?city=InvalidCity"))
      .willReturn(aResponse()
        .withStatus(404)
        .withHeader("Content-Type", "application/json")
        .withBody("{\"error\": \"City not found\"}")));

    // 创建指向 WireMock 的 WebClient
    WebClient webClient = webClientBuilder.baseUrl("http://localhost:" + wireMockPort).build();

   // 获取无效城市的天气数据
    WebClientResponseException exception = assertThrows(WebClientResponseException.class, () -> {
      webClient.get()
      .uri("/weather?city=InvalidCity")
      .retrieve()
      .bodyToMono(WeatherData.class)
      .block();
});

✅ 关键点:测试 WebClient 在查询无效城市时能否正确处理服务器错误响应。验证请求 /weather?city=InvalidCity 会抛出 WebClientResponseException,确保应用具备正确的错误处理机制。

3.5. 模拟带查询参数的响应

实际开发中常需发送带查询参数的请求。下面模拟这种场景:

@Test
public void givenWebClientWithBaseURLConfiguredToWireMock_whenGetWithQueryParameter_thenWireMockReturnsResponse() {
    // 模拟特定查询参数的响应
    stubFor(get(urlPathEqualTo("/weather"))
      .withQueryParam("city", equalTo("London"))
      .willReturn(aResponse()
        .withStatus(200)
        .withHeader("Content-Type", "application/json")
        .withBody("{\"city\": \"London\", \"temperature\": 20, \"description\": \"Cloudy\"}")));

    // 创建指向 WireMock 的 WebClient
    WebClient webClient = webClientBuilder.baseUrl("http://localhost:" + wireMockPort).build();

    WeatherData londonWeatherData = webClient.get()
      .uri(uriBuilder -> uriBuilder.path("/weather").queryParam("city", "London").build())
      .retrieve()
      .bodyToMono(WeatherData.class)
      .block();
    assertEquals("London", londonWeatherData.getCity());
}

3.6. 模拟动态响应

下面模拟返回随机温度值(10-30℃)的动态响应:

@Test
public void givenWebClientBaseURLConfiguredToWireMock_whenGetRequest_theDynamicResponseIsSent() {
    // 模拟动态温度值的响应
    stubFor(get(urlEqualTo("/weather?city=London"))
      .willReturn(aResponse()
        .withStatus(200)
        .withHeader("Content-Type", "application/json")
        .withBody("{\"city\": \"London\", \"temperature\": ${randomValue|10|30}, \"description\": \"Cloudy\"}")));

    // 创建指向 WireMock 的 WebClient
    WebClient webClient = webClientBuilder.baseUrl("http://localhost:" + wireMockPort).build();

    // 获取伦敦天气数据
    WeatherData weatherData = webClient.get()
      .uri("/weather?city=London")
      .retrieve()
      .bodyToMono(WeatherData.class)
      .block();

    // 验证温度在预期范围内
    assertNotNull(weatherData);
    assertTrue(weatherData.getTemperature() >= 10 && weatherData.getTemperature() <= 30);
}

3.7. 模拟异步行为

通过模拟 1 秒延迟响应,测试应用处理延迟的能力:

@Test
public void  givenWebClientBaseURLConfiguredToWireMock_whenGetRequest_thenResponseReturnedWithDelay() {
    // 模拟带延迟的响应
    stubFor(get(urlEqualTo("/weather?city=London"))
      .willReturn(aResponse()
        .withStatus(200)
        .withFixedDelay(1000) // 1秒延迟
        .withHeader("Content-Type", "application/json")
        .withBody("{\"city\": \"London\", \"temperature\": 20, \"description\": \"Cloudy\"}")));

    // 创建指向 WireMock 的 WebClient
    WebClient webClient = webClientBuilder.baseUrl("http://localhost:" + wireMockPort).build();

    // 获取伦敦天气数据
    long startTime = System.currentTimeMillis();
    WeatherData weatherData = webClient.get()
      .uri("/weather?city=London")
      .retrieve()
      .bodyToMono(WeatherData.class)
      .block();
    long endTime = System.currentTimeMillis();

    assertNotNull(weatherData);
    assertTrue(endTime - startTime >= 1000); // 验证延迟
}

✅ 核心目标:确保应用能优雅处理延迟响应,避免超时或意外错误。

3.8. 模拟有状态行为

使用 WireMock 场景(Scenario)模拟有状态行为,使同一接口在不同调用时返回不同响应:

@Test
public void givenWebClientBaseURLConfiguredToWireMock_whenMulitpleGet_thenWireMockReturnsMultipleResponsesBasedOnState() {
    // 模拟首次调用的响应
    stubFor(get(urlEqualTo("/weather?city=London"))
      .inScenario("Weather Scenario")
      .whenScenarioStateIs("started")
      .willReturn(aResponse()
        .withStatus(200)
        .withHeader("Content-Type", "application/json")
        .withBody("{\"city\": \"London\", \"temperature\": 20, \"description\": \"Cloudy\"}"))
    .willSetStateTo("Weather Found"));

    // 模拟第二次调用的响应
    stubFor(get(urlEqualTo("/weather?city=London"))
      .inScenario("Weather Scenario")
      .whenScenarioStateIs("Weather Found")
      .willReturn(aResponse()
        .withStatus(200)
        .withHeader("Content-Type", "application/json")
        .withBody("{\"city\": \"London\", \"temperature\": 25, \"description\": \"Sunny\"}")));

    // 创建指向 WireMock 的 WebClient
    WebClient webClient = webClientBuilder.baseUrl("http://localhost:" + wireMockPort).build();

    // 首次获取伦敦天气
    WeatherData firstWeatherData = webClient.get()
      .uri("/weather?city=London")
      .retrieve()
      .bodyToMono(WeatherData.class)
      .block();

  // 验证首次响应
  assertNotNull(firstWeatherData);
  assertEquals("London", firstWeatherData.getCity());
  assertEquals(20, firstWeatherData.getTemperature());
  assertEquals("Cloudy", firstWeatherData.getDescription());

  // 再次获取伦敦天气
  WeatherData secondWeatherData = webClient.get()
    .uri("/weather?city=London")
    .retrieve()
    .bodyToMono(WeatherData.class)
    .block();

  // 验证第二次响应
  assertNotNull(secondWeatherData);
  assertEquals("London", secondWeatherData.getCity());
  assertEquals(25, secondWeatherData.getTemperature());
  assertEquals("Sunny", secondWeatherData.getDescription());
}

🔍 实现原理:在 "Weather Scenario" 场景中定义两个 stub:

  1. 首次调用(状态为 "started")返回 20℃ 的多云天气,并将状态转为 "Weather Found"
  2. 第二次调用(状态为 "Weather Found")返回 25℃ 的晴天

4. 总结

本文介绍了使用 Spring WebClient 和 WireMock 进行集成测试的基础知识。WireMock 提供了强大的 HTTP 响应模拟能力,可覆盖多种测试场景。

我们快速实践了 WebClient 结合 WireMock 的常见测试用例:

  • ✅ 成功 JSON 响应
  • ✅ 自定义请求头匹配
  • ✅ 异常响应处理
  • ✅ 查询参数匹配
  • ✅ 动态响应生成
  • ✅ 异步延迟模拟
  • ✅ 有状态行为测试

完整代码实现可在 GitHub 查看。


原始标题:Integration Testing Spring WebClient Using WireMock | Baeldung