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
+58
View File
@@ -0,0 +1,58 @@
# rest-test-client
Companion project for the article **[RestTestClient in Spring Framework 7: One Client for Every Kind of REST Test](https://ankurm.com/)** on **[ankurm.com](https://ankurm.com)**.
Every console block quoted in the article came out of `docs/output/`. These are real `mvn test`
and `curl` transcripts, not retyped output — including four real, captured failures (two
different ways to get `TestRestTemplate` wrong, one way to get `RestTestClient` wrong, and a
`javap` dump proving a real API gap) that are as much the point of the post as the passing tests
are.
There is deliberately **no `docs/NN-topic.md` chapter folder**: the deeper material lives in
collapsible "going deeper" sections inside the article itself, next to the paragraph each one
extends. `docs/output/` (captured transcripts) is the one thing that still lives under `docs/`.
## Versions
| | |
|---|---|
| Spring Boot | 4.1.1 |
| Spring Framework | 7.0.9 (GA 2025-11-13) |
| JDK | 25 (GraalVM CE 25.4.4.1.1) |
| Maven | 3.9 |
## Quickstart
```bash
export JAVA_HOME=/path/to/jdk-25
mvn test # runs everything below; no Docker, no running server required to start it
```
## What's demonstrated
| Area | Where |
|---|---|
| `bindToController`: the fastest binding mode, no Spring context at all | `PersonControllerUnitTest.java` |
| `bindTo(mockMvc)` inside `@WebMvcTest`: real MVC dispatch, mocked collaborator | `PersonMockMvcSliceTest.java` |
| `bindToApplicationContext`: the full context, no server, plus the AssertJ-style `RestTestClientResponse` | `PersonApplicationContextTest.java` |
| `bindToServer` with `@AutoConfigureRestTestClient`: a real embedded Tomcat on a real port | `PersonEndToEndTest.java` |
| `RestTestClient`, `TestRestTemplate`, and `@WebMvcTest` all moved into new Spring Boot 4 modules — confirmed by unzipping the real jars | `pom.xml`, `docs/output/02`, `docs/output/03`, `docs/output/04` |
| `TestRestTemplate` is not deprecated, just relocated and no longer free: `@AutoConfigureTestRestTemplate` migrates existing tests unchanged | `TestRestTemplateMigrationTest.java` |
| The default HTTP client Boot detects for `bindToServer` (`java.net.http.HttpClient`) transparently decodes gzip and hides `Content-Encoding` from you — a real surprise, verified against `curl` and against the older `SimpleClientHttpRequestFactory` | `PersonEndToEndTest.java`, `HttpClientChoiceComparisonTest.java` |
| `RestTestClient` genuinely has no multipart support yet — confirmed with `javap` against the real class files, not a blog post's claim | `docs/output/08-no-multipart-method-javap.txt` |
## Captured output
| File | What it shows |
|---|---|
| `01-full-test-run-passing.txt` | the real, full `mvn clean test` run: all 11 tests passing across 6 classes |
| `02-testresttemplate-missing-restclient-dependency.txt` | `@AutoConfigureTestRestTemplate` present, but `spring-boot-starter-restclient` missing: a `NoClassDefFoundError` on `RestTemplateBuilder`, not a clean "bean not found" |
| `03-resttestclient-not-autoconfigured.txt` | `@AutoConfigureRestTestClient` removed: a clean `NoSuchBeanDefinitionException` for `RestTestClient` |
| `04-testresttemplate-not-autoconfigured.txt` | `@AutoConfigureTestRestTemplate` removed (with the restclient starter present this time): the same clean failure shape, for `TestRestTemplate` |
| `05-curl-sees-real-gzip.txt` | direct `curl` against the running app: Tomcat really does send `Content-Encoding: gzip` |
| `06-resttestclient-hides-the-gzip.txt` | the same request through `RestTestClient.bindToServer()`: no `Content-Encoding` header at all |
| `07-simplefactory-shows-the-real-header.txt` | the same request again, through the older `SimpleClientHttpRequestFactory`: `Content-Encoding: gzip` is visible |
| `08-no-multipart-method-javap.txt` | `javap` against the real `RestTestClient` class files: no `multipart()` method anywhere in the chain |
Nothing here needs Docker or a pre-running server — `mvn test` starts and stops everything it
needs, including the one test class that opens a real socket.
@@ -0,0 +1,15 @@
$ mvn clean test
[INFO] Running com.ankurm.resttestclient.HttpClientChoiceComparisonTest
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 7.285 s -- in com.ankurm.resttestclient.HttpClientChoiceComparisonTest
[INFO] Running com.ankurm.resttestclient.PersonEndToEndTest
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.177 s -- in com.ankurm.resttestclient.PersonEndToEndTest
[INFO] Running com.ankurm.resttestclient.PersonApplicationContextTest
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.841 s -- in com.ankurm.resttestclient.PersonApplicationContextTest
[INFO] Running com.ankurm.resttestclient.TestRestTemplateMigrationTest
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.802 s -- in com.ankurm.resttestclient.TestRestTemplateMigrationTest
[INFO] Running com.ankurm.resttestclient.PersonControllerUnitTest
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.537 s -- in com.ankurm.resttestclient.PersonControllerUnitTest
[INFO] Running com.ankurm.resttestclient.PersonMockMvcSliceTest
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.259 s -- in com.ankurm.resttestclient.PersonMockMvcSliceTest
[INFO] Tests run: 11, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
@@ -0,0 +1,56 @@
$ mvn test -Dtest=TestRestTemplateMigrationTest
# captured with spring-boot-starter-restclient removed from pom.xml: @AutoConfigureTestRestTemplate
# is present, but TestRestTemplateTestAutoConfiguration itself cannot even be inspected, because
# TestRestTemplateBuilder's bean method signature needs org.springframework.boot.restclient.RestTemplateBuilder
# on the classpath, and nothing else in this module pulls that class in.
[ERROR] Tests run: 1, Failures: 0, Errors: 1, Skipped: 0, Time elapsed: 0.401 s <<< FAILURE! -- in com.ankurm.resttestclient.TestRestTemplateMigrationTest
[ERROR] com.ankurm.resttestclient.TestRestTemplateMigrationTest.theOldApiStillWorksWithOneAnnotationAdded -- Time elapsed: 0.008 s <<< ERROR!
java.lang.IllegalStateException: Failed to load ApplicationContext for [WebMergedContextConfiguration@4b2acb12 testClass = com.ankurm.resttestclient.TestRestTemplateMigrationTest, ...]
at org.springframework.test.context.cache.DefaultCacheAwareContextLoaderDelegate.lambda$loadContext$0(DefaultCacheAwareContextLoaderDelegate.java:195)
at org.springframework.test.context.cache.DefaultContextCache.put(DefaultContextCache.java:214)
at org.springframework.test.context.cache.DefaultCacheAwareContextLoaderDelegate.loadContext(DefaultCacheAwareContextLoaderDelegate.java:160)
at org.springframework.test.context.support.DefaultTestContext.getApplicationContext(DefaultTestContext.java:128)
at org.springframework.test.context.support.DependencyInjectionTestExecutionListener.injectDependencies(DependencyInjectionTestExecutionListener.java:156)
at org.springframework.test.context.support.DependencyInjectionTestExecutionListener.prepareTestInstance(DependencyInjectionTestExecutionListener.java:111)
at org.springframework.test.context.TestContextManager.prepareTestInstance(TestContextManager.java:260)
at org.springframework.test.context.junit.jupiter.SpringExtension.postProcessTestInstance(SpringExtension.java:242)
Caused by: java.lang.IllegalStateException: Error processing condition on org.springframework.boot.resttestclient.autoconfigure.TestRestTemplateTestAutoConfiguration.testRestTemplate
at org.springframework.boot.autoconfigure.condition.SpringBootCondition.matches(SpringBootCondition.java:60)
at org.springframework.context.annotation.ConditionEvaluator.shouldSkip(ConditionEvaluator.java:100)
at org.springframework.context.annotation.ConfigurationClassBeanDefinitionReader.loadBeanDefinitionsForBeanMethod(ConfigurationClassBeanDefinitionReader.java:196)
at org.springframework.context.annotation.ConfigurationClassBeanDefinitionReader.loadBeanDefinitionsForConfigurationClass(ConfigurationClassBeanDefinitionReader.java:148)
at org.springframework.context.annotation.ConfigurationClassPostProcessor.processConfigBeanDefinitions(ConfigurationClassPostProcessor.java:464)
at org.springframework.context.annotation.ConfigurationClassPostProcessor.postProcessBeanDefinitionRegistry(ConfigurationClassPostProcessor.java:316)
at org.springframework.context.support.PostProcessorRegistrationDelegate.invokeBeanDefinitionRegistryPostProcessors(PostProcessorRegistrationDelegate.java:349)
at org.springframework.context.support.AbstractApplicationContext.invokeBeanFactoryPostProcessors(AbstractApplicationContext.java:795)
at org.springframework.context.support.AbstractApplicationContext.refresh(AbstractApplicationContext.java:603)
at org.springframework.boot.web.server.servlet.context.ServletWebServerApplicationContext.refresh(ServletWebServerApplicationContext.java:143)
at org.springframework.boot.SpringApplication.refresh(SpringApplication.java:756)
at org.springframework.boot.SpringApplication.refreshContext(SpringApplication.java:445)
at org.springframework.boot.SpringApplication.run(SpringApplication.java:321)
Caused by: java.lang.IllegalStateException: @ConditionalOnMissingBean did not specify a bean using type, name or annotation and the attempt to deduce the bean's type failed
at org.springframework.boot.autoconfigure.condition.OnBeanCondition$Spec.validate(OnBeanCondition.java:657)
at org.springframework.boot.autoconfigure.condition.OnBeanCondition$Spec.<init>(OnBeanCondition.java:603)
at org.springframework.boot.autoconfigure.condition.OnBeanCondition.getMatchOutcome(OnBeanCondition.java:147)
at org.springframework.boot.autoconfigure.condition.SpringBootCondition.matches(SpringBootCondition.java:47)
Caused by: org.springframework.boot.autoconfigure.condition.OnBeanCondition$BeanTypeDeductionException: Failed to deduce bean type for org.springframework.boot.resttestclient.autoconfigure.TestRestTemplateTestAutoConfiguration.testRestTemplate
at org.springframework.boot.autoconfigure.condition.OnBeanCondition$Spec.deducedBeanTypeForBeanMethod(OnBeanCondition.java:686)
at org.springframework.boot.autoconfigure.condition.OnBeanCondition$Spec.deducedBeanType(OnBeanCondition.java:676)
at org.springframework.boot.autoconfigure.condition.OnBeanCondition$Spec.<init>(OnBeanCondition.java:596)
Caused by: java.lang.IllegalStateException: Failed to introspect Class [org.springframework.boot.resttestclient.autoconfigure.TestRestTemplateTestAutoConfiguration] from ClassLoader [jdk.internal.loader.ClassLoaders$AppClassLoader@c387f44]
at org.springframework.util.ReflectionUtils.getDeclaredMethods(ReflectionUtils.java:483)
at org.springframework.util.ReflectionUtils.findMethod(ReflectionUtils.java:240)
at org.springframework.util.ReflectionUtils.findMethod(ReflectionUtils.java:221)
at org.springframework.boot.autoconfigure.condition.OnBeanCondition$Spec.findBeanMethod(OnBeanCondition.java:715)
at org.springframework.boot.autoconfigure.condition.OnBeanCondition$Spec.getMethodReturnType(OnBeanCondition.java:710)
at org.springframework.boot.autoconfigure.condition.OnBeanCondition$Spec.getReturnType(OnBeanCondition.java:694)
at org.springframework.boot.autoconfigure.condition.OnBeanCondition$Spec.deducedBeanTypeForBeanMethod(OnBeanCondition.java:683)
Caused by: java.lang.NoClassDefFoundError: org/springframework/boot/restclient/RestTemplateBuilder
at java.base/java.lang.Class.getDeclaredMethods0(Native Method)
at java.base/java.lang.Class.privateGetDeclaredMethods(Class.java:3010)
at java.base/java.lang.Class.getDeclaredMethods(Class.java:2329)
at org.springframework.util.ReflectionUtils.getDeclaredMethods(ReflectionUtils.java:465)
Caused by: java.lang.ClassNotFoundException: org.springframework.boot.restclient.RestTemplateBuilder
at java.base/jdk.internal.loader.BuiltinClassLoader.loadClass(BuiltinClassLoader.java:580)
at java.base/java.lang.ClassLoader.loadClass(ClassLoader.java:490)
@@ -0,0 +1,23 @@
$ mvn test -Dtest=PersonEndToEndTest
# captured with @AutoConfigureRestTestClient removed from PersonEndToEndTest: @SpringBootTest alone
# never gives you an injectable RestTestClient, unlike TestRestTemplate's old free-auto-configuration
# behavior in Spring Boot 3.
[ERROR] Tests run: 2, Failures: 0, Errors: 2, Skipped: 0, Time elapsed: 4.846 s <<< FAILURE! -- in com.ankurm.resttestclient.PersonEndToEndTest
[ERROR] com.ankurm.resttestclient.PersonEndToEndTest.theResponseIsCompressedOnTheWireButTheClientHidesIt -- Time elapsed: 0.016 s <<< ERROR!
org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'com.ankurm.resttestclient.PersonEndToEndTest': Unsatisfied dependency expressed through field 'client': No qualifying bean of type 'org.springframework.test.web.servlet.client.RestTestClient' available: expected at least 1 bean which qualifies as autowire candidate. Dependency annotations: {@org.springframework.beans.factory.annotation.Autowired(required=true)}
at org.springframework.beans.factory.annotation.AutowiredAnnotationBeanPostProcessor$AutowiredFieldElement.resolveFieldValue(AutowiredAnnotationBeanPostProcessor.java:767)
at org.springframework.beans.factory.annotation.AutowiredAnnotationBeanPostProcessor$AutowiredFieldElement.inject(AutowiredAnnotationBeanPostProcessor.java:748)
at org.springframework.beans.factory.annotation.InjectionMetadata.inject(InjectionMetadata.java:146)
at org.springframework.beans.factory.annotation.AutowiredAnnotationBeanPostProcessor.postProcessProperties(AutowiredAnnotationBeanPostProcessor.java:493)
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.populateBean(AbstractAutowireCapableBeanFactory.java:1444)
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.autowireBeanProperties(AbstractAutowireCapableBeanFactory.java:402)
at org.springframework.test.context.support.DependencyInjectionTestExecutionListener.injectDependencies(DependencyInjectionTestExecutionListener.java:157)
at org.springframework.test.context.support.DependencyInjectionTestExecutionListener.prepareTestInstance(DependencyInjectionTestExecutionListener.java:111)
at org.springframework.test.context.TestContextManager.prepareTestInstance(TestContextManager.java:260)
at org.springframework.test.context.junit.jupiter.SpringExtension.postProcessTestInstance(SpringExtension.java:242)
Caused by: org.springframework.beans.factory.NoSuchBeanDefinitionException: No qualifying bean of type 'org.springframework.test.web.servlet.client.RestTestClient' available: expected at least 1 bean which qualifies as autowire candidate. Dependency annotations: {@org.springframework.beans.factory.annotation.Autowired(required=true)}
at org.springframework.beans.factory.support.DefaultListableBeanFactory.raiseNoMatchingBeanFound(DefaultListableBeanFactory.java:2304)
at org.springframework.beans.factory.support.DefaultListableBeanFactory.doResolveDependency(DefaultListableBeanFactory.java:1732)
at org.springframework.beans.factory.support.DefaultListableBeanFactory.resolveDependency(DefaultListableBeanFactory.java:1658)
at org.springframework.beans.factory.annotation.AutowiredAnnotationBeanPostProcessor$AutowiredFieldElement.resolveFieldValue(AutowiredAnnotationBeanPostProcessor.java:764)
@@ -0,0 +1,25 @@
$ mvn test -Dtest=TestRestTemplateMigrationTest
# captured with @AutoConfigureTestRestTemplate removed, but spring-boot-starter-restclient still
# present on the classpath. A completely different, much cleaner failure than
# 02-testresttemplate-missing-restclient-dependency.txt: this time the class loads fine, the
# condition evaluates fine, and Spring just never created the bean, because nothing asked it to.
org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'com.ankurm.resttestclient.TestRestTemplateMigrationTest': Unsatisfied dependency expressed through field 'restTemplate': No qualifying bean of type 'org.springframework.boot.resttestclient.TestRestTemplate' available: expected at least 1 bean which qualifies as autowire candidate. Dependency annotations: {@org.springframework.beans.factory.annotation.Autowired(required=true)}
at org.springframework.beans.factory.annotation.AutowiredAnnotationBeanPostProcessor$AutowiredFieldElement.resolveFieldValue(AutowiredAnnotationBeanPostProcessor.java:767) ~[spring-beans-7.0.9.jar:7.0.9]
at org.springframework.beans.factory.annotation.AutowiredAnnotationBeanPostProcessor$AutowiredFieldElement.inject(AutowiredAnnotationBeanPostProcessor.java:748) ~[spring-beans-7.0.9.jar:7.0.9]
at org.springframework.beans.factory.annotation.InjectionMetadata.inject(InjectionMetadata.java:146) ~[spring-beans-7.0.9.jar:7.0.9]
at org.springframework.beans.factory.annotation.AutowiredAnnotationBeanPostProcessor.postProcessProperties(AutowiredAnnotationBeanPostProcessor.java:493) ~[spring-beans-7.0.9.jar:7.0.9]
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.populateBean(AbstractAutowireCapableBeanFactory.java:1444) ~[spring-beans-7.0.9.jar:7.0.9]
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.autowireBeanProperties(AbstractAutowireCapableBeanFactory.java:402) ~[spring-beans-7.0.9.jar:7.0.9]
at org.springframework.test.context.support.DependencyInjectionTestExecutionListener.injectDependencies(DependencyInjectionTestExecutionListener.java:157) ~[spring-test-7.0.9.jar:7.0.9]
at org.springframework.test.context.support.DependencyInjectionTestExecutionListener.prepareTestInstance(DependencyInjectionTestExecutionListener.java:111) ~[spring-test-7.0.9.jar:7.0.9]
at org.springframework.test.context.TestContextManager.prepareTestInstance(TestContextManager.java:260) ~[spring-test-7.0.9.jar:7.0.9]
at org.springframework.test.context.junit.jupiter.SpringExtension.postProcessTestInstance(SpringExtension.java:242) ~[spring-test-7.0.9.jar:7.0.9]
Caused by: org.springframework.beans.factory.NoSuchBeanDefinitionException: No qualifying bean of type 'org.springframework.boot.resttestclient.TestRestTemplate' available: expected at least 1 bean which qualifies as autowire candidate. Dependency annotations: {@org.springframework.beans.factory.annotation.Autowired(required=true)}
at org.springframework.beans.factory.support.DefaultListableBeanFactory.raiseNoMatchingBeanFound(DefaultListableBeanFactory.java:2304) ~[spring-beans-7.0.9.jar:7.0.9]
at org.springframework.beans.factory.support.DefaultListableBeanFactory.doResolveDependency(DefaultListableBeanFactory.java:1732) ~[spring-beans-7.0.9.jar:7.0.9]
at org.springframework.beans.factory.support.DefaultListableBeanFactory.resolveDependency(DefaultListableBeanFactory.java:1658) ~[spring-beans-7.0.9.jar:7.0.9]
at org.springframework.beans.factory.annotation.AutowiredAnnotationBeanPostProcessor$AutowiredFieldElement.resolveFieldValue(AutowiredAnnotationBeanPostProcessor.java:764) ~[spring-beans-7.0.9.jar:7.0.9]
... 88 common frames omitted
[ERROR] Tests run: 1, Failures: 0, Errors: 1, Skipped: 0, Time elapsed: 5.107 s <<< FAILURE! -- in com.ankurm.resttestclient.TestRestTemplateMigrationTest
@@ -0,0 +1,8 @@
$ java -jar target/rest-test-client.jar --server.port=18080 &
$ curl -s -D - -o /dev/null http://localhost:18080/api/persons -H "Accept-Encoding: gzip"
HTTP/1.1 200
vary: accept-encoding
Content-Encoding: gzip
Content-Type: application/json
Transfer-Encoding: chunked
Date: Thu, 08 Oct 2026 09:12:37 GMT
@@ -0,0 +1,9 @@
$ mvn test -Dtest=PersonEndToEndTest
[INFO] Running com.ankurm.resttestclient.PersonEndToEndTest
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 6.971 s -- in com.ankurm.resttestclient.PersonEndToEndTest
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0
# theResponseIsCompressedOnTheWireButTheClientHidesIt asserts Content-Encoding is ABSENT from the
# response RestTestClient.bindToServer() sees, against the same /api/persons endpoint
# 05-curl-sees-real-gzip.txt just proved really does send Content-Encoding: gzip. It passes: the
# default client hides it.
@@ -0,0 +1,12 @@
$ mvn test -Dtest=HttpClientChoiceComparisonTest
[INFO] Running com.ankurm.resttestclient.HttpClientChoiceComparisonTest
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 7.876 s -- in com.ankurm.resttestclient.HttpClientChoiceComparisonTest
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0
# Same real server, same request, two different ClientHttpRequestFactory implementations:
# theJdkHttpClientDecodesGzipAndHidesTheHeader asserts Content-Encoding is absent through
# JdkClientHttpRequestFactory (java.net.http.HttpClient) -- the one Boot's
# ClientHttpRequestFactoryBuilder.detect() actually picks on this classpath.
# theOldHttpUrlConnectionFactoryShowsTheRealHeader asserts Content-Encoding equals "gzip" through
# SimpleClientHttpRequestFactory (java.net.HttpURLConnection) instead. Both pass: the two clients
# genuinely disagree about what they show you for the identical bytes on the wire.
@@ -0,0 +1,48 @@
$ unzip -oq spring-test-7.0.9.jar "org/springframework/test/web/servlet/client/RestTestClient*.class"
$ javap -p org/springframework/test/web/servlet/client/RestTestClient.class
public interface org.springframework.test.web.servlet.client.RestTestClient {
public static final java.lang.String RESTTESTCLIENT_REQUEST_ID;
public abstract org.springframework.test.web.servlet.client.RestTestClient$RequestHeadersUriSpec<?> get();
public abstract org.springframework.test.web.servlet.client.RestTestClient$RequestHeadersUriSpec<?> head();
public abstract org.springframework.test.web.servlet.client.RestTestClient$RequestBodyUriSpec post();
public abstract org.springframework.test.web.servlet.client.RestTestClient$RequestBodyUriSpec put();
public abstract org.springframework.test.web.servlet.client.RestTestClient$RequestBodyUriSpec patch();
public abstract org.springframework.test.web.servlet.client.RestTestClient$RequestHeadersUriSpec<?> delete();
public abstract org.springframework.test.web.servlet.client.RestTestClient$RequestHeadersUriSpec<?> options();
public abstract org.springframework.test.web.servlet.client.RestTestClient$RequestBodyUriSpec method(org.springframework.http.HttpMethod);
public abstract <B extends org.springframework.test.web.servlet.client.RestTestClient$Builder<B>> org.springframework.test.web.servlet.client.RestTestClient$Builder<B> mutate();
public static org.springframework.test.web.servlet.client.RestTestClient$StandaloneSetupBuilder bindToController(java.lang.Object...);
public static org.springframework.test.web.servlet.client.RestTestClient$RouterFunctionSetupBuilder bindToRouterFunction(org.springframework.web.servlet.function.RouterFunction<?>...);
public static org.springframework.test.web.servlet.client.RestTestClient$WebAppContextSetupBuilder bindToApplicationContext(org.springframework.web.context.WebApplicationContext);
public static org.springframework.test.web.servlet.client.RestTestClient$Builder<?> bindTo(org.springframework.test.web.servlet.MockMvc);
public static org.springframework.test.web.servlet.client.RestTestClient$Builder<?> bindToServer();
public static org.springframework.test.web.servlet.client.RestTestClient$Builder<?> bindToServer(org.springframework.http.client.ClientHttpRequestFactory);
}
$ javap -p 'org/springframework/test/web/servlet/client/RestTestClient$RequestHeadersSpec.class'
public interface org.springframework.test.web.servlet.client.RestTestClient$RequestHeadersSpec<S extends org.springframework.test.web.servlet.client.RestTestClient$RequestHeadersSpec<S>> {
public abstract S accept(org.springframework.http.MediaType...);
public abstract S acceptCharset(java.nio.charset.Charset...);
public abstract S cookie(java.lang.String, java.lang.String);
public abstract S cookies(java.util.function.Consumer<org.springframework.util.MultiValueMap<java.lang.String, java.lang.String>>);
public abstract S ifModifiedSince(java.time.ZonedDateTime);
public abstract S ifNoneMatch(java.lang.String...);
public abstract S header(java.lang.String, java.lang.String...);
public abstract S headers(java.util.function.Consumer<org.springframework.http.HttpHeaders>);
public abstract S apiVersion(java.lang.Object);
public abstract S attribute(java.lang.String, java.lang.Object);
public abstract S attributes(java.util.function.Consumer<java.util.Map<java.lang.String, java.lang.Object>>);
public abstract org.springframework.test.web.servlet.client.RestTestClient$ResponseSpec exchange();
public abstract org.springframework.test.web.servlet.client.RestTestClient$ResponseSpec exchangeSuccessfully();
}
$ javap -p 'org/springframework/test/web/servlet/client/RestTestClient$RequestBodySpec.class'
public interface org.springframework.test.web.servlet.client.RestTestClient$RequestBodySpec extends org.springframework.test.web.servlet.client.RestTestClient$RequestHeadersSpec<org.springframework.test.web.servlet.client.RestTestClient$RequestBodySpec> {
public abstract org.springframework.test.web.servlet.client.RestTestClient$RequestBodySpec contentLength(long);
public abstract org.springframework.test.web.servlet.client.RestTestClient$RequestBodySpec contentType(org.springframework.http.MediaType);
public abstract org.springframework.test.web.servlet.client.RestTestClient$RequestHeadersSpec<?> body(java.lang.Object);
}
# No multipart() method and no MultiValueMap<String, Object> body builder anywhere in the chain --
# body(Object) is the only way to set a request body. Confirms the multipart limitation directly
# against the real class files instead of trusting a blog post's claim.
+91
View File
@@ -0,0 +1,91 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
<relativePath/>
</parent>
<groupId>com.ankurm</groupId>
<artifactId>rest-test-client</artifactId>
<version>1.0.0</version>
<name>rest-test-client</name>
<description>RestTestClient in Spring Framework 7: one client across bindToController, bindToMockMvc,
bindToApplicationContext and bindToServer, vs MockMvcTester, vs the relocated TestRestTemplate</description>
<packaging>jar</packaging>
<properties>
<java.version>25</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<!-- The app itself never calls RestTemplate. This is here only because
TestRestTemplateTestAutoConfiguration needs RestTemplateBuilder on the classpath to build
its bean: without it, @AutoConfigureTestRestTemplate fails the whole context with a
NoClassDefFoundError on org.springframework.boot.restclient.RestTemplateBuilder, not a
clean "bean not found." Confirmed by hitting that exact failure with this dependency left
out, then adding it back. -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-restclient</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- RestTestClient itself (org.springframework.test.web.servlet.client.RestTestClient) ships
inside Spring Framework's spring-test jar, already on the classpath via
spring-boot-starter-test above. What is NOT on that classpath: TestRestTemplate, and the
two test-slice annotations that wire either client into a @SpringBootTest context:
@AutoConfigureRestTestClient and @AutoConfigureTestRestTemplate. In Spring Boot 4 both of
those, plus TestRestTemplate itself, moved out of spring-boot-test into their own module,
confirmed by unzipping this jar: it is spring-boot-resttestclient, not the similarly
spelled spring-boot-restclient-test (that one backs the unrelated @RestClientTest slice
for testing outbound RestClient/RestTemplate callers). See the post. -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-resttestclient</artifactId>
<scope>test</scope>
</dependency>
<!-- @WebMvcTest itself moved too: it is no longer in spring-boot-test-autoconfigure.
Boot 4 split every web-stack-specific test slice into its own module alongside the
matching main module (spring-boot-webmvc / spring-boot-webmvc-test, spring-boot-webflux
/ spring-boot-webflux-test, and so on): confirmed the same way, by unzipping the
jar and finding WebMvcTest.class under org/springframework/boot/webmvc/test/autoconfigure. -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-webmvc-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<finalName>rest-test-client</finalName>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
@@ -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);
}
}