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
@@ -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);
}
}