diff --git a/rest-client-test/README.md b/rest-client-test/README.md new file mode 100644 index 0000000..78f30dc --- /dev/null +++ b/rest-client-test/README.md @@ -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. diff --git a/rest-client-test/docs/output/00-full-test-run.txt b/rest-client-test/docs/output/00-full-test-run.txt new file mode 100644 index 0000000..e794551 --- /dev/null +++ b/rest-client-test/docs/output/00-full-test-run.txt @@ -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 diff --git a/rest-client-test/docs/output/01-restclient-basic-fetch.txt b/rest-client-test/docs/output/01-restclient-basic-fetch.txt new file mode 100644 index 0000000..72f386a --- /dev/null +++ b/rest-client-test/docs/output/01-restclient-basic-fetch.txt @@ -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":"ankur@example.com"} +result = Employee[id=1, firstName=Ankur, lastName=M, email=ankur@example.com] +auditLogger.logFetch(1) called = true diff --git a/rest-client-test/docs/output/02-resttemplate-same-mocking-code.txt b/rest-client-test/docs/output/02-resttemplate-same-mocking-code.txt new file mode 100644 index 0000000..fea4718 --- /dev/null +++ b/rest-client-test/docs/output/02-resttemplate-same-mocking-code.txt @@ -0,0 +1,4 @@ +== RestTemplate-based EmployeeTemplateClient, identical MockRestServiceServer code == + +requestTo("/employees/2") -> 200 OK {"id":2,"firstName":"Priya","lastName":"V","email":"priya@example.com"} +result = Employee[id=2, firstName=Priya, lastName=V, email=priya@example.com] diff --git a/rest-client-test/docs/output/03-404-throws-notfound.txt b/rest-client-test/docs/output/03-404-throws-notfound.txt new file mode 100644 index 0000000..4fe8b41 --- /dev/null +++ b/rest-client-test/docs/output/03-404-throws-notfound.txt @@ -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 diff --git a/rest-client-test/docs/output/04-http-interface-client.txt b/rest-client-test/docs/output/04-http-interface-client.txt new file mode 100644 index 0000000..eb81212 --- /dev/null +++ b/rest-client-test/docs/output/04-http-interface-client.txt @@ -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":"daniel@example.com"} +result = Employee[id=3, firstName=Daniel, lastName=O, email=daniel@example.com] diff --git a/rest-client-test/docs/output/05-restclient-relative-requestto-fails.txt b/rest-client-test/docs/output/05-restclient-relative-requestto-fails.txt new file mode 100644 index 0000000..19dc6c9 --- /dev/null +++ b/rest-client-test/docs/output/05-restclient-relative-requestto-fails.txt @@ -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: but was: + 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: diff --git a/rest-client-test/docs/output/06-dependency-tree-jackson3.txt b/rest-client-test/docs/output/06-dependency-tree-jackson3.txt new file mode 100644 index 0000000..e431a4b --- /dev/null +++ b/rest-client-test/docs/output/06-dependency-tree-jackson3.txt @@ -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 diff --git a/rest-client-test/pom.xml b/rest-client-test/pom.xml new file mode 100644 index 0000000..7e161a5 --- /dev/null +++ b/rest-client-test/pom.xml @@ -0,0 +1,56 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 4.1.1 + + + + com.ankurm + rest-client-test + 1.0.0 + rest-client-test + 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. + jar + + + 25 + + + + + org.springframework.boot + spring-boot-starter-restclient + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + org.springframework.boot + spring-boot-restclient-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/rest-client-test/scripts/run-all.sh b/rest-client-test/scripts/run-all.sh new file mode 100755 index 0000000..e7936a7 --- /dev/null +++ b/rest-client-test/scripts/run-all.sh @@ -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", "ankur@example.com"); + 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." diff --git a/rest-client-test/src/main/java/com/ankurm/restclienttest/AppConfig.java b/rest-client-test/src/main/java/com/ankurm/restclienttest/AppConfig.java new file mode 100644 index 0000000..4c214b6 --- /dev/null +++ b/rest-client-test/src/main/java/com/ankurm/restclienttest/AppConfig.java @@ -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); + } +} diff --git a/rest-client-test/src/main/java/com/ankurm/restclienttest/Employee.java b/rest-client-test/src/main/java/com/ankurm/restclienttest/Employee.java new file mode 100644 index 0000000..bcce048 --- /dev/null +++ b/rest-client-test/src/main/java/com/ankurm/restclienttest/Employee.java @@ -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) { +} diff --git a/rest-client-test/src/main/java/com/ankurm/restclienttest/EmployeeAuditLogger.java b/rest-client-test/src/main/java/com/ankurm/restclienttest/EmployeeAuditLogger.java new file mode 100644 index 0000000..24264f8 --- /dev/null +++ b/rest-client-test/src/main/java/com/ankurm/restclienttest/EmployeeAuditLogger.java @@ -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); +} diff --git a/rest-client-test/src/main/java/com/ankurm/restclienttest/EmployeeAuditLoggerImpl.java b/rest-client-test/src/main/java/com/ankurm/restclienttest/EmployeeAuditLoggerImpl.java new file mode 100644 index 0000000..909b9b6 --- /dev/null +++ b/rest-client-test/src/main/java/com/ankurm/restclienttest/EmployeeAuditLoggerImpl.java @@ -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); + } +} diff --git a/rest-client-test/src/main/java/com/ankurm/restclienttest/EmployeeClient.java b/rest-client-test/src/main/java/com/ankurm/restclienttest/EmployeeClient.java new file mode 100644 index 0000000..e4bae92 --- /dev/null +++ b/rest-client-test/src/main/java/com/ankurm/restclienttest/EmployeeClient.java @@ -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. + * + *

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).

+ */ +@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; + } +} diff --git a/rest-client-test/src/main/java/com/ankurm/restclienttest/EmployeeHttpInterface.java b/rest-client-test/src/main/java/com/ankurm/restclienttest/EmployeeHttpInterface.java new file mode 100644 index 0000000..1c0faaa --- /dev/null +++ b/rest-client-test/src/main/java/com/ankurm/restclienttest/EmployeeHttpInterface.java @@ -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); +} diff --git a/rest-client-test/src/main/java/com/ankurm/restclienttest/EmployeeTemplateClient.java b/rest-client-test/src/main/java/com/ankurm/restclienttest/EmployeeTemplateClient.java new file mode 100644 index 0000000..4e9c239 --- /dev/null +++ b/rest-client-test/src/main/java/com/ankurm/restclienttest/EmployeeTemplateClient.java @@ -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); + } +} diff --git a/rest-client-test/src/main/java/com/ankurm/restclienttest/RestClientTestApplication.java b/rest-client-test/src/main/java/com/ankurm/restclienttest/RestClientTestApplication.java new file mode 100644 index 0000000..8e09990 --- /dev/null +++ b/rest-client-test/src/main/java/com/ankurm/restclienttest/RestClientTestApplication.java @@ -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); + } +} diff --git a/rest-client-test/src/test/java/com/ankurm/restclienttest/Capture.java b/rest-client-test/src/test/java/com/ankurm/restclienttest/Capture.java new file mode 100644 index 0000000..1e9c855 --- /dev/null +++ b/rest-client-test/src/test/java/com/ankurm/restclienttest/Capture.java @@ -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/}, 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); + } + } +} diff --git a/rest-client-test/src/test/java/com/ankurm/restclienttest/ErrorScenarioTest.java b/rest-client-test/src/test/java/com/ankurm/restclienttest/ErrorScenarioTest.java new file mode 100644 index 0000000..dfd7941 --- /dev/null +++ b/rest-client-test/src/test/java/com/ankurm/restclienttest/ErrorScenarioTest.java @@ -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()); + } +} diff --git a/rest-client-test/src/test/java/com/ankurm/restclienttest/HttpInterfaceClientTest.java b/rest-client-test/src/test/java/com/ankurm/restclienttest/HttpInterfaceClientTest.java new file mode 100644 index 0000000..848a627 --- /dev/null +++ b/rest-client-test/src/test/java/com/ankurm/restclienttest/HttpInterfaceClientTest.java @@ -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", "daniel@example.com"); + 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); + } +} diff --git a/rest-client-test/src/test/java/com/ankurm/restclienttest/RestClientEmployeeTest.java b/rest-client-test/src/test/java/com/ankurm/restclienttest/RestClientEmployeeTest.java new file mode 100644 index 0000000..884ca2b --- /dev/null +++ b/rest-client-test/src/test/java/com/ankurm/restclienttest/RestClientEmployeeTest.java @@ -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", "ankur@example.com"); + 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"))); + } +} diff --git a/rest-client-test/src/test/java/com/ankurm/restclienttest/RestTemplateEmployeeTest.java b/rest-client-test/src/test/java/com/ankurm/restclienttest/RestTemplateEmployeeTest.java new file mode 100644 index 0000000..99f1117 --- /dev/null +++ b/rest-client-test/src/test/java/com/ankurm/restclienttest/RestTemplateEmployeeTest.java @@ -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", "priya@example.com"); + 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); + } +}