For a cleaner implementation of this, check out my updated blog.

1. Overview

As many of you know, Spring is deprecating RestTemplate and replacing it with WebClient. Mocking a server with RestTemplate was done with Spring’s MockRestServiceServer, which was able to mock the endpoint your service would normally call. There is a way to almost replicate that functionality using MockWebServer (okhttp).

2. Clarify Integration Test

We will spin up the application with the Spring context, hit the endpoint with a Spring MockMvc client, have it pass through the application, and then mock the server our service calls on the other end to return back the desired HttpStatus and response body.

This will allow us to test things like what our system returns when getting a 500 error from their dependencies and other complex scenarios.

Simple testing diagram: MockMvc pointing to application under test, pointing to MockWebServer

Integration Test Flow

3. 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'

Of course, you need a project to import these into.

4. Test Project

We will be using Spring Boot version 2.4.2 with Gradle and Java 8. Our project will include Spring Security with 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 to skip to the GitHub repo with the build.gradle file and example code, go here.

5. Writing the Test

Because MockWebServer just provides a url that you can hit, we have to figure out a way to insert that url into our application context at runtime. We do that by mocking the WebClient bean we have previously created. We are also choosing to disable the security in this test because we are testing responses and error handling, not security.

Here is the test code testing a 200 Success response:

import com.fasterxml.jackson.databind.ObjectMapper;
import okhttp3.HttpUrl;
import okhttp3.mockwebserver.MockResponse;
import okhttp3.mockwebserver.MockWebServer;
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.junit.jupiter.api.extension.ExtendWith;
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.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.springframework.test.context.web.WebAppConfiguration;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
import org.springframework.web.reactive.function.client.WebClient;

import java.io.IOException;

import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@ExtendWith(SpringExtension.class)
@WebAppConfiguration
@SpringBootTest
@TestPropertySource(properties = {"spring.main.allow-bean-definition-overriding=true"})
@AutoConfigureMockMvc(addFilters = false)
@ActiveProfiles("local")
@ContextConfiguration(classes = {WebclientIntegrationtestApplication.class, 
                                 WebClientMockWebServerIntegrationTest.TestConfig.class})
class WebClientMockWebServerIntegrationTest {

	public static MockWebServer mockServer;

	private ObjectMapper objectMapper = new ObjectMapper();

	@Autowired
	private MockMvc mockMvc;

	@MockBean
	JwtDecoder jwtDecoder;

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

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

	@Configuration
	public static class TestConfig {
		@Bean(name = "wokeWebClient")
		WebClient dmppsWebClient() {
			HttpUrl url = mockServer.url("/");
			WebClient webClient = WebClient.create(url.toString());
			return webClient;
		}
	}
	
	@Test
	void successResponse() 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);
		mockBackendEndpoint(200, responseBody);
		ResultActions resultActions = executeRequest("1234");
		verifyResults(resultActions, 200);
	}

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

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

	private void verifyResults(ResultActions resultActions, int status) throws Exception {
		resultActions
				.andDo(print())
				.andExpect(status().is(status));
		String responseBody = resultActions.andReturn().getResponse().getContentAsString();
		Assertions.assertThat(responseBody).contains("\"alarm1\":\"Time to get up\"");
		Assertions.assertThat(responseBody).contains("\"alarm2\":\"You're gonna be late\"");
	}
}

6. Explaining the Test

This is quite a bit of code for one test, so I will explain each part separately.There are seven class-level annotations.

@ExtendWith(SpringExtension.class)
@WebAppConfiguration
@SpringBootTest
@TestPropertySource(properties = {"spring.main.allow-bean-definition-overriding=true"})
@AutoConfigureMockMvc(addFilters = false)
@ActiveProfiles("local")
@ContextConfiguration(classes = {WebclientIntegrationtestApplication.class, 
                                 WebClientMockWebServerIntegrationTest.TestConfig.class})

The @ExtendWith( SpringExtension.class) and @SpringBootTest annotations are required to spin up the Spring Context for your application when using JUnit 5. Furthermore, the @WebAppConfiguration and @AutoConfigureMockMvc( addFilters = false) allow MockMvc to interact with it. The addFilters are set to false to disable security. We are testing the integration of the immediate requirements here, not the security requirements.

The @TestPropertySource(properties = {"spring.main.allow-bean-definition-overriding=true"}) annotation is to allow you to override the WebClient bean.

@ContextConfiguration(classes = 
    {WebClientIntegrationTestApplication.class, 
     WebClientMockWebServerIntegrationTest.TestConfig.class})

The annotation above is to insert the inner TestConfig class we defined that overrides the WebClient bean. The TestConfig class must come after your main Application class, or the bean will not be overridden.

Finally, the @ActiveProfiles("local") annotation is optional and only necessary if you are using a Spring Profile that is required for your application context to spin up. In our case, the Spring profile is named “local”.

In the test code, we are using @MockBean for the JwtDecoder because we are using that bean in our WebSecurityConfig and the context does not spin up without it being mocked.

The MockMvc client can be autowired, and is used to call our endpoint.

The MockWebServer is started in the @BeforeAll setup and stopped in the @AfterAll and is set to a field so it can be referenced in the TestConfig class and the tests.

	@Configuration
	public static class TestConfig {
		@Bean(name = "wokeWebClient")
		WebClient dmppsWebClient() {
			HttpUrl url = mockServer.url("/");
			WebClient webClient = WebClient.create(url.toString());
			return webClient;
		}
	}

The TestConfig class is just creating a bean that will overwrite the WebClient bean specified in the production code. It creates a real WebClient, and inserts the randomly generated url from the mockServer into it.

Then, before each test, you can insert the response you want the backend server to return.

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

This helper method allows you to easily insert a mocked response into your mocked server with the status code and JSON body you desire.

To test how your application handles 500 responses, mock the server to return a 500 response and assert on what you want your response to that error to be. For an example of a 500 test case, see my GitHub repo here.