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:
@@ -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
|
||||||
@@ -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>
|
||||||
Executable
+92
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+18
@@ -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")));
|
||||||
|
}
|
||||||
|
}
|
||||||
+52
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user