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,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