使用 Spring WebClient 和 WireMock 进行集成测试

1、简介

Spring WebClient 是一款非阻塞、响应式的 HTTP 客户端,而 WireMock 是一个强大的用于模拟基于 HTTP 的 API 的工具。

2、依赖和示例

首先,需要在 Spring Boot 项目中添加必要的依赖。

pom.xml 中添加 spring-boot-starter-flux(WebClient) 和 spring-cloud-starter-wiremock(WireMock Server)依赖:

<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;
   // 构造函数、Getter/Setter 方法省略

我们要使用 WebClientWireMock 进行集成测试,以测试这个功能。

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

首先用 WireMockWebClient 设置 Spring Boot 测试类:

@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 baseURL 创建 WebClient 实例
    WebClient webClient = webClientBuilder.baseUrl("http://localhost:" + wireMockPort).build();
  ....
  ....
}

如上,@AutoConfigureWireMock 会在随机端口上启动一个 WireMock 服务器。通过 WireMock Server 的 base URL 创建了一个 WebClient 实例。

现在,通过 WebClient 发出的任何请求都会转到 WireMock Server 实例,如果存在正确的存根(Stub),就会发送相应的响应。

3.1、存根(Stub)成功的响应以及响应体

首先,存根(Stub)一个 HTTP 调用(JSON 请求),服务器返回 200 OK

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

    // 使用 WireMock baseURL 创建 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 发起 /weather?city=London 请求时,将返回存根(Stub)响应。

3.2、模拟自定义 Header

有时候,HTTP 请求需要自定义 Header。WireMock 可以匹配自定义 Header 以提供相应的响应。

创建一个包含两个 Header 的存根(Stub),一个是 Content-Type Header,另一个是 X-Custom-Header 标头,其值为 “springdoc-header”:

@Test
public void givenWebClientBaseURLConfiguredToWireMock_whenGetRequest_theCustomHeaderIsReturned() {
    // 使用自定义 Heaader 存根(Stub)响应
    stubFor(get(urlEqualTo("/weather?city=London"))
      .willReturn(aResponse()
        .withStatus(200)
        .withHeader("Content-Type", "application/json")
        .withHeader("X-Custom-Header", "springdoc-header")
        .withBody("{\"city\": \"London\", \"temperature\": 20, \"description\": \"Cloudy\"}")));

    //使用 WireMock baseURL 创建 WebClient 实例
    WebClient webClient = webClientBuilder.baseUrl("http://localhost:" + wireMockPort).build();

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

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

WireMock Server 响应伦敦的存根(Stub)天气数据,包括自定义 Header。

3.3、模拟异常

另一种测试情况是外部服务返回异常。通过 WireMock Server,可以模拟这些异常情况,查看系统在这些情况下的行为:

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

   //使用 WireMock baseURL 创建 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.4、模拟带有查询参数的响应

我们经常需要发送带有查询参数的请求。为此创建一个存根(Stub):

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

    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.5、模拟动态响应

来看一个例子,在响应体中随机生成一个介于 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\"}")));

    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.6、 模拟异步行为

这里,通过在响应中引入一秒钟的模拟延迟,来模拟现实世界中服务可能会遇到延迟或网络延迟的情况:

@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\"}")));

    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.7、模拟有状态行为

接下来,结合使用 WireMock 场景来模拟有状态的行为。该 API 允许我们根据状态配置存根(Stub),在多次调用时以不同的方式做出响应:

@Test
public void givenWebClientBaseURLConfiguredToWireMock_whenMulitpleGet_thenWireMockReturnsMultipleResponsesBasedOnState() {
    // 第一个请求的存根(Stub)响应
    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"));

     // 第二个请求的存根(Stub)响应
    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\"}")));

    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());

  // 再次调用 API
  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” 的场景中为相同的 URL 定义了两个存根(Stub)映射。

当场景是 “started” 状态时,第一个存根(Stub)响应伦敦的天气数据,包括 20°C 的温度和 “Cloudy” 的描述。

响应后,它将场景状态转换为 “Weather Found”。第二个存根(Stub)的配置是,当场景处于 “Weather Found” 状态时,响应温度为 25°C 和描述为 “Weather Found” 的天气数据。

4、总结

本文介绍了如何使用 Spring WebClient 和 WireMock 进行集成测试,WireMock 为模拟各种场景的 HTTP 响应提供了广泛的存根(Stub)功能。


Ref:https://www.baeldung.com/spring-webclient-wiremock-integration-testing