RestClient Shall Not Be Mocked: How to properly test classes that use it

Why Spring Boot's RestClient and Mockito do not play well together, and how you can use the @RestClientTest test slice to come up with more robust tests

Why Spring Boot’s RestClient and Mockito do not play well together, and how you can use the @RestClientTest test slice to come up with more robust tests

The main idea behind unit tests is that classes or components are each tested in isolation. Typically in this context, “isolation” is understood to mean that a component’s dependencies are mocked to return fake answers when certain methods are called. But as we will see here, sometimes it is best not to mock certain dependencies—a RestClient for example, and we need to find some other way to come up with robust tests for the class under test. In doing so, we refine our understanding of what it means to test in isolation.

Rather than just provide some code snippets telling you how to write tests for classes that use RestClient, I thought it would be a great idea to go over some concepts and background information to help you better see the value in the test slice features that Spring Boot provides.

An easy Mockito example

In the following example, StudentService has a dependency on StudentRepository, which can be easily mocked by Mockito. (Using an in-memory database would be more robust than mocking the repository, but that is beside the point.)

Student.java – JPA entity for students
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;

@Entity
@Table(name = "students")
@Getter
@Setter
public class Student {
	@Id
	@GeneratedValue
	private int id;

	@Column(name = "last_name", nullable = false)
	private String lastName;

	@Column(name = "given_name", nullable = false)
	private String givenName;

	@Column(name = "gpa", nullable = false)
	private float gpa;
}
Code language: Java (java)
StudentRepository.java – JPA repository for students
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;

import java.util.List;

@Repository
public interface StudentRepository extends JpaRepository<Student, Integer> {
	@Query("SELECT s FROM Student s WHERE LOWER(s.lastName) = LOWER(:lastName)")
	List<Student> findByLastName(String lastName);
}


Code language: Java (java)
StudentService.java – Business logic around students
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.Optional;

@Service
@RequiredArgsConstructor
public class StudentService {
	private final StudentRepository studentRepository;

	public List<Student> findByLastName(String lastName) {
		return studentRepository.findByLastName(lastName);
	}
}


Code language: Java (java)
StudentServiceTest.java – Unit test class for StudentService
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;

import java.util.List;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.matches;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.mockito.MockitoAnnotations.openMocks;

class StudentServiceTest {
	@InjectMocks
	private StudentService studentService;

	@Mock
	private StudentRepository studentRepository;

	private AutoCloseable closeable;

	@BeforeEach
	void setUp() {
		closeable = openMocks(this);
	}

	@AfterEach
	void tearDown() throws Exception {
		closeable.close();
	}

	@Test
	void testGetStudentByLastName() {
		Student student = new Student();
		student.setId(1);
		student.setGivenName("Joe");
		student.setLastName("Schmoe");

		final List<Student> expected = List.of(student);

		when(studentRepository.findByLastName(matches("(?i)schmoe"))).thenReturn(expected);

		final List<Student> actual = studentService.findByLastName("SCHMOE");

		// Student should be found in case-insensitive search.
		assertEquals(expected, actual);

		verify(studentRepository).findByLastName("SCHMOE");
	}
}


Code language: Java (java)

This example is fairly straightforward. The StudentRepository is mocked so that whenever something calls findByLastName to do a case-insensitive search by last name for "Schmoe", it returns the Joe Schmoe that was created earlier. Then, the test calls findByLastName on the class under test, asserts that Joe Schmoe really was returned, and verifies that the service called out to the repository with the expected argument.

What happens when you mock RestClient with Mockito?

When it comes to RestClient, however, things are quite a bit more complicated. RestClient, unlike its predecessor, RestTemplate, uses a fluent API, meaning that methods will be chained together as the RestClient sends out a request.

For this next example, let’s create a WeatherApiClient, which uses a RestClient to reach out over the network to retrieve a seven-day forecast from an external web service.

WeatherApiClient.java – API client for retrieving weather information
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import tools.jackson.databind.JsonNode;

import java.util.Objects;

@Component
@RequiredArgsConstructor
public class WeatherApiClient {
	private static final String SEVEN_DAY_FORECAST_URI_FORMAT = "forecast/7-day/%s/%s";
	private final RestClient restClient;

	public JsonNode fetchSevenDayForecast(String stateCode, String cityName) {
		String uri = SEVEN_DAY_FORECAST_URI_FORMAT.formatted(stateCode, cityName);
		return Objects.requireNonNull(
			restClient.get()
				.uri(uri)
				.retrieve()
				.body(JsonNode.class)
		);
	}
}
Code language: Java (java)

Next, here is a unit test that mocks the RestClient:

WeatherApiClientTest.java – Unit test class for WeatherApiClient
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.springframework.web.client.RestClient;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.json.JsonMapper;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.doReturn;
import static org.mockito.MockitoAnnotations.openMocks;

class WeatherApiClientTest {
	@InjectMocks
	private WeatherApiClient weatherApiClient;

	@Mock
	private RestClient restClient;

	@Mock
	private RestClient.RequestHeadersUriSpec<?> requestHeadersUriSpec;

	@Mock
	private RestClient.RequestHeadersSpec<?> requestHeadersSpec;

	@Mock
	private RestClient.ResponseSpec responseSpec;

	private AutoCloseable closeable;

	@BeforeEach
	void setUp() {
		closeable = openMocks(this);
	}

	@AfterEach
	void tearDown() throws Exception {
		closeable.close();
	}

	@Test
	void testFetchSevenDayForecast() {
		// Create a test JsonNode for the mock RestClient to retrieve.
		String json = "{\"testKey\": \"testValue\"}";
		JsonNode expected = new JsonMapper().readTree(json);

		// Mock the RestClient's fluent API chain to return the test JsonNode.
		doReturn(requestHeadersUriSpec).when(restClient).get();
		doReturn(requestHeadersSpec).when(requestHeadersUriSpec).uri("forecast/7-day/ID/Boise");
		doReturn(responseSpec).when(requestHeadersSpec).retrieve();
		doReturn(expected).when(responseSpec).body(JsonNode.class);

		// Fetch the seven-day forecast.
		JsonNode actual = weatherApiClient.fetchSevenDayForecast("ID", "Boise");

		// The result should be the same as the test JsonNode.
		assertEquals(expected, actual);
	}
}Code language: JavaScript (javascript)

Although the unit test passes, there are several problems with this approach; they revolve around the fact that the test focuses heavily on implementation details rather than behavior.

  • Every method used in the fluent chain—in this case, get(), uri(), retrieve(), and body()—must be mocked.
  • The WeatherApiClient has one injected dependency—the mocked RestClient, but the test must also mock a RequestHeadersUriSpec, RequestHeadersSpec, and ResponseSpec.
  • There is a third problem, which I will demonstrate below.

The first two above bullet points illustrate the tedium of mocking a RestClient (unless, of course, you generate the test with AI). It involves digging into every method of the fluent chain, seeing what types they return, and creating mock objects for each of those types. That is quite a bit more than what I signed up for when I decided to unit test the WeatherApiClient.

To demonstrate the third problem, let’s change the implementation slightly so that it uses toEntity() instead of body(). (A common reason to do this would be to gain control over error handling when receiving a non-2xx HTTP response code.)

public JsonNode fetchSevenDayForecast(String stateCode, String cityName) {
	String uri = SEVEN_DAY_FORECAST_URI_FORMAT.formatted(stateCode, cityName);
	ResponseEntity<JsonNode> responseEntity = restClient.get()
		.uri(uri)
		.retrieve()
		.toEntity(JsonNode.class);

	if (!responseEntity.getStatusCode().is2xxSuccessful()) {
		throw new RuntimeException("Received non-2xx HTTP status code when requesting seven-day forecast "
			+ responseEntity.getStatusCode());
	}

	return Objects.requireNonNull(responseEntity.getBody());
}
Code language: Java (java)

If we run the unit test again, it will fail.

java.lang.NullPointerException: Cannot invoke "org.springframework.http.ResponseEntity.getStatusCode()" because "responseEntity" is nullCode language: CSS (css)

Although the behavior of the client method has not hardly changed, the unit test has become very brittle.

Why not use deep stubs?

You may be tempted to use @Mock(answer = Answers.RETURNS_DEEP_STUBS) on restClient. This would eliminate the need to mock implementation details such as the “specs.”

Mockito’s own documentation advises against returning deep stubs. It is a major code smell, and as you will see, it does nothing to make the tests any less fragile.

Consider the following code snippet:

ResponseEntity<JsonNode> responseEntity = restClient.get().uri("forecast/7-day/ID/Boise").retrieve().toEntity(JsonNode.class);
when(responseEntity.getBody()).thenReturn(expected);
when(responseEntity.getStatusCode()).thenReturn(HttpStatus.OK);Code language: Java (java)

The fetchSevenDayForecast method does successfully retrieve the response entity via deep stubs. Then, it tries to create another deep stub when the getStatusCode() method, which returns an HttpStatusCode, gets called. But it cannot create the stub because HttpStatusCode is a sealed interface. Sealed interfaces cannot be mocked. You can get around this by changing the last line to:

doReturn(HttpStatus.OK).when(responseEntity).getStatusCode();Code language: CSS (css)

This works, but it is not consistent with the when(…).thenReturn(…) syntax. Plus, the doReturn(…).when(…) syntax does not work on deep stubs.

Indeed, the test is very fragile. You can imagine how painful it was for me to go through all this trial and error, just so I could demonstrate this example to you.

To mock, or not to mock?

Knowing that some classes or components are very difficult to mock, it may cause us to take a step back and think, should we mock a component, or not? Here are some questions to help us decide:

  • How easy or difficult is it to mock the component?
  • How easy or difficult is the setup if we choose not to mock it?
  • What is the value we gain by mocking it versus not mocking it?

In general, the following components should be mocked:

  • Databases
  • Persistence layers (e.g., JPA repositories)
  • API clients
  • Business layers (e.g., service classes)
  • Slow components

With these examples, you call a method on the mock, and the mock instantly gives you the result you asked for. These components are very easy to mock.

In contrast, it is usually in your best interest to not mock the following components:

  • Utility methods
  • In-memory storage
  • Methods with very fast computation
  • Fluent API chains
  • Mock objects (I mean, why would you ever want to mock a mock object?)

If real methods perform very fast and give you high quality answers, and faking the methods would not provide a good return on investment, then the component should not be mocked.

Where does RestClient fit into this picture? Its fluent API alone makes it a poor candidate for mocking. Indeed, we have already seen how difficult it is to mock RestClient and how fragile the tests become when we do so. Furthermore, for the most part, the methods in the chain are very fast and already give us what we need (in the form of RequestHeadersUriSpecs, RequestHeadersSpecs, and so on. The exceptions are those methods (e.g., retrieve(), exchange()) that reach out to the server to get a response.

If RestClient should not be mocked, then how do we prevent RestClient from reaching out over the network during unit tests? The idea is this: Do not mock the RestClient; mock the server instead, and bind the mock server to the RestClient. This can be done using Spring Boot’s @RestClientTest test slice.

Spring Boot test slices

What are “test slices,” exactly? Think of it as a happy medium between complete isolation and integration testing. The idea here is to load only the components we need for the test.

Spring Boot provides a variety of test slices, one of them being @RestClientTest.

The @RestClientTest test slice

As opposed to @SpringBootTest, which loads the entire application context, @RestClientTest only loads the beans needed to test a class that uses RestClient or RestTemplate. More specifically1:

  • It auto-configures Jackson, Gson, and JSONB support.
  • It configures a RestTemplateBuilder and RestClient.Builder.
  • It adds support for MockRestServiceServer.

A MockRestServiceServer mocks network calls and fakes responses. @RestClientTest automatically binds a MockRestServiceServer to a RestClient.Builder. This means that whenever the RestClient (still being constructed at this point) will make a network request, the mock server will intercept it and respond with whatever the unit test tells it to do. That RestClient.Builder is then injected as a dependency into each instance of the class under test.

In the WeatherApiClient.java example above, a RestClient, not a RestClient.Builder, was injected into the constructor. I did this on purpose to demonstrate the pitfalls of mocking a RestClient. A best practice is to inject the RestClient.Builder instead, and to have the constructor build the RestClient field using the RestClient.Builder.

Using the MockRestServiceServer

The MockRestServiceServer is the main driver for testing network interactions initiated by the class under test via a RestClient. Here are three key steps to using the MockRestServiceServer:

  1. The test tells the server the expectations for requests being performed through the RestClient.
    • Each expectation has one or more criteria.
    • Such requests may be expected one or more times, or the server may expect said request to never happen.
  2. For each expected request, the test tells the server how to respond.
  3. After running the method under test, the test can have the server verify that all expected requests were made.

A request expectation might look something like this:

mockServer.expect(requestTo("https://www.example.com/forecast/7-day/ID/Boise"))
	.andExpect(method(HttpMethod.GET))
	.andRespond(withSuccess(someJsonString, MediaType.APPLICATION_JSON));Code language: Java (java)

There is another important function, bindTo(), that the test may need to use to have the server bind to a RestClient.Builder, especially if some customization is involved. If the MockRestServiceServer is @Autowired, that should already be handled automatically.

Since customizing a MockRestServiceServer can be rather tricky, this article will assume that you are using an @Autowired MockRestServiceServer with its default configurations. I may decide in the future to write a separate article about customizing a MockRestServiceServer.

How to write tests using @RestClientTest

Before we begin, we want to make sure that the required modules are in our classpath. The one I care about here is spring-boot-starter-restclient-test.

<dependency>
	<groupId>org.springframework.boot</groupId>
	<artifactId>spring-boot-starter-restclient-test</artifactId>
	<scope>test</scope>
</dependency>Code language: HTML, XML (xml)

There are a couple pieces of key information the RestClientTest needs to know:

  • The component to test
  • The @SpringBootApplication class from which to load the application context.

If the class under test is WeatherApiClient, then we can simply annotate the test with @RestClientTest(WeatherApiClient.class).

The test will search, beginning from the test class itself, upwards through the package structure until it finds a class annotated with @SpringBootApplication. If your test is in the same module as the existing Spring Boot application, then it should load the application context from there. If your test is not in the same module, your test may need to declare an inner class annotated with @SpringBootApplication.

Next, you’ll need to add two fields with the @Autowired annotation: the class under test, and the MockRestServiceServer. (The class under test must be annotated with @Component or a stereotype.)

Now, we can proceed to write the test. In the following example, I have adjusted WeatherApiClient to take a RestClient.Builder, and rewritten WeatherApiClientTest to be a @RestClientTest.

WeatherApiClient.java – API client for retrieving weather information
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import tools.jackson.databind.JsonNode;

import java.util.Objects;

@Component
public class WeatherApiClient {
	private static final String SEVEN_DAY_FORECAST_URI_FORMAT = "forecast/7-day/%s/%s";
	private final RestClient restClient;

	public WeatherApiClient(RestClient.Builder restClientBuilder) {
		this.restClient = restClientBuilder.baseUrl("https://www.example.com/").build();
	}

	public JsonNode fetchSevenDayForecast(String stateCode, String cityName) {
		String uri = SEVEN_DAY_FORECAST_URI_FORMAT.formatted(stateCode, cityName);
		return Objects.requireNonNull(
			restClient.get()
				.uri(uri)
				.retrieve()
				.body(JsonNode.class)
		);
	}
}

Code language: Java (java)
WeatherApiClientTest.java – Unit test class for WeatherApiClient
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.restclient.test.autoconfigure.RestClientTest;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.test.web.client.MockRestServiceServer;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.json.JsonMapper;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.method;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;

@RestClientTest(WeatherApiClient.class)
class WeatherApiClientTest {
	@Autowired
	private WeatherApiClient weatherApiClient;

	@Autowired
	private MockRestServiceServer mockServer;

	@Test
	void testFetchSevenDayForecast() {
		// Fake forecast for mock server to respond with
		String expectedJson = "{\"city\":\"Denver\",\"state\":\"CO\",\"forecast\":[]}";

		// Give the mock server a request expectation.
		mockServer.expect(requestTo("https://www.example.com/forecast/7-day/ID/Boise"))
			.andExpect(method(HttpMethod.GET))
			.andRespond(withSuccess(expectedJson, MediaType.APPLICATION_JSON));

		// Run the method under test.
		JsonNode forecast = weatherApiClient.fetchSevenDayForecast("ID", "Boise");

		// Convert the response object into JSON.
		JsonMapper mapper = JsonMapper.builder().build();
		String actualJson = mapper.writeValueAsString(forecast);

		// Check that the actual response is as expected.
		assertEquals(expectedJson, actualJson);

		// Verify that the server received all expected requests.
		mockServer.verify();
	}
}

Code language: Java (java)

If the Spring Boot application is configured properly, then the unit test should pass. The best part about this unit test is that the external web service’s server, not the WeatherApiClient‘s RestClient, was mocked. If I were to change the implementation while preserving existing behavior, then as long as the same request is sent to the mock server, the unit test should still pass.

Conclusion

At the beginning of this article, I mentioned the idea of refining what it means to test in isolation. Even though our revised WeatherApiClientTest loads a @RestClientTest test slice, one could argue that WeatherApiClient was tested in “isolation.” There was still only one class under test—WeatherApiClient. Although other components are being loaded to aid in testing, those components are assumed to work perfectly. Our only concern in testing fetchSevenDayForecast() was verifying that our WeatherApiClient interacted properly with the external web service to retrieve the seven-day forecast.

If you are writing a unit test, and mocking a dependency becomes difficult, you have options. The best option depends on the situation. If the said dependency is a built-in Spring component, you may find that Spring Boot’s testing tools will help you tremendously in coming up with robust tests. Spring Boot’s test slices are very powerful, and also very fast because they only load the components you need. Deciding what to mock, and what not to mock, can make or break your unit testing experience.

References

  1. “Testing Spring Boot Applications,” Spring Boot. Accessed February 21, 2026, at https://docs.spring.io/spring-boot/reference/testing/spring-boot-applications.html#testing.spring-boot-applications.autoconfigured-rest-client ↩︎

Leave a Reply

Your email address will not be published. Required fields are marked *