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:
Claude
2026-10-08 09:40:13 +00:00
parent 5851521420
commit 5e4d481c02
22 changed files with 795 additions and 5 deletions
@@ -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;
}
}
@@ -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
@@ -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");
}
}
@@ -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);
}
}
@@ -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();
}
}
@@ -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);
}
}