Skip to main content

Testing Spring REST Clients with @RestClientTest in Spring Boot 4.1

@RestClientTest and MockRestServiceServer, rebuilt against Spring Boot 4.1.1: a RestClient-based client, a legacy RestTemplate client, a declarative HTTP interface client, and the error path — plus three real, verified surprises: two packages that moved without warning, Jackson 3 resolving in place of Jackson 2 by default, and a genuine trap where identical mocking code works for RestTemplate and throws a real AssertionError for RestClient.

A service that talks to one other service over HTTP has a testing problem that has nothing to do with the service itself: you want to assert that your client builds the right request and parses the right response, without a single real socket opening, and without the test depending on some other team’s API being up, fast, and in the exact state your assertions expect. Spin up a full @SpringBootTest for this and you get a slow test that fails for reasons unrelated to your client — a database that did not start, a security filter that redirected the request before it ever got there.

Spring Boot’s answer is a test slice built for exactly this shape of problem: @RestClientTest, paired with MockRestServiceServer. This post rebuilds it from scratch against Spring Boot 4.1.1 — a RestClient-based client, a legacy RestTemplate one, a declarative HTTP interface client, and the error path — and along the way runs into three real, verified surprises an earlier article on this exact annotation would have gotten wrong: two packages that moved without warning, a default JSON library swap most teams will not notice until a test fails to compile, and a genuine trap where the exact same mocking code works for one client type and throws a real assertion failure for the other.

Versions used in this post. Spring Boot 4.1.1 (GA 2026-08-20), Spring Framework 7.0.9, JUnit Jupiter 6.0.3, Mockito 5.23.0, JDK 25 (Temurin 25.0.4.1+1) — all resolved directly by Maven, not assumed, and confirmed against the actual jars with mvn dependency:tree and unzip -l. That second step mattered: it is how the Jackson surprise below got caught before it reached a reader’s build.

What @RestClientTest Actually Wires Up For You

@RestClientTest is a “test slice” — instead of the entire application context that @SpringBootTest loads, it boots a deliberately small one containing only the beans relevant to testing a REST client. Your database auto-configuration, your security filter chain, your whole web MVC stack: none of it exists in this context, so none of it can break your test for reasons that have nothing to do with the client you’re actually testing.

Specifically, @RestClientTest auto-configures:

  • The client under test — the specific bean(s) you name in the annotation, e.g. @RestClientTest(EmployeeClient.class).
  • MockRestServiceServer — the bean that lets you declare expected requests and canned responses instead of hitting a real server.
  • A mocked RestClient.Builder and a mocked RestTemplateBuilder — both, regardless of which one your client actually uses, so @RestClientTest works unchanged whether your code is on the modern fluent RestClient or the older RestTemplate.
  • Jackson (or Gson) support — the same JSON converters your real client would use, so serialization bugs show up here instead of in production.

That third point is worth pausing on if you have read an older write-up of this annotation: it is not just RestTemplateBuilder any more. RestClient is the recommended client for new code, and the slice treats both builders as first-class citizens.

Same application, two very different test contexts @SpringBootTest DataSource SecurityFilterChain DispatcherServlet …every @Controller EmployeeClient (and everything else) Real network port, real DB connection pool, every unrelated bean started before your test runs. @RestClientTest(EmployeeClient.class) EmployeeClient MockRestServiceServer mocked RestClient.Builder / RestTemplateBuilder No DataSource, no filter chain, no servlet — nothing to start except what this test needs.

Confirmed by actually building one: everything below is a real, compiled, executed module — browse the rest-client-test module in the companion repository.

The Smallest Working Example: an Employee API Client

The running example throughout this post: a service that fetches employee records from an external HR system over HTTP. First, an immutable record for the response shape — a Java record is the obvious fit for a DTO with no behavior:

package com.ankurm.restclienttest;

/**
 * Immutable DTO for the employee data returned by the (simulated) external HR system's API.
 */
public record Employee(Integer id, String firstName, String lastName, String email) {
}

Source: Employee.java.

Next, the client itself, built on Spring’s modern fluent RestClient — the recommended choice for new code:

package com.ankurm.restclienttest;

import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient;

/**
 * Fetches employee data from the external HR system using Spring's fluent RestClient -
 * the recommended approach for new client code. Every successful fetch is audited through
 * EmployeeAuditLogger, a plain collaborator bean unrelated to REST at all.
 *
 * The client builds itself from an injected RestClient.Builder, the same shape as
 * EmployeeTemplateClient's RestTemplateBuilder constructor - this is what lets
 * @RestClientTest wire a mocked client straight in, with no separate RestClient
 * bean required.
 */
@Service
public class EmployeeClient {

    private final RestClient restClient;
    private final EmployeeAuditLogger auditLogger;

    public EmployeeClient(RestClient.Builder restClientBuilder, EmployeeAuditLogger auditLogger) {
        this.restClient = restClientBuilder.baseUrl("http://hr-api.example.com").build();
        this.auditLogger = auditLogger;
    }

    public Employee getEmployeeById(Integer id) {
        Employee employee = restClient.get()
                .uri("/employees/{id}", id)
                .retrieve()
                .body(Employee.class);
        auditLogger.logFetch(id);
        return employee;
    }
}

Source: EmployeeClient.java.

Injecting a finished RestClient bean looks cleaner — and does not test cleanly. It’s tempting to write a separate @Configuration class with a @Bean RestClient method and inject that finished RestClient straight into the constructor. The problem: @RestClientTest(EmployeeClient.class) only auto-configures a mocked RestClient.Builder — it does not scan your own @Configuration classes unless you add them to components. Try it and the context fails to start with UnsatisfiedDependencyException: No qualifying bean of type 'RestClient' available, confirmed by actually hitting it while building this client. Injecting the Builder and calling .baseUrl(...).build() yourself, as above, sidesteps the problem entirely — the mocked builder is auto-configured, so the client builds its own mocked RestClient for free. The one case that genuinely needs a separate RestClient bean is covered later in this post, and it needs one extra step precisely because of this gap.

Now the test, using JUnit 5/6 and AssertJ:

package com.ankurm.restclienttest;

import tools.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.restclient.test.autoconfigure.RestClientTest;
import org.springframework.http.MediaType;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.client.MockRestServiceServer;

import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;

@RestClientTest(EmployeeClient.class)
class RestClientEmployeeTest {

    @Autowired
    private MockRestServiceServer mockServer;

    @Autowired
    private EmployeeClient employeeClient;

    @Autowired
    private ObjectMapper objectMapper;

    @MockitoBean
    private EmployeeAuditLogger auditLogger;

    @Test
    void whenGetEmployeeById_thenReturnsEmployeeAndAudits() throws Exception {
        Employee mockEmployee = new Employee(1, "Ankur", "M", "[email protected]");
        String employeeJson = objectMapper.writeValueAsString(mockEmployee);

        mockServer.expect(requestTo("http://hr-api.example.com/employees/1"))
                .andRespond(withSuccess(employeeJson, MediaType.APPLICATION_JSON));

        Employee result = employeeClient.getEmployeeById(1);

        assertThat(result).isNotNull();
        assertThat(result.id()).isEqualTo(1);
        assertThat(result.firstName()).isEqualTo("Ankur");

        mockServer.verify();
        Mockito.verify(auditLogger).logFetch(1);
    }
}

Source: RestClientEmployeeTest.java. Real, committed output from running it — not retyped:

== RestClient-based EmployeeClient, real deserialized result and audit call ==

requestTo("http://hr-api.example.com/employees/1") -> 200 OK {"id":1,"firstName":"Ankur","lastName":"M","email":"[email protected]"}
result = Employee[id=1, firstName=Ankur, lastName=M, [email protected]]
auditLogger.logFetch(1) called = true

Captured output: 01-restclient-basic-fetch.txt.

Breaking Down the Test

Six things are doing real work in that short class:

  1. @RestClientTest(EmployeeClient.class) sets up a minimal context containing only EmployeeClient and its supporting infrastructure.
  2. Autowired beans: EmployeeClient itself and the auto-configured MockRestServiceServer.
  3. ObjectMapper, injected to serialize the mock response — and, as the next section explains, not the ObjectMapper an older post on this topic would have imported.
  4. @MockitoBean private EmployeeAuditLogger auditLogger replaces the collaborator bean the slice has no way to create on its own — more on why this annotation exists at all later in this post.
  5. Arrange and mock: mockServer.expect(requestTo(...)) declares the expected request; .andRespond(withSuccess(...)) supplies the canned 200 response.
  6. mockServer.verify() confirms the expected request actually happened — a client that silently called the wrong URL, or never called out at all, fails here.

That requestTo("http://hr-api.example.com/employees/1") call is not a stylistic choice — it is a full absolute URL, and a later section in this post explains exactly why that is required for this specific client, with a real captured failure to prove it.

  • Every annotation import in that file is one you will not find in a Spring Boot 3.x project — the next section is the module map that explains why.

The Module-Naming Trap: Three Imports Moved, Not Just the Version Number

Compile the test above against a Boot 3.x mental model and three imports fail, each with a message that looks like a missing dependency rather than a moved class. All three were confirmed the slow way — by letting the build fail, then unzipping the resolved jars and listing their contents.

ClassBoot 3.x packageBoot 4.1 package
RestTemplateBuilderorg.springframework.boot.web.clientorg.springframework.boot.restclient
@RestClientTestorg.springframework.boot.test.autoconfigure.web.clientorg.springframework.boot.restclient.test.autoconfigure
MockRestServiceServerAutoConfigurationorg.springframework.boot.test.autoconfigure.web.clientorg.springframework.boot.restclient.test.autoconfigure

The pattern underneath: Spring Boot 4 split the old monolithic spring-boot-autoconfigure/spring-boot-test-autoconfigure jars by concern. Everything RestClient and RestTemplate related — production beans and their test support — now lives in two small, purpose-built artifacts: spring-boot-restclient (the production side, including the relocated RestTemplateBuilder) and spring-boot-restclient-test (@RestClientTest, MockRestServiceServer‘s auto-configuration, and @MockRestServiceServer‘s companion annotations). spring-boot-starter-restclient pulls in the first automatically; the test artifact is a test-scope dependency you add yourself.

Do not confuse spring-boot-restclient-test with spring-boot-resttestclient. The names are one letter apart and Maven Central’s search box will cheerfully suggest the wrong one. spring-boot-restclient-test is this post’s module — @RestClientTest and MockRestServiceServer. spring-boot-resttestclient backs an entirely different type, RestTestClient, plus TestRestTemplate and its @AutoConfigureTestRestTemplate/@AutoConfigureRestTestClient annotations — see RestTestClient in Spring Framework 7 on this blog for that module map in full, including the one piece of good news in that story (RestTestClient itself needs no extra dependency at all).
<!-- @RestClientTest + MockRestServiceServer live here, not in spring-boot-resttestclient
     (that one backs RestTestClient, a different type entirely). -->
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-restclient-test</artifactId>
  <scope>test</scope>
</dependency>

Source: pom.xml.

  • Confirmed by unzipping both jars and listing their class names side by side — no javadoc reading required, and none trusted either.
  • RestTemplateBuilder‘s move is the one most likely to silently break an upgrade: the compiler error names a missing package, not a hint about where the class went.

Still Using RestTemplate? The Mocking Code Doesn’t Change

@RestClientTest predates RestClient entirely and still works seamlessly with RestTemplate. If your client is built the older way, only the client’s constructor changes — the test is identical in every other respect:

package com.ankurm.restclienttest;

import org.springframework.boot.restclient.RestTemplateBuilder;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;

/**
 * The same job as EmployeeClient, built on the older RestTemplate instead of
 * RestClient. Still fully supported in Spring Boot 4.1, and @RestClientTest
 * handles it identically - see the post for why.
 */
@Service
public class EmployeeTemplateClient {

    private final RestTemplate restTemplate;

    public EmployeeTemplateClient(RestTemplateBuilder builder) {
        this.restTemplate = builder.rootUri("http://hr-api.example.com").build();
    }

    public Employee getEmployeeById(Integer id) {
        return restTemplate.getForObject("/employees/{id}", Employee.class, id);
    }
}

Source: EmployeeTemplateClient.java. The test’s mocking code — mockServer.expect(requestTo(...)).andRespond(withSuccess(...)) — is line-for-line identical to the RestClient version above, which is the whole point when migrating legacy code gradually. Real output:

== RestTemplate-based EmployeeTemplateClient, identical MockRestServiceServer code ==

requestTo("/employees/2") -> 200 OK {"id":2,"firstName":"Priya","lastName":"V","email":"[email protected]"}
result = Employee[id=2, firstName=Priya, lastName=V, [email protected]]

Captured output: 02-resttemplate-same-mocking-code.txt. Notice the one detail that is different: requestTo("/employees/2") is relative, even though rootUri("http://hr-api.example.com") is just as absolute as the RestClient example’s baseUrl(...). That is not a typo — it is the headline trap in this post, next.

The Trap: An Absolute Base URL Breaks a Relative requestTo()

Copy the RestTemplate test’s relative requestTo("/employees/1") style into the RestClient test above, and the test compiles, runs, and fails — not because the client is wrong, but because MockRestServiceServer treats the two clients’ absolute base URLs completely differently. This is exactly backwards from what the previous section’s “identical mocking code” claim would lead you to expect, which is why it is worth isolating here rather than leaving it as a footnote.

Reproduced for real, then deleted — this class is not part of the committed test suite, only its real output is:

# ScratchRelativeRequestToFailsDemoTest - run once to capture this real failure, then deleted.
# Reproduces EmployeeClient (RestClient.Builder.baseUrl("http://hr-api.example.com")) with a
# relative mockServer.expect(requestTo("/employees/1")) - the pattern that works fine for
# EmployeeTemplateClient's RestTemplateBuilder.rootUri(). See scripts/run-all.sh to regenerate.

java.lang.AssertionError: Request URI expected:</employees/1> but was:<http://hr-api.example.com/employees/1>
	at org.springframework.test.util.AssertionErrors.fail(AssertionErrors.java:62)
	at org.springframework.test.util.AssertionErrors.assertEquals(AssertionErrors.java:129)
	at org.springframework.test.web.client.match.MockRestRequestMatchers.lambda$requestTo$1(MockRestRequestMatchers.java:92)
	at org.springframework.web.client.DefaultRestClient$DefaultResponseSpec.body(DefaultRestClient.java:833)
	at com.ankurm.restclienttest.EmployeeClient.getEmployeeById(EmployeeClient.java:31)

Full transcript: 05-restclient-relative-requestto-fails.txt, regenerated on demand by scripts/run-all.sh.

Why the exact same requestTo(“/employees/N”) behaves differently RestClient.Builder.baseUrl(abs) request.getURI() is absolute no root-URI unwrapping exists requestTo() compares raw strings, must match fully requestTo(“/employees/1”) fails: expected relative, actual absolute. RestTemplateBuilder.rootUri(abs) RootUriTemplateHandler detected RootUriRequestExpectationManager strips the root URI before comparing to requestTo() requestTo(“/employees/2”) passes: the root URI was stripped first.

The mechanism, confirmed by locating the class in the resolved jar rather than guessing from the name: org.springframework.boot.restclient.test.RootUriRequestExpectationManager ships inside spring-boot-restclient-test. When MockRestServiceServerAutoConfiguration binds a RestTemplate whose UriTemplateHandler is a RootUriTemplateHandler — exactly what RestTemplateBuilder.rootUri(...) installs — it routes expectation matching through this class, which strips the root URI before comparing against your requestTo() argument. RestClient.Builder.baseUrl(...) has no equivalent: there is no root-URI concept to detect and unwrap, so the request’s full absolute URI reaches the matcher as-is.

Rule of thumb that survives this trap: if the client under test is RestClient-based with an absolute baseUrl, write requestTo() expectations as full absolute URLs, as both RestClientEmployeeTest and ErrorScenarioTest do above. If it’s RestTemplate-based with rootUri, relative paths keep working — which also means a copy-paste from a RestTemplate test into a new RestClient test is exactly the moment this bites, since nothing about the code looks wrong until it fails.
  • This asymmetry is not documented anywhere in the @RestClientTest Javadoc — it only becomes visible by reading MockRestServiceServerAutoConfiguration‘s source or, as here, by triggering the failure for real.
  • If your base URL is actually relative (an in-process test server, say) this entire trap disappears — it only shows up once a real absolute host is involved, which is most real HR-API-style clients.

Testing Error Scenarios

A well-rounded client test suite also covers failure paths. MockRestServiceServer makes a 404 just as easy to simulate as a 200:

package com.ankurm.restclienttest;

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.HttpStatus;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.client.MockRestServiceServer;
import org.springframework.web.client.HttpClientErrorException;

import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withStatus;

@RestClientTest(EmployeeClient.class)
class ErrorScenarioTest {

    @Autowired
    private MockRestServiceServer mockServer;

    @Autowired
    private EmployeeClient employeeClient;

    @MockitoBean
    private EmployeeAuditLogger auditLogger;

    @Test
    void whenEmployeeNotFound_thenThrowsException() {
        mockServer.expect(requestTo("http://hr-api.example.com/employees/99"))
                .andRespond(withStatus(HttpStatus.NOT_FOUND));

        HttpClientErrorException.NotFound thrown = null;
        try {
            employeeClient.getEmployeeById(99);
        } catch (HttpClientErrorException.NotFound ex) {
            thrown = ex;
        }

        assertThat(thrown).isNotNull();
        mockServer.verify();
    }
}

Source: ErrorScenarioTest.java. Real output confirming exactly which exception type a 404 surfaces as:

== A 404 response from MockRestServiceServer surfaces as HttpClientErrorException.NotFound ==

requestTo("http://hr-api.example.com/employees/99") -> 404 Not Found
exception class = org.springframework.web.client.HttpClientErrorException$NotFound
exception.getStatusCode() = 404 NOT_FOUND

Captured output: 03-404-throws-notfound.txt. Note the requestTo() call is absolute here too — same RestClient, same trap from the previous section.

  • HttpClientErrorException.NotFound is one of several status-specific subclasses Spring provides (BadRequest, Unauthorized, Forbidden, and more) — catching the specific subclass is more precise than catching the general HttpClientErrorException when your handling genuinely differs by status.

A Declarative HTTP Interface Client, Tested the Same Way

Spring Framework 6 introduced declarative HTTP interface clients: a bare interface, annotated, with no implementation at all.

package com.ankurm.restclienttest;

import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.service.annotation.GetExchange;

/**
 * A declarative HTTP interface client (Spring Framework 6+): no implementation here at all,
 * just the contract. AppConfig turns this into a real bean at runtime via
 * HttpServiceProxyFactory, bound to the same RestClient as EmployeeClient.
 */
public interface EmployeeHttpInterface {

    @GetExchange("/employees/{id}")
    Employee getEmployeeById(@PathVariable Integer id);
}

Source: EmployeeHttpInterface.java. Something has to turn that interface into a real proxy bean, and it has to happen in a @Configuration class, via HttpServiceProxyFactory:

package com.ankurm.restclienttest;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.support.RestClientAdapter;
import org.springframework.web.service.invoker.HttpServiceProxyFactory;

@Configuration
public class AppConfig {

    @Bean
    public RestClient employeeRestClient(RestClient.Builder builder) {
        return builder.baseUrl("http://hr-api.example.com").build();
    }

    @Bean
    public EmployeeHttpInterface employeeHttpInterface(RestClient employeeRestClient) {
        RestClientAdapter adapter = RestClientAdapter.create(employeeRestClient);
        HttpServiceProxyFactory factory = HttpServiceProxyFactory.builderFor(adapter).build();
        return factory.createClient(EmployeeHttpInterface.class);
    }
}

Source: AppConfig.java. This is the one genuine case that needs a RestClient bean, not just a builder — RestClientAdapter.create(...) takes a built client, and @RestClientTest can’t scan a bare interface the way it scans a @Service class. The fix: pass the @Configuration class itself as the slice’s components, so its @Bean methods run inside the sliced context and pick up the auto-configured mocked builder:

package com.ankurm.restclienttest;

import tools.jackson.databind.ObjectMapper;
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.MediaType;
import org.springframework.test.web.client.MockRestServiceServer;

import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;

@RestClientTest(components = AppConfig.class)
class HttpInterfaceClientTest {

    @Autowired
    private MockRestServiceServer mockServer;

    @Autowired
    private EmployeeHttpInterface employeeHttpInterface;

    @Autowired
    private ObjectMapper objectMapper;

    @Test
    void whenGetEmployeeById_thenHttpInterfaceReturnsEmployee() throws Exception {
        Employee mockEmployee = new Employee(3, "Daniel", "O", "[email protected]");
        String employeeJson = objectMapper.writeValueAsString(mockEmployee);

        mockServer.expect(requestTo("http://hr-api.example.com/employees/3"))
                .andRespond(withSuccess(employeeJson, MediaType.APPLICATION_JSON));

        Employee result = employeeHttpInterface.getEmployeeById(3);

        assertThat(result).isNotNull();
        assertThat(result.id()).isEqualTo(3);
        assertThat(result.firstName()).isEqualTo("Daniel");

        mockServer.verify();
    }
}

Source: HttpInterfaceClientTest.java. This is the one claim in this post that genuinely needed a real build before being trusted — it worked on the first real run:

== A declarative @HttpExchange interface client, mocked the same way as a hand-written one ==

requestTo("http://hr-api.example.com/employees/3") -> 200 OK {"id":3,"firstName":"Daniel","lastName":"O","email":"[email protected]"}
result = Employee[id=3, firstName=Daniel, lastName=O, [email protected]]

Captured output: 04-http-interface-client.txt.

@RestClientTest(components = AppConfig.class) mocked RestClient.Builder AppConfig.employeeRestClient() @Bean -> mocked RestClient employeeHttpInterface() HttpServiceProxyFactory Without components = AppConfig.class, neither @Bean method runs, and EmployeeHttpInterface has no bean to autowire at all – the same UnsatisfiedDependencyException shape as the earlier RestClient trap, for the same underlying reason: a bare @RestClientTest(X.class) never scans your @Configuration.

@MockBean Is Gone: @MockitoBean for a Plain Collaborator Bean

EmployeeClient depends on EmployeeAuditLogger, a plain interface with nothing to do with REST clients at all:

package com.ankurm.restclienttest;

/**
 * A collaborator EmployeeClient depends on but that has nothing to do with REST clients
 * itself. @RestClientTest's minimal context never creates a bean for this on its own -
 * it exists specifically so the test class can demonstrate replacing it with
 * @MockitoBean, now that @MockBean is gone in Spring Boot 4.
 */
public interface EmployeeAuditLogger {

    void logFetch(Integer employeeId);
}

Source: EmployeeAuditLogger.java; its production implementation is EmployeeAuditLoggerImpl.java, a real SLF4J-logging @Component that is deliberately never present in the test slice. Without something standing in for it, the context fails to start — and the something is no longer @MockBean:

@MockitoBean
private EmployeeAuditLogger auditLogger;

The same two lines, in context, in RestClientEmployeeTest.java. @MockBean is not deprecated in Spring Boot 4 — it is physically gone. @MockitoBean (org.springframework.test.context.bean.override.mockito.MockitoBean) replaces it, built on Spring Framework’s newer bean-override mechanism rather than Boot’s own test infrastructure. The usage above is drop-in equivalent for this case: declare a field, annotate it, Mockito supplies the mock, Spring wires it into the context in place of the real bean.

This is a removal, not a deprecation cycle. A project with existing @MockBean usages fails to compile on Boot 4, with no deprecation warning period to lean on. The companion repository for Testing Spring Boot 4 Without Containers, elsewhere on this blog, covers the full @MockitoBean/@MockitoSpyBean migration in depth, including the ApplicationContext caching behavior that changes alongside it — worth reading before migrating a large suite rather than converting annotations one at a time.
  • Used here for exactly the reason @MockBean existed in the first place: a collaborator the test slice has no way to construct, that the client under test genuinely needs to be present as something.

The Default JSON Library Changed Too

Every test file above imports tools.jackson.databind.ObjectMapper. Not a typo, and not com.fasterxml.jackson.databind.ObjectMapper — confirmed the hard way, when the classic import failed to compile with package com.fasterxml.jackson.databind does not exist, and mvn dependency:tree resolved this instead:

|  +- org.springframework.boot:spring-boot-starter-jackson:jar:4.1.1:compile
|  |  \- org.springframework.boot:spring-boot-jackson:jar:4.1.1:compile
|  |     \- tools.jackson.core:jackson-databind:jar:3.1.5:compile
|  |        +- com.fasterxml.jackson.core:jackson-annotations:jar:2.21:compile
|  |        \- tools.jackson.core:jackson-core:jar:3.1.5:compile

Full transcript: 06-dependency-tree-jackson3.txt. spring-boot-starter-jackson pulls in Jackson 3, under a brand-new Maven groupId, tools.jackson.core — not the familiar com.fasterxml.jackson.core that every Jackson 2 project has used for over a decade. Unzipping the resolved jar confirms the package moved along with the group: the class is tools/jackson/databind/ObjectMapper.class, not com/fasterxml/jackson/databind/ObjectMapper.class. jackson-annotations is the one piece that stayed put, still under com.fasterxml.jackson.core — so @JsonProperty and friends keep their familiar import, while ObjectMapper, JsonNode, and the rest of jackson-databind/jackson-core do not.

This is bigger than the module-naming trap above. A moved package is a one-line import fix once you know where it went. Jackson 3 is a different major version of the library your application’s JSON correctness depends on, defaulted in silently by spring-boot-starter-jackson. If your project adds Jackson customizations — custom serializers, mix-ins, module registrations — against the classic com.fasterxml.jackson.databind types, expect them to need the same import swap this test code did, and budget time to check Jackson 3’s own behavioral changes beyond the package rename. This blog now covers that migration as its own topic in full: Custom Serialisers, Deserialisers and Mix-ins in Jackson 3, rebuilt from scratch against a real Jackson 3.2.3 build.
  • Confirmed two independent ways: the compiler’s own error message, and unzip -l against the resolved tools.jackson.core:jackson-databind:3.1.5 jar — not inferred from a changelog.
  • ObjectMapper.writeValueAsString(...) itself is unchanged in shape and used identically above — this is a package move with real downstream consequences, not an API redesign of the method you are most likely to call every day.

Should You Even Write This Test?

A @RestClientTest proves your client builds the right request and parses the right response — nothing more. It does not prove the external API still behaves the way your mock assumes, does not catch a contract drift on the other team’s side, and — as the root-URI trap above shows — it will happily pass or fail based on details of how Spring’s HTTP clients construct URIs internally, not on anything about your business logic. Pair it with a contract test or a periodic integration check against a real staging endpoint if the external API changes without warning you; do not treat a green @RestClientTest suite as proof the integration itself still works.

With that caveat in place, it is still the right default for this layer: fast, deterministic, and — once you know which base-URL style you’re using — precise about exactly what request your client sends. The module built across this post is the proof: four real test classes, four real captured transcripts, and one real captured failure that would otherwise have been a surprising production debugging session instead of a paragraph in a blog post.

Further reading

No Comments yet!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.