Add rest-client-test module: @RestClientTest on Boot 4.1, Jackson 3 by default, and the RestClient-vs-RestTemplate root-URI matching trap (post #49)

This commit is contained in:
Claude
2026-10-08 16:40:51 +00:00
parent 4370ac7fdf
commit b9388053c4
23 changed files with 704 additions and 0 deletions
+81
View File
@@ -0,0 +1,81 @@
# rest-client-test
Companion module for [ankurm.com](https://ankurm.com) - testing Spring REST clients on **Spring
Boot 4.1.1** with `@RestClientTest` and `MockRestServiceServer`: a `RestClient`-based client, a
`RestTemplate`-based client, a declarative `@HttpExchange` interface client, an error-path test,
and `@MockitoBean` standing in for a plain collaborator bean now that `@MockBean` is gone.
## Versions actually used here
| Component | Version | Verified against |
|---|---|---|
| Spring Boot | 4.1.1 | `spring-boot-starter-parent` POM, Maven Central |
| Spring Framework | 7.0.9 | resolved transitively, confirmed via `mvn dependency:tree` |
| JDK | 25 (Temurin 25.0.4.1+1) | `java -version` in this sandbox |
| JUnit | 6.0.3 | resolved transitively |
| Jackson | **3.1.5**, groupId `tools.jackson.core` | resolved transitively, confirmed via `mvn dependency:tree` and by unzipping the jar |
## The module-naming trap, reconfirmed
`@RestClientTest` and `MockRestServiceServer`'s auto-configuration now live in
**`spring-boot-restclient-test`** (`org.springframework.boot.restclient.test.autoconfigure.RestClientTest`),
not in `spring-boot-resttestclient` - that second module backs the newer `RestTestClient` type used
in the [`rest-test-client`](../rest-test-client) module instead. Confirmed by unzipping both jars
and listing their classes; the names are one letter apart and Maven Central's search box will
happily suggest the wrong one.
`RestTemplateBuilder` moved too, from `org.springframework.boot.web.client` (Boot 3.x) to
**`org.springframework.boot.restclient.RestTemplateBuilder`** - same module, `spring-boot-restclient`,
as `RestClient.Builder`'s auto-configuration. A Boot 3 import of `RestTemplateBuilder` fails to
compile on Boot 4.1 with no hint beyond "package does not exist."
## The bigger surprise: Jackson 3 by default
`mvn dependency:tree` resolves `tools.jackson.core:jackson-databind:3.1.5`, not
`com.fasterxml.jackson.core:jackson-databind`. Spring Boot 4.1's `spring-boot-starter-jackson`
pulls in **Jackson 3**, whose `ObjectMapper` lives at `tools.jackson.databind.ObjectMapper` - a
different package entirely, not just a version bump. `com.fasterxml.jackson.core:jackson-annotations`
is still on the classpath (so `@JsonProperty` etc. keep their old import), but `jackson-databind`
and `jackson-core` are the new `tools.jackson.*` artifacts. Every test class in this module imports
`tools.jackson.databind.ObjectMapper`, not the classic one - this bit every test file on the first
compile attempt here. See the [Jackson 3 post](https://ankurm.com) on this blog for the full
migration story.
## Quickstart
```bash
./scripts/run-all.sh
```
Runs the full suite and regenerates every file in `docs/output/`.
## Test classes
| Class | What it proves |
|---|---|
| [`RestClientEmployeeTest`](src/test/java/com/ankurm/restclienttest/RestClientEmployeeTest.java) | `@RestClientTest(EmployeeClient.class)` + `MockRestServiceServer`, with `@MockitoBean` replacing the collaborator `EmployeeAuditLogger` |
| [`RestTemplateEmployeeTest`](src/test/java/com/ankurm/restclienttest/RestTemplateEmployeeTest.java) | The same job on `RestTemplate`, and the one case where a *relative* `requestTo()` still works against an absolute root URI |
| [`HttpInterfaceClientTest`](src/test/java/com/ankurm/restclienttest/HttpInterfaceClientTest.java) | `@RestClientTest(components = AppConfig.class)` wiring a declarative `@HttpExchange` interface client through the mock |
| [`ErrorScenarioTest`](src/test/java/com/ankurm/restclienttest/ErrorScenarioTest.java) | A 404 from `MockRestServiceServer` surfacing as `HttpClientErrorException.NotFound` |
## Captured output
| File | From |
|---|---|
| [`00-full-test-run.txt`](docs/output/00-full-test-run.txt) | `mvn test`, all 4 classes, green |
| [`01-restclient-basic-fetch.txt`](docs/output/01-restclient-basic-fetch.txt) | `RestClientEmployeeTest` |
| [`02-resttemplate-same-mocking-code.txt`](docs/output/02-resttemplate-same-mocking-code.txt) | `RestTemplateEmployeeTest` |
| [`03-404-throws-notfound.txt`](docs/output/03-404-throws-notfound.txt) | `ErrorScenarioTest` |
| [`04-http-interface-client.txt`](docs/output/04-http-interface-client.txt) | `HttpInterfaceClientTest` |
| [`05-restclient-relative-requestto-fails.txt`](docs/output/05-restclient-relative-requestto-fails.txt) | A scratch test (not committed) reproducing the real `AssertionError` when `requestTo()` is written relative against a `RestClient` built with an absolute `baseUrl` |
## Why #01 and #05 use an absolute `requestTo()` but #02 does not
`EmployeeClient` builds its `RestClient` with `RestClient.Builder.baseUrl("http://hr-api.example.com")`.
`EmployeeTemplateClient` builds its `RestTemplate` with `RestTemplateBuilder.rootUri(...)` instead.
Spring Boot's `MockRestServiceServerAutoConfiguration` detects a `RestTemplate` built with a root
URI and binds it through `RootUriRequestExpectationManager`
(`org.springframework.boot.restclient.test.RootUriRequestExpectationManager`), which strips the
root URI before matching - so `requestTo("/employees/2")` still works. There is no equivalent for
`RestClient.Builder.baseUrl(...)`: a `RestClient` test has to match the full absolute URI, as #05's
captured failure shows. This is covered in depth in the published post.
@@ -0,0 +1,13 @@
# mvn test - full run, Spring Boot 4.1.1
[INFO] Running com.ankurm.restclienttest.RestClientEmployeeTest
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 3.122 s -- in com.ankurm.restclienttest.RestClientEmployeeTest
[INFO] Running com.ankurm.restclienttest.HttpInterfaceClientTest
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.401 s -- in com.ankurm.restclienttest.HttpInterfaceClientTest
[INFO] Running com.ankurm.restclienttest.ErrorScenarioTest
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.067 s -- in com.ankurm.restclienttest.ErrorScenarioTest
[INFO] Running com.ankurm.restclienttest.RestTemplateEmployeeTest
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.280 s -- in com.ankurm.restclienttest.RestTemplateEmployeeTest
[INFO] Results:
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
@@ -0,0 +1,5 @@
== 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
@@ -0,0 +1,4 @@
== 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]]
@@ -0,0 +1,5 @@
== 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
@@ -0,0 +1,4 @@
== 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]]
@@ -0,0 +1,26 @@
# 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.test.web.client.DefaultRequestExpectation.match(DefaultRequestExpectation.java:86)
at org.springframework.test.web.client.SimpleRequestExpectationManager.matchRequest(SimpleRequestExpectationManager.java:62)
at org.springframework.test.web.client.AbstractRequestExpectationManager.validateRequest(AbstractRequestExpectationManager.java:92)
at org.springframework.test.web.client.MockRestServiceServer$MockClientHttpRequestFactory$1.executeInternal(MockRestServiceServer.java:342)
at org.springframework.mock.http.client.MockClientHttpRequest.execute(MockClientHttpRequest.java:140)
at org.springframework.web.client.DefaultRestClient$DefaultRequestBodyUriSpec.exchangeInternal(DefaultRestClient.java:614)
at org.springframework.web.client.DefaultRestClient$DefaultRequestBodyUriSpec.exchange(DefaultRestClient.java:572)
at org.springframework.web.client.RestClient$RequestHeadersSpec.exchange(RestClient.java:747)
at org.springframework.web.client.DefaultRestClient$DefaultResponseSpec.executeAndExtract(DefaultRestClient.java:924)
at org.springframework.web.client.DefaultRestClient$DefaultResponseSpec.body(DefaultRestClient.java:833)
at com.ankurm.restclienttest.EmployeeClient.getEmployeeById(EmployeeClient.java:31)
at com.ankurm.restclienttest.ScratchRelativeRequestToFailsDemoTest.whenRequestToIsRelative_thenMismatch(ScratchRelativeRequestToFailsDemoTest.java:36)
[INFO]
[INFO] Results:
[INFO]
[ERROR] Failures:
@@ -0,0 +1,8 @@
# mvn dependency:tree, trimmed to the spring-boot-starter-restclient / Jackson subtree.
# Full raw output has an "[INFO] " prefix on every line, stripped here for quoting.
| +- 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
+56
View File
@@ -0,0 +1,56 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
<relativePath/>
</parent>
<groupId>com.ankurm</groupId>
<artifactId>rest-client-test</artifactId>
<version>1.0.0</version>
<name>rest-client-test</name>
<description>Testing Spring REST clients with @RestClientTest on Spring Boot 4.1: RestClient and
RestTemplate clients, an HTTP interface client, MockRestServiceServer, and @MockitoBean for a
collaborator bean now that @MockBean is gone.</description>
<packaging>jar</packaging>
<properties>
<java.version>25</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-restclient</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- @RestClientTest + MockRestServiceServer live here, not in spring-boot-resttestclient
(that one backs RestTestClient, a different type - see the post). -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-restclient-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
+92
View File
@@ -0,0 +1,92 @@
#!/usr/bin/env bash
# Regenerates every file in docs/output/ from a real build - nothing in there is hand-typed.
#
# 00-full-test-run.txt, 01-04: from the 4 committed test classes (mvn test).
# 05: from a scratch test class, recreated here, run once, captured, deleted again. It is not
# part of the committed suite - it exists only to show the real failure of a relative
# requestTo() against a RestClient built with an absolute baseUrl.
# 06: the real `mvn dependency:tree` subtree showing Jackson 3 (tools.jackson.core) resolving
# in place of classic Jackson 2.
set -euo pipefail
cd "$(dirname "$0")/.."
echo "== mvn test (4 committed classes) =="
mvn -q test
{
echo "# mvn test - full run, Spring Boot 4.1.1"
echo
mvn test 2>&1 | grep -E "^\[INFO\] Running |^\[INFO\] Tests run|^\[INFO\] Results:|^\[INFO\] BUILD"
} > docs/output/00-full-test-run.txt
echo "== scratch: relative requestTo() against an absolute RestClient baseUrl =="
SCRATCH=src/test/java/com/ankurm/restclienttest/ScratchRelativeRequestToFailsDemoTest.java
cat > "$SCRATCH" <<'EOF'
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.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
@RestClientTest(EmployeeClient.class)
class ScratchRelativeRequestToFailsDemoTest {
@Autowired
private MockRestServiceServer mockServer;
@Autowired
private EmployeeClient employeeClient;
@Autowired
private ObjectMapper objectMapper;
@org.springframework.test.context.bean.override.mockito.MockitoBean
private EmployeeAuditLogger auditLogger;
@Test
void whenRequestToIsRelative_thenMismatch() throws Exception {
Employee mockEmployee = new Employee(1, "Ankur", "M", "[email protected]");
String employeeJson = objectMapper.writeValueAsString(mockEmployee);
mockServer.expect(requestTo("/employees/1"))
.andRespond(withSuccess(employeeJson, MediaType.APPLICATION_JSON));
employeeClient.getEmployeeById(1);
}
}
EOF
# The scratch test's real, expected failure gives mvn a nonzero exit code - capture via command
# substitution with `|| true` rather than a raw pipe, which would abort this script under
# set -o pipefail even though the failure is exactly what we're here to capture.
raw_out="$(mvn test -Dtest=ScratchRelativeRequestToFailsDemoTest 2>&1 || true)"
rm "$SCRATCH"
{
echo "# ScratchRelativeRequestToFailsDemoTest - run once to capture this real failure, then deleted."
echo "# Reproduces EmployeeClient (RestClient.Builder.baseUrl(\"http://hr-api.example.com\")) with a"
echo "# relative mockServer.expect(requestTo(\"/employees/1\")) - the pattern that works fine for"
echo "# EmployeeTemplateClient's RestTemplateBuilder.rootUri(). See scripts/run-all.sh to regenerate."
echo
echo "$raw_out" | grep -A20 "AssertionError: Request URI expected"
} > docs/output/05-restclient-relative-requestto-fails.txt
echo "== dependency:tree subtree showing Jackson 3 resolving by default =="
{
echo "# mvn dependency:tree, trimmed to the spring-boot-starter-restclient / Jackson subtree."
echo "# Full raw output has an \"[INFO] \" prefix on every line, stripped here for quoting."
echo
mvn dependency:tree 2>&1 | grep -E "^\[INFO\]" | sed -E 's/^\[INFO\] //' \
| grep -A4 "spring-boot-starter-jackson:jar"
} > docs/output/06-dependency-tree-jackson3.txt
echo "== confirming the committed suite is still green after the scratch run =="
mvn -q test
echo "Done. docs/output/ regenerated."
@@ -0,0 +1,32 @@
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;
/**
* Production wiring: a single {@code RestClient} pointed at the HR API's base URL, plus the
* declarative HTTP interface client built on top of it.
*/
@Configuration
public class AppConfig {
@Bean
public RestClient employeeRestClient(RestClient.Builder builder) {
return builder.baseUrl("http://hr-api.example.com").build();
}
/**
* {@link EmployeeHttpInterface} is a plain annotated interface, not a class
* {@code @RestClientTest} can scan or instantiate directly - it has to be created here,
* from the same {@code RestClient}, via {@link HttpServiceProxyFactory}.
*/
@Bean
public EmployeeHttpInterface employeeHttpInterface(RestClient employeeRestClient) {
RestClientAdapter adapter = RestClientAdapter.create(employeeRestClient);
HttpServiceProxyFactory factory = HttpServiceProxyFactory.builderFor(adapter).build();
return factory.createClient(EmployeeHttpInterface.class);
}
}
@@ -0,0 +1,7 @@
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) {
}
@@ -0,0 +1,12 @@
package com.ankurm.restclienttest;
/**
* A collaborator {@code EmployeeClient} depends on but that has nothing to do with REST clients
* itself. {@code @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
* {@code @MockitoBean}, now that {@code @MockBean} is gone in Spring Boot 4.
*/
public interface EmployeeAuditLogger {
void logFetch(Integer employeeId);
}
@@ -0,0 +1,20 @@
package com.ankurm.restclienttest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
/**
* The real implementation, wired in production but never present in the {@code @RestClientTest}
* slice - see {@code EmployeeAuditLogger}.
*/
@Component
public class EmployeeAuditLoggerImpl implements EmployeeAuditLogger {
private static final Logger log = LoggerFactory.getLogger(EmployeeAuditLoggerImpl.class);
@Override
public void logFetch(Integer employeeId) {
log.info("Fetched employee {}", employeeId);
}
}
@@ -0,0 +1,35 @@
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 {@code RestClient} -
* the recommended approach for new client code. Every successful fetch is audited through
* {@link EmployeeAuditLogger}, a plain collaborator bean unrelated to REST at all.
*
* <p>The client builds itself from an injected {@code RestClient.Builder}, the same shape as
* {@link EmployeeTemplateClient}'s {@code RestTemplateBuilder} constructor - this is what lets
* {@code @RestClientTest} wire a mocked client straight in, with no separate {@code RestClient}
* bean required (see {@link EmployeeHttpInterface} for the one case that does need one).</p>
*/
@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;
}
}
@@ -0,0 +1,15 @@
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. {@link AppConfig} turns this into a real bean at runtime via
* {@code HttpServiceProxyFactory}, bound to the same {@code RestClient} as {@link EmployeeClient}.
*/
public interface EmployeeHttpInterface {
@GetExchange("/employees/{id}")
Employee getEmployeeById(@PathVariable Integer id);
}
@@ -0,0 +1,24 @@
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 {@link EmployeeClient}, built on the older {@code RestTemplate} instead of
* {@code RestClient}. Still fully supported in Spring Boot 4.1, and {@code @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);
}
}
@@ -0,0 +1,18 @@
package com.ankurm.restclienttest;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* No real {@code main} is ever run in this module - the tests only need a
* {@code @SpringBootApplication} class somewhere above them in the package tree so that
* {@code @RestClientTest} (and Spring Boot's test slices in general) can find a configuration to
* bootstrap from.
*/
@SpringBootApplication
public class RestClientTestApplication {
public static void main(String[] args) {
SpringApplication.run(RestClientTestApplication.class, args);
}
}
@@ -0,0 +1,27 @@
package com.ankurm.restclienttest;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
/**
* Writes a real line of test output to {@code docs/output/<fileName>}, so every number quoted in
* the post traces back to a committed transcript rather than a remembered one.
*/
final class Capture {
private Capture() {
}
static void write(String fileName, String heading, String body) {
Path dir = Paths.get("docs", "output");
try {
Files.createDirectories(dir);
Files.writeString(dir.resolve(fileName), "== " + heading + " ==\n\n" + body + "\n");
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
}
@@ -0,0 +1,52 @@
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;
/**
* A well-rounded client test suite covers failure paths too. {@code MockRestServiceServer} makes
* a 404 just as easy to simulate as a 200.
*/
@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();
Capture.write("03-404-throws-notfound.txt",
"A 404 response from MockRestServiceServer surfaces as HttpClientErrorException.NotFound",
"requestTo(\"http://hr-api.example.com/employees/99\") -> 404 Not Found\n"
+ "exception class = " + thrown.getClass().getName()
+ "\nexception.getStatusCode() = " + thrown.getStatusCode());
}
}
@@ -0,0 +1,54 @@
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;
/**
* {@link EmployeeHttpInterface} has no class {@code @RestClientTest} can point at directly - it
* is a bare interface, and the real bean only exists because {@link AppConfig}'s
* {@code @Bean} method builds it from an {@code HttpServiceProxyFactory}. Passing the
* {@code @Configuration} class itself as the slice's "component" is what gets that bean (and
* the underlying {@code RestClient} it is built from) into the test context at all; the mocking
* code underneath is otherwise identical to {@link RestClientEmployeeTest}.
*/
@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();
Capture.write("04-http-interface-client.txt",
"A declarative @HttpExchange interface client, mocked the same way as a hand-written one",
"requestTo(\"http://hr-api.example.com/employees/3\") -> 200 OK " + employeeJson + "\nresult = " + result);
}
}
@@ -0,0 +1,62 @@
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;
/**
* The core example: a {@code RestClient}-based client, tested with {@code @RestClientTest} and
* {@code MockRestServiceServer}. {@link EmployeeClient} also depends on
* {@link EmployeeAuditLogger}, a plain collaborator the REST-client slice never creates on its
* own - replaced here with {@code @MockitoBean} now that {@code @MockBean} no longer exists in
* Spring Boot 4.
*/
@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);
Capture.write("01-restclient-basic-fetch.txt",
"RestClient-based EmployeeClient, real deserialized result and audit call",
"requestTo(\"http://hr-api.example.com/employees/1\") -> 200 OK " + employeeJson
+ "\nresult = " + result
+ "\nauditLogger.logFetch(1) called = " + Mockito.mockingDetails(auditLogger)
.getInvocations().stream().anyMatch(i -> i.getMethod().getName().equals("logFetch")));
}
}
@@ -0,0 +1,52 @@
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;
/**
* Same job, built on {@code RestTemplate} instead of {@code RestClient}.
* {@code @RestClientTest} auto-configures a {@code RestTemplateBuilder} too, so any
* {@code RestTemplate} built from it is wired through the same {@code MockRestServiceServer} -
* the mocking code below is identical to {@link RestClientEmployeeTest}'s, line for line.
*/
@RestClientTest(EmployeeTemplateClient.class)
class RestTemplateEmployeeTest {
@Autowired
private MockRestServiceServer mockServer;
@Autowired
private EmployeeTemplateClient employeeTemplateClient;
@Autowired
private ObjectMapper objectMapper;
@Test
void whenGetEmployeeById_thenReturnsEmployee() throws Exception {
Employee mockEmployee = new Employee(2, "Priya", "V", "[email protected]");
String employeeJson = objectMapper.writeValueAsString(mockEmployee);
mockServer.expect(requestTo("/employees/2"))
.andRespond(withSuccess(employeeJson, MediaType.APPLICATION_JSON));
Employee result = employeeTemplateClient.getEmployeeById(2);
assertThat(result).isNotNull();
assertThat(result.id()).isEqualTo(2);
assertThat(result.firstName()).isEqualTo("Priya");
mockServer.verify();
Capture.write("02-resttemplate-same-mocking-code.txt",
"RestTemplate-based EmployeeTemplateClient, identical MockRestServiceServer code",
"requestTo(\"/employees/2\") -> 200 OK " + employeeJson + "\nresult = " + result);
}
}