Add rest-test-client module: RestTestClient across all four binding modes, TestRestTemplate relocation, gzip-hiding HTTP client surprise (post #44)
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
package com.ankurm.resttestclient;
|
||||
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
/**
|
||||
* The one domain type this module exists to move across the wire. See the article
|
||||
* for why {@code id} is nullable on the way in (the client never supplies it) but
|
||||
* never null on the way out.
|
||||
*/
|
||||
public record Person(Long id, @NotBlank String name, @Email @NotBlank String email) {
|
||||
|
||||
public Person withId(long newId) {
|
||||
return new Person(newId, name, email);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.ankurm.resttestclient;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.net.URI;
|
||||
import java.util.List;
|
||||
|
||||
import static org.springframework.http.HttpStatus.NOT_FOUND;
|
||||
|
||||
/**
|
||||
* The controller every test in this module exercises, through four different
|
||||
* {@code RestTestClient} binding modes. See the article's "smallest thing that works"
|
||||
* section for {@link #get(long)} and the validation section for {@link #create}.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/persons")
|
||||
public class PersonController {
|
||||
|
||||
private final PersonService people;
|
||||
|
||||
public PersonController(PersonService people) {
|
||||
this.people = people;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public List<Person> all() {
|
||||
return people.findAll();
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public Person get(@PathVariable long id) {
|
||||
return people.findById(id)
|
||||
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "No person with id " + id));
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<Person> create(@Valid @RequestBody Person candidate) {
|
||||
Person saved = people.save(candidate);
|
||||
return ResponseEntity.created(URI.create("/api/persons/" + saved.id())).body(saved);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.ankurm.resttestclient;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
/**
|
||||
* Deliberately not a repository: the point of this module is RestTestClient's binding
|
||||
* modes, not a persistence layer. An in-memory store is enough to make {@code bindToController}
|
||||
* (mock this directly) and {@code bindToApplicationContext} / {@code bindToServer} (run the
|
||||
* real bean) behave identically.
|
||||
*/
|
||||
@Service
|
||||
public class PersonService {
|
||||
|
||||
private final Map<Long, Person> people = new ConcurrentHashMap<>();
|
||||
private final AtomicLong ids = new AtomicLong();
|
||||
|
||||
public PersonService() {
|
||||
save(new Person(null, "Jane Doe", "[email protected]"));
|
||||
save(new Person(null, "Jason Pollack", "[email protected]"));
|
||||
}
|
||||
|
||||
public List<Person> findAll() {
|
||||
return people.values().stream()
|
||||
.sorted((a, b) -> Long.compare(a.id(), b.id()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
public Optional<Person> findById(long id) {
|
||||
return Optional.ofNullable(people.get(id));
|
||||
}
|
||||
|
||||
public Person save(Person incoming) {
|
||||
long id = ids.incrementAndGet();
|
||||
Person saved = incoming.withId(id);
|
||||
people.put(id, saved);
|
||||
return saved;
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package com.ankurm.resttestclient;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
@SpringBootApplication
|
||||
public class RestTestClientApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(RestTestClientApplication.class, args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
spring:
|
||||
mvc:
|
||||
problemdetails:
|
||||
enabled: true
|
||||
|
||||
server:
|
||||
compression:
|
||||
enabled: true
|
||||
mime-types: application/json
|
||||
min-response-size: 1
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
package com.ankurm.resttestclient;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.web.server.LocalServerPort;
|
||||
import org.springframework.http.client.JdkClientHttpRequestFactory;
|
||||
import org.springframework.http.client.SimpleClientHttpRequestFactory;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Not a RestTestClient test -- this is plain {@code RestClient} against the same running
|
||||
* server, isolating exactly one variable: which {@link org.springframework.http.client.ClientHttpRequestFactory}
|
||||
* handles the socket. It exists to prove the claim made in {@link PersonEndToEndTest}: that
|
||||
* "bind to a real server" does not by itself guarantee you can observe real HTTP-level
|
||||
* behavior, because the client you get by default may have already undone it for you.
|
||||
*/
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
||||
class HttpClientChoiceComparisonTest {
|
||||
|
||||
@LocalServerPort
|
||||
int port;
|
||||
|
||||
@Test
|
||||
void theJdkHttpClientDecodesGzipAndHidesTheHeader() {
|
||||
RestClient client = RestClient.builder()
|
||||
.requestFactory(new JdkClientHttpRequestFactory())
|
||||
.baseUrl("http://localhost:" + port)
|
||||
.build();
|
||||
|
||||
Boolean hasContentEncoding = client.get().uri("/api/persons")
|
||||
.header("Accept-Encoding", "gzip")
|
||||
.exchange((request, response) -> response.getHeaders().get("Content-Encoding") != null);
|
||||
|
||||
assertThat(hasContentEncoding).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void theOldHttpUrlConnectionFactoryShowsTheRealHeader() {
|
||||
RestClient client = RestClient.builder()
|
||||
.requestFactory(new SimpleClientHttpRequestFactory())
|
||||
.baseUrl("http://localhost:" + port)
|
||||
.build();
|
||||
|
||||
String contentEncoding = client.get().uri("/api/persons")
|
||||
.header("Accept-Encoding", "gzip")
|
||||
.exchange((request, response) -> response.getHeaders().getFirst("Content-Encoding"));
|
||||
|
||||
assertThat(contentEncoding).isEqualTo("gzip");
|
||||
}
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
package com.ankurm.resttestclient;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.web.servlet.client.RestTestClient;
|
||||
import org.springframework.test.web.servlet.client.assertj.RestTestClientResponse;
|
||||
import org.springframework.web.context.WebApplicationContext;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* The full application context -- real {@link PersonService} bean, real validation, real
|
||||
* exception handling -- but still no servlet container and no open port. {@code
|
||||
* bindToApplicationContext} discovers the {@code DispatcherServlet} infrastructure already
|
||||
* registered in the context and dispatches to it with mock request/response objects, the
|
||||
* same mechanism {@code @SpringBootTest(webEnvironment = MOCK)} has always used for {@code
|
||||
* MockMvc} -- this is that mechanism wearing RestTestClient's fluent API instead.
|
||||
*
|
||||
* <p>This test also shows the second way to read a response: instead of the built-in
|
||||
* {@code expectStatus()}/{@code expectBody()} chain, wrap the exchange in a {@link
|
||||
* RestTestClientResponse} and use ordinary AssertJ assertions.
|
||||
*/
|
||||
@SpringBootTest // webEnvironment defaults to MOCK: a WebApplicationContext, no server
|
||||
class PersonApplicationContextTest {
|
||||
|
||||
@Autowired
|
||||
WebApplicationContext context;
|
||||
|
||||
RestTestClient client;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
client = RestTestClient.bindToApplicationContext(context).build();
|
||||
}
|
||||
|
||||
@Test
|
||||
void theRealServiceSeedsTwoPeopleOnStartup() {
|
||||
client.get().uri("/api/persons")
|
||||
.exchange()
|
||||
.expectStatus().isOk()
|
||||
.expectBody()
|
||||
.jsonPath("$.length()").isEqualTo(2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void assertJStyleAssertionsWorkOnTheSameExchange() {
|
||||
RestTestClient.ResponseSpec spec = client.get().uri("/api/persons/1")
|
||||
.accept(MediaType.APPLICATION_JSON)
|
||||
.exchange();
|
||||
|
||||
RestTestClientResponse response = RestTestClientResponse.from(spec);
|
||||
|
||||
assertThat(response).hasStatusOk();
|
||||
assertThat(response).hasContentTypeCompatibleWith(MediaType.APPLICATION_JSON);
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
package com.ankurm.resttestclient;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.web.servlet.client.RestTestClient;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.mockito.BDDMockito.given;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
/**
|
||||
* The fastest binding mode: no Spring context at all. {@code bindToController} instantiates
|
||||
* the controller directly and routes requests to it in-process with mock request/response
|
||||
* objects -- there is no DispatcherServlet, no handler mapping lookup, no application
|
||||
* context startup. A plain {@code mock(PersonService.class)} stands in for the collaborator,
|
||||
* same as any other Mockito unit test.
|
||||
*/
|
||||
class PersonControllerUnitTest {
|
||||
|
||||
private final PersonService people = mock(PersonService.class);
|
||||
private final RestTestClient client =
|
||||
RestTestClient.bindToController(new PersonController(people)).build();
|
||||
|
||||
@Test
|
||||
void listsPeopleFromTheMockedService() {
|
||||
given(people.findAll()).willReturn(List.of(
|
||||
new Person(1L, "Jane Doe", "[email protected]"),
|
||||
new Person(2L, "Jason Pollack", "[email protected]")));
|
||||
|
||||
client.get().uri("/api/persons")
|
||||
.exchange()
|
||||
.expectStatus().isOk()
|
||||
.expectBody()
|
||||
.jsonPath("$[0].name").isEqualTo("Jane Doe")
|
||||
.jsonPath("$[1].name").isEqualTo("Jason Pollack");
|
||||
}
|
||||
|
||||
@Test
|
||||
void aMissingPersonSurfacesAs404() {
|
||||
given(people.findById(99L)).willReturn(java.util.Optional.empty());
|
||||
|
||||
client.get().uri("/api/persons/99")
|
||||
.exchange()
|
||||
.expectStatus().isNotFound();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.ankurm.resttestclient;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureRestTestClient;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.test.web.servlet.client.RestTestClient;
|
||||
|
||||
/**
|
||||
* The only binding mode that starts a real embedded Tomcat on a real port and sends real
|
||||
* bytes over a real socket. {@code @AutoConfigureRestTestClient} is what makes a {@code
|
||||
* RestTestClient} injectable here at all -- Spring Boot 4 no longer auto-configures either
|
||||
* test client for you, unlike {@code TestRestTemplate}'s old behavior (see {@link
|
||||
* TestRestTemplateMigrationTest} for that failure captured directly). With the annotation
|
||||
* present, the injected client is pre-wired with the random port's base URL.
|
||||
*
|
||||
* <p>Response compression looked, at first, like the clearest possible example of something
|
||||
* only real end-to-end HTTP can show: {@code docs/output/04-curl-sees-real-gzip.txt} is a
|
||||
* direct {@code curl} against this same endpoint, proving Tomcat really does compress it.
|
||||
* But {@code docs/output/05-resttestclient-hides-the-gzip.txt} captures the surprise:
|
||||
* {@link #theResponseIsCompressedOnTheWireButTheClientHidesIt()} below sees no {@code
|
||||
* Content-Encoding} header at all. The reason is in the test's own Javadoc, and it is the
|
||||
* same {@code ClientHttpRequestFactoryBuilder.detect()} mechanism from the companion post on
|
||||
* testing without containers: the JDK's own {@code java.net.http.HttpClient}, which is what
|
||||
* gets detected and wired in here, decodes gzip transparently and never shows the caller the
|
||||
* header that said it happened. A {@code RestClient} built on the older
|
||||
* {@code SimpleClientHttpRequestFactory} (java.net.HttpURLConnection) does not do this --
|
||||
* {@code docs/output/06-simplefactory-shows-the-real-header.txt} is that same request through
|
||||
* that factory instead, with {@code Content-Encoding: gzip} intact. So "bind to a real
|
||||
* server" buys you real HTTP, but which HTTP client answers still decides what you can
|
||||
* actually observe about it.
|
||||
*/
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
||||
@AutoConfigureRestTestClient
|
||||
class PersonEndToEndTest {
|
||||
|
||||
@Autowired
|
||||
RestTestClient client;
|
||||
|
||||
@Test
|
||||
void theResponseIsCompressedOnTheWireButTheClientHidesIt() {
|
||||
client.get().uri("/api/persons")
|
||||
.header("Accept-Encoding", "gzip")
|
||||
.exchange()
|
||||
.expectStatus().isOk()
|
||||
.expectHeader().valueEquals("Vary", "accept-encoding")
|
||||
.expectHeader().doesNotExist("Content-Encoding");
|
||||
}
|
||||
|
||||
@Test
|
||||
void theFullRoundTripStillWorks() {
|
||||
client.get().uri("/api/persons/2")
|
||||
.exchange()
|
||||
.expectStatus().isOk()
|
||||
.expectBody()
|
||||
.jsonPath("$.name").isEqualTo("Jason Pollack");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.ankurm.resttestclient;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.client.RestTestClient;
|
||||
|
||||
import static org.mockito.BDDMockito.given;
|
||||
|
||||
/**
|
||||
* One layer up from {@link PersonControllerUnitTest}: a real (sliced) Spring MVC
|
||||
* context, so validation, exception handlers and argument resolvers all run for real --
|
||||
* but still no servlet container. {@code @WebMvcTest} wires a {@code MockMvc}; {@code
|
||||
* RestTestClient.bindTo(mockMvc)} gives that MockMvc the same fluent request/response API
|
||||
* the other three binding modes use, so the test method bodies barely differ from the
|
||||
* unit test's.
|
||||
*/
|
||||
@WebMvcTest(PersonController.class)
|
||||
class PersonMockMvcSliceTest {
|
||||
|
||||
@Autowired
|
||||
MockMvc mockMvc;
|
||||
|
||||
@MockitoBean
|
||||
PersonService people;
|
||||
|
||||
RestTestClient client;
|
||||
|
||||
@org.junit.jupiter.api.BeforeEach
|
||||
void setUp() {
|
||||
client = RestTestClient.bindTo(mockMvc).build();
|
||||
}
|
||||
|
||||
@Test
|
||||
void aValidPersonIsCreated() {
|
||||
given(people.save(new Person(null, "Ada Lovelace", "[email protected]")))
|
||||
.willReturn(new Person(3L, "Ada Lovelace", "[email protected]"));
|
||||
|
||||
client.post().uri("/api/persons")
|
||||
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
|
||||
.body(new Person(null, "Ada Lovelace", "[email protected]"))
|
||||
.exchange()
|
||||
.expectStatus().isCreated()
|
||||
.expectHeader().valueEquals("Location", "/api/persons/3")
|
||||
.expectBody()
|
||||
.jsonPath("$.id").isEqualTo(3);
|
||||
}
|
||||
|
||||
@Test
|
||||
void aBlankNameFailsValidationBeforeTheServiceIsEverCalled() {
|
||||
client.post().uri("/api/persons")
|
||||
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
|
||||
.body(new Person(null, "", "not-an-email"))
|
||||
.exchange()
|
||||
.expectStatus().isBadRequest();
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
package com.ankurm.resttestclient;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.resttestclient.TestRestTemplate;
|
||||
import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* {@code TestRestTemplate} is not deprecated and not gone -- it moved. In Spring Boot 3 it
|
||||
* shipped inside {@code spring-boot-test} and was auto-configured onto any {@code
|
||||
* @SpringBootTest(webEnvironment = RANDOM_PORT)} or {@code DEFINED_PORT} test for free. In
|
||||
* Spring Boot 4 both the class and its auto-configuration relocated to the new {@code
|
||||
* spring-boot-resttestclient} module, and the free auto-configuration is gone: without
|
||||
* {@code @AutoConfigureTestRestTemplate} the field below is never populated and the context
|
||||
* fails to start. {@code docs/output/03-testresttemplate-not-autoconfigured.txt} is that
|
||||
* exact failure, captured by deleting the annotation for one real run; see the post.
|
||||
*
|
||||
* <p>This is a genuinely separate decision from {@code RestTestClient}: a project with
|
||||
* existing {@code TestRestTemplate} tests can add this one annotation and keep them exactly
|
||||
* as they were, with no rewrite required.
|
||||
*/
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
||||
@AutoConfigureTestRestTemplate
|
||||
class TestRestTemplateMigrationTest {
|
||||
|
||||
@Autowired
|
||||
TestRestTemplate restTemplate;
|
||||
|
||||
@Test
|
||||
void theOldApiStillWorksWithOneAnnotationAdded() {
|
||||
ResponseEntity<Person[]> response = restTemplate.getForEntity("/api/persons", Person[].class);
|
||||
|
||||
assertThat(response.getStatusCode().is2xxSuccessful()).isTrue();
|
||||
assertThat(response.getBody()).hasSize(2);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user