Integration Testing Your Spring WebClients with Wiremock

Featured image for sharing metadata for article

If you're building Spring Boot services which interact with other services, it's likely that you're using the WebClient from the WebFlux project to use a more reactive and non-blocking HTTP client.

Although we can unit test these methods nicely, we're still going to want to build an integration test to validate that the HTTP layer works correctly.

As noted in the version of this article, using OkHttp, we can't use a built-in Spring means to test this, but we can use an HTTP server like Wiremock.

Sample code for this blog post can be found on GitLab.

Base setup

Let's say that we have a class, ProductServiceClient, which can be described using the following interface:

public interface ProductServiceClient {
  List<Product> retrieveProducts() throws ProductServiceException;
}

And which utilises the following POJOs:

public record Product(String id, String name) {}
import java.util.List;

public class ProductContainer {
  private List<Product> products;

  public List<Product> getProducts() {
    return products;
  }

  public void setProducts(List<Product> products) {
    this.products = products;
  }
}
public class ProductServiceException extends Exception {
  public ProductServiceException(String message) {
    super(message);
  }

  public ProductServiceException(String message, Throwable throwable) {
    super(message, throwable);
  }
}

And finally, we have our ProductServiceClient:

import java.util.List;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;

@Component
public class ProductServiceClient {
  private final WebClient webClient;

  public ProductServiceClient(WebClient webClient) {
    this.webClient = webClient;
  }

  public List<Product> retrieveProducts() throws ProductServiceException {
    ProductContainer response;
    response =
        webClient
            .get()
            .uri("/products")
            .retrieve()
            .onStatus(
                HttpStatus::is4xxClientError,
                error -> Mono.error(new ProductServiceException("Huh, something went wrong")))
            .bodyToMono(ProductContainer.class)
            .block();

    if (response == null) {
      throw new ProductServiceException("No response body was returned from the service");
    }

    return response.getProducts();
  }
}

Setting up Wiremock

Firstly, we need to add Wiremock to the classpath, i.e. for Gradle:

dependencies {
  testImplementation 'com.github.tomakehurst:wiremock-jre8:2.32.0'
}

Next, we set up the following Spring integration test, so we can make use of the autowired ObjectMapper from Spring:

import static com.github.tomakehurst.wiremock.client.WireMock.*;
import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.options;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.github.tomakehurst.wiremock.WireMockServer;
import java.util.List;
import me.jvt.hacking.webclient.*;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.springframework.web.reactive.function.client.WebClient;

@Import({ProductServiceClientTest.Config.class, JacksonAutoConfiguration.class})
@ExtendWith(SpringExtension.class)
class ProductServiceClientTest {

  @TestConfiguration
  static class Config {
    @Bean
    public WireMockServer webServer() {
      WireMockServer wireMockServer = new WireMockServer(options().dynamicPort());
      // required so we can use `baseUrl()` in the construction of `webClient` below
      wireMockServer.start();
      return wireMockServer;
    }

    @Bean
    public WebClient webClient(WireMockServer server) {
      return WebClient.builder().baseUrl(server.baseUrl()).build();
    }

    @Bean
    public ProductServiceClient client(WebClient webClient) {
      return new ProductServiceClient(webClient);
    }
  }

  @Autowired private ObjectMapper mapper;
  @Autowired private WireMockServer server;

  @Autowired private ProductServiceClient client;

  @Test
  void returnsProductsWhenSuccessful() throws ProductServiceException {
    server.stubFor(
        get(urlEqualTo("/products"))
            .willReturn(
                aResponse()
                    .withHeader("Content-Type", MediaType.APPLICATION_JSON_VALUE)
                    .withBody(successBody())));

    List<Product> products = client.retrieveProducts();

    assertThat(products)
        .containsExactly(
            new Product("123", "Credit Card"), new Product("456", "Debit Card (Express)"));
  }

  @Test
  void throwsProductServiceExceptionWhenErrorStatus() {
    server.stubFor(get(anyUrl()).willReturn(aResponse().withStatus(400)));

    assertThatThrownBy(() -> client.retrieveProducts())
        .hasCauseInstanceOf(ProductServiceException.class);
  }

  @Test
  void setsAcceptHeader() throws ProductServiceException {
    server.stubFor(
        get(urlEqualTo("/products"))
            .willReturn(
                aResponse()
                    .withHeader("Content-Type", MediaType.APPLICATION_JSON_VALUE)
                    .withBody(successBody())));

    client.retrieveProducts();

    server.verify(
        getRequestedFor(urlEqualTo("/products")).withHeader("accept", equalTo("application/json")));
  }

  private String successBody() {
    ProductContainer container = new ProductContainer();
    container.setProducts(
        List.of(new Product("123", "Credit Card"), new Product("456", "Debit Card (Express)")));
    try {
      return mapper.writeValueAsString(container);
    } catch (JsonProcessingException e) {
      throw new IllegalStateException(e);
    }
  }
}

If you're happy constructing an ObjectMapper another way, I'll leave it as an exercise to the reader, based on how we did it for OkHttp's tests.

Adding tests for multiple WebClient together, with custom configuration

If we want to add tests to validate that the WebClients themselves are set up correctly, independent to the classes that test them, we may want to create a common test class, which can allow us to verify any configuration that has been applied to them.

Let's say that we have the following configuration class for two different WebClients:

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.MediaType;
import org.springframework.web.reactive.function.client.WebClient;

@Configuration
public class WebClientConfig {
  @Bean
  public WebClient foo(@Value("1.2.3") String apiKey) {
    return WebClient.builder()
        .defaultRequest(requestHeadersSpec -> requestHeadersSpec.header("api-key", apiKey))
        .build();
  }

  @Bean
  public WebClient bar() {
    return WebClient.builder()
        .defaultRequest(
            requestHeadersSpec -> requestHeadersSpec.accept(MediaType.valueOf("text/plain")))
        .build();
  }
}

This allows us to write the following test to verify that the HTTP requests are sent correctly.

import static com.github.tomakehurst.wiremock.client.WireMock.*;
import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.options;

import com.github.tomakehurst.wiremock.WireMockServer;
import me.jvt.hacking.webclient.Application;
import me.jvt.hacking.webclient.WebClientConfig;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.springframework.web.reactive.function.client.WebClient;

@ExtendWith(SpringExtension.class)
@Import(WebClientConfig.class)
@ContextConfiguration(classes = Application.class)
class WebClientIntegrationTest {

  @Autowired
  @Qualifier("foo")
  private WebClient foo;

  @Autowired
  @Qualifier("bar")
  private WebClient bar;

  private final WireMockServer server = new WireMockServer(options().dynamicPort());

  @BeforeEach
  void setup() {
    server.start();
    server.stubFor(get(anyUrl()).willReturn(aResponse().withStatus(200)));
  }

  @Test
  void fooSetsApiKey() throws InterruptedException {
    foo.get().uri(server.url("/products")).retrieve().toBodilessEntity().block();

    server.verify(getRequestedFor(urlEqualTo("/products")).withHeader("Api-Key", equalTo("1.2.3")));
  }

  @Test
  void barSetsTextPlainAcceptHeader() throws InterruptedException {
    bar.get().uri(server.url("/products")).retrieve().bodyToMono(String.class).block();

    server.verify(
        getRequestedFor(urlEqualTo("/products"))
            .withHeader("accept", equalTo(MediaType.TEXT_PLAIN_VALUE)));
  }
}

Written by Jamie Tanna's profile image Jamie Tanna on , and last updated on .

Content for this article is shared under the terms of the Creative Commons Attribution Non Commercial Share Alike 4.0 International, and code is shared under the Apache License 2.0.

#blogumentation #java #spring-boot #testing #tdd.

Also on:

This post was filed under articles.

Interactions with this post

Interactions with this post

Below you can find the interactions that this page has had using WebMention.

Have you written a response to this post? Let me know the URL:

Do you not have a website set up with WebMention capabilities? You can use Comment Parade.