silver Spring Boot leaf logo, on a bright green background

1. Overview

I had previously written an article Integration Testing with MockWebServer that explained a way to write integration tests for a web application using WebClient and MockWebServer (okhttp). The intention was to write an integration test that did not touch the inside of the code, but only the edges. It was not completely successful. The tests overrode the WebClient, and so did not cover the configuration of the WebClient (which could be incorrectly configured). Spring Boot 2.2.6 introduced the @DynamicPropertySource which allows you to insert the MockWebServer url into the properties at runtime.

This lets you test your WebClient setup as well. Better still, it enables you to test the token caching functionality that comes built into WebClient, but we won’t get to that today.

2. MockWebServer Dependencies

To use MockWebServer, you need two dependencies. Shown below as Gradle imports:

testImplementation 'com.squareup.okhttp3:okhttp:4.0.1'
testImplementation 'com.squareup.okhttp3:mockwebserver:4.0.1'

3. Test Project

We will be using Spring Boot version 2.6.3 with Gradle wrapper version 7.3.3 and Java 11. Our project will include Spring Security with OAuth2 Client Credentials, Actuator, Spring Web, JUnit 5 and Webflux, and some other common dependencies. It is a simple pass-through API that hits a backend and returns the response from it. If you want, you can skip to my GitHub repo with the build.gradle file and example code.

4. Writing the Tests

MockWebServer will generate a url, which we will insert into our application properties using @DynamicPropertySource.

We are also choosing to not require the MockMvc to pass a token to our application, because that concern is better tested after we deploy our application.

Here is the test code for handling 200 responses for GET and POST and a 500 response:

import com.fasterxml.jackson.databind.ObjectMapper;
import okhttp3.mockwebserver.MockResponse;
import okhttp3.mockwebserver.MockWebServer;
import okhttp3.mockwebserver.RecordedRequest;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;

import java.io.IOException;
import java.util.concurrent.TimeUnit;

import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
@AutoConfigureMockMvc(addFilters = false)
class WebClientIntegrationTest {

    public static MockWebServer mockServer;
    private final ObjectMapper objectMapper = new ObjectMapper();

    @Autowired
    private MockMvc mockMvc;

    @BeforeAll
    static void beforeAll() throws IOException {
        mockServer = new MockWebServer();
        mockServer.start();
    }

    @DynamicPropertySource
    static void backendProperties(DynamicPropertyRegistry registry) {
        registry.add("base-url", () -> mockServer.url("/").toString());
    }

    @AfterAll
    static void afterAll() throws IOException {
        mockServer.shutdown();
    }

    @Test
    void handlesSuccessResponseForGET() throws Exception {
        WokeResponse wokeResponse = WokeResponse.builder()
                .alarm1("Time to get up")
                .alarm2("You're gonna be late")
                .alarm3("Your boss is calling")
                .build();
        String responseBody = objectMapper.writeValueAsString(wokeResponse);
        mockExternalEndpoint(200, responseBody);

        ResultActions result = executeGetRequest();

        assertBackendServerWasCalledCorrectlyForGET(mockServer.takeRequest());
        verifyResults(result, 200, "\"alarm1\":\"Time to get up\"", "\"alarm2\":\"You're gonna be late\"");
    }

    @Test
    void handles500ErrorsFromBackendServerForGET() throws Exception {
        WokeResponse wokeResponse = WokeResponse.builder().error("What does that even mean?").build();
        String responseBody = objectMapper.writeValueAsString(wokeResponse);
        mockExternalEndpoint(500, responseBody);

        ResultActions result = executeGetRequest();

        assertBackendServerWasCalledCorrectlyForGET(mockServer.takeRequest());
        verifyResults(result, 500, "\"error\":\"500 Internal Server Error", "context: WAKEUP");
    }

    @Test
    void handlesSuccessResponseForPOST() throws Exception {
        mockExternalEndpoint(200, "{\"alarm1\": \"Hello World\"}");
        String requestBody = new ObjectMapper().writeValueAsString(AlarmRequest.builder().day(10).hour(10).month(10).year(1972).message("Hi").build());

        ResultActions result = executePostRequest(requestBody);

        assertBackendServerWasCalledCorrectlyForPOST(mockServer.takeRequest(5L, TimeUnit.SECONDS));
        verifyResults(result, 200, "\"alarm1\":\"Hello World\"");
    }

    private ResultActions executeGetRequest() throws Exception {
        return mockMvc.perform(MockMvcRequestBuilders
                .get("/v1/alarms")
                .header("Identification-No", "app-id")
                .header("Authorization", "Bearer 123"));
    }

    private ResultActions executePostRequest(String requestBody) throws Exception {
        return mockMvc.perform(MockMvcRequestBuilders
                .post("/v1/alarm")
                .header("Identification-No", "app-id")
                .header("Content-Type", "application/json")
                .content(requestBody));
    }

    private void mockExternalEndpoint(int responseCode, String body) {
        MockResponse mockResponse = new MockResponse().setResponseCode(responseCode)
                .setBody(body)
                .addHeader("Content-Type", "application/json");
        mockServer.enqueue(mockResponse);
    }

    private void assertBackendServerWasCalledCorrectlyForGET(RecordedRequest recordedRequest) {
        assertThat(recordedRequest.getMethod()).isEqualTo("GET");
        assertThat(recordedRequest.getPath()).isEqualTo("/api/clock/alarms");
    }

    private void assertBackendServerWasCalledCorrectlyForPOST(RecordedRequest recordedRequest) {
        assertThat(recordedRequest).as("Request did not reach MockWebServer").isNotNull();
        assertThat(recordedRequest.getPath()).isEqualTo("/api/clock/alarms");
        assertThat(recordedRequest.getMethod()).isEqualTo("POST");
        String expectedRequestBody = "[text={\"year\":1972,\"month\":10,\"day\":10,\"hour\":10,\"message\":\"Hi\"}]";
        assertThat(recordedRequest.getBody().readByteString().toString()).isEqualTo(expectedRequestBody);
    }

    private void verifyResults(ResultActions resultActions, int status, String... message) throws Exception {
        resultActions
                .andDo(print())
                .andExpect(status().is(status));
        String responseBody = resultActions.andReturn().getResponse().getContentAsString();
        Assertions.assertThat(responseBody).contains(message);
    }
}

5. Explaining the Tests

@SpringBootTest
@AutoConfigureMockMvc(addFilters = false)

The first thing to notice is that there are only 2 annotations needed, compared to the 7 annotations in my previous blog. The @ActiveProfiles annotation is optional depending on how many properties you have in your local spring profile. In this example the url was the only property, and it was injected with the @DynamicPropertySource functionality.

In JUnit 5 @SpringBootTest spins up the context. It includes the @ExtendWith(SpringRunner.class) annotation, so you do not need to add that.

The @AutoConfigureMockMvc(addFilters = false) annotation sets up the MockMvc functionality so you can autowire and use the MockMvc. Setting the addFilters to false turns off security so you don’t have to pass an auth token to call your endpoint. As of Spring 5.3 (Spring Boot 2), the @WebAppConfiguration annotation is no longer needed for MockMvc to work.

The MockWebServer is started in the @BeforeAll setup and stopped in the @AfterAll and is set to a field. This means it starts before any tests run, and is not restarting between tests, just like the Spring context.

@DynamicPropertySource
static void backendProperties(DynamicPropertyRegistry registry) {
    registry.add("base-url", () -> mockServer.url("/").toString());
}

The @DynamicPropertySource method takes in the registry of properties, and lets you add or override properties. I am overriding the “base-url” property with the value of the MockWebServer url and random port (e.g. http://localhost:2345). The “/” shows I am not adding a path. This executes after the MockWebServer starts running, but before the tests start running which allows us to insert the generated urls.

With the tests for the GET calls, we are executing the call with a simple MockMvc implementation and verifying the response in a separate method so we can separate the Arrange, Act and Assert stages of the tests. We also created a helper method to verify the backend was called correctly. You can see that our application code added the /api/clock/alarms path to the end of the base url when it calls the backend.

private void assertBackendServerWasCalledCorrectlyForGET(RecordedRequest recordedRequest) {
    assertThat(recordedRequest.getMethod()).isEqualTo("GET");
    assertThat(recordedRequest.getPath()).isEqualTo("/api/clock/alarms");
}

The test for the POST endpoint shows that our application called the backend POST endpoint correctly. You can see that when we pass the request into the assertBackendServerWasCalledCorrectlyForPOST() helper method, we use the mockServer.takeRequest(5L, TimeUnit.SECONDS) method.

assertBackendServerWasCalledCorrectlyForPOST(mockServer.takeRequest(5L, TimeUnit.SECONDS));

This collects requests for 5 seconds before continuing and is necessary for POST tests. Adding this timeout for all your mockServer.takeRequest() methods is a good idea so the tests will fail fast when they fail, instead of hanging.

private void assertBackendServerWasCalledCorrectlyForPOST(RecordedRequest recordedRequest) {
    assertThat(recordedRequest).as("Request did not reach MockWebServer").isNotNull();
    assertThat(recordedRequest.getMethod()).isEqualTo("POST");
    String expectedRequestBody = "[text={\"year\":1972,\"month\":10,\"day\":10,\"hour\":10,\"message\":\"Hi\"}]";
    assertThat(recordedRequest.getBody().readByteString().toString()).isEqualTo(expectedRequestBody);
}

Getting the body from the request to the backend can be done by reading the byte string of the body and converting it to a string. The resulting format is different than you might expect. It has the word “text” in square brackets with the JSON body:

"[text={\"year\":1972,\"month\":10,\"day\":10,\"hour\":10,\"message\":\"Hi\"}]"

6. Challenges

One challenge is that if there is any problem with your test setup or mocks you are likely to get a blocking timeout error instead of a useful error, and the debugger can be difficult to follow. Because of this, I recommend you limit the number of integration tests to as few as possible, only testing concerns that cannot be tested with micro tests. For instance, what your response body format looks like when you receive a 500 from the backend.

You can check out my GitHub repo here. Also, for more on how to make coding easier in Spring Boot, check out Industrial Logic’s course on Spring Boot.