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
+10 -5
View File
@@ -41,12 +41,17 @@ files.
| [`aot-cache/`](aot-cache) | [Faster Spring Boot Startup with the JDK AOT Cache (JEP 514/515): Benchmarks vs CDS and Native](https://ankurm.com/spring-boot-jdk-aot-cache-jep-514-515-startup-benchmarks/) | the JDK 25 AOT cache trained two ways, against a plain JVM, AppCDS, Spring's own AOT output and a GraalVM native image (ten interleaved rounds each), first-request latency, 24 cases where the cache silently stops applying (jar, timestamp, JVM build, ZGC, compact headers), `-XX:AOTMode=on`, Docker layer arithmetic | | [`aot-cache/`](aot-cache) | [Faster Spring Boot Startup with the JDK AOT Cache (JEP 514/515): Benchmarks vs CDS and Native](https://ankurm.com/spring-boot-jdk-aot-cache-jep-514-515-startup-benchmarks/) | the JDK 25 AOT cache trained two ways, against a plain JVM, AppCDS, Spring's own AOT output and a GraalVM native image (ten interleaved rounds each), first-request latency, 24 cases where the cache silently stops applying (jar, timestamp, JVM build, ZGC, compact headers), `-XX:AOTMode=on`, Docker layer arithmetic |
| [`kotlin/`](kotlin) | [Kotlin with Spring Boot 4.1: Null Safety, Coroutines, and What Java Developers Get Wrong](https://ankurm.com/) | Kotlin 2.3.21 (Boot 4.1.1's own managed version, not the latest GA), the real `@Configuration class may not be final` and `@Bean method must not be private or final` failures without the allopen/noarg `spring`/`jpa` compiler plugins, `suspend fun`/`Flow<T>` controller methods proven to still resume off a Tomcat thread under plain `spring-boot-starter-webmvc`, a JSpecify `@NullMarked` Java method that still NPEs at runtime because the contract isn't runtime-checked, and `TestRestTemplate` confirmed absent from Boot 4.1's `spring-boot-test` jar | | [`kotlin/`](kotlin) | [Kotlin with Spring Boot 4.1: Null Safety, Coroutines, and What Java Developers Get Wrong](https://ankurm.com/) | Kotlin 2.3.21 (Boot 4.1.1's own managed version, not the latest GA), the real `@Configuration class may not be final` and `@Bean method must not be private or final` failures without the allopen/noarg `spring`/`jpa` compiler plugins, `suspend fun`/`Flow<T>` controller methods proven to still resume off a Tomcat thread under plain `spring-boot-starter-webmvc`, a JSpecify `@NullMarked` Java method that still NPEs at runtime because the contract isn't runtime-checked, and `TestRestTemplate` confirmed absent from Boot 4.1's `spring-boot-test` jar |
| [`testing-no-containers/`](testing-no-containers) | [Testing Spring Boot 4 Without Containers: WireMock, MockWebServer and @MockitoBean](https://ankurm.com/) | `@MockBean`'s total removal proven with a real `javac` failure, `@MockitoBean`/`@MockitoSpyBean` context-cache behavior proven with real `org.springframework.test.context.cache` DEBUG log lines, WireMock fault injection against a real `RestClient`, a real `NoClassDefFoundError` from mixing `wiremock` with `wiremock-jetty12` (fixed with `wiremock-standalone`), and MockWebServer's `SocketEffect.ShutdownConnection` for genuinely severing a connection mid-response | | [`testing-no-containers/`](testing-no-containers) | [Testing Spring Boot 4 Without Containers: WireMock, MockWebServer and @MockitoBean](https://ankurm.com/) | `@MockBean`'s total removal proven with a real `javac` failure, `@MockitoBean`/`@MockitoSpyBean` context-cache behavior proven with real `org.springframework.test.context.cache` DEBUG log lines, WireMock fault injection against a real `RestClient`, a real `NoClassDefFoundError` from mixing `wiremock` with `wiremock-jetty12` (fixed with `wiremock-standalone`), and MockWebServer's `SocketEffect.ShutdownConnection` for genuinely severing a connection mid-response |
| [`rest-test-client/`](rest-test-client) | [RestTestClient in Spring Framework 7: Testing REST APIs End-to-End (vs MockMvcTester)](https://ankurm.com/resttestclient-spring-framework-7-end-to-end-testing/) | one fluent `RestTestClient` API proven across `bindToController`, `bindTo(mockMvc)`, `bindToApplicationContext` and `bindToServer`; `RestTestClient`, `TestRestTemplate` and `@WebMvcTest` confirmed relocated into new Spring Boot 4 modules by unzipping the real jars; two distinct real failures for the two now-separate `@AutoConfigureRestTestClient`/`@AutoConfigureTestRestTemplate` annotations; and the JDK `HttpClient` Boot picks for `bindToServer` caught transparently decoding gzip and hiding `Content-Encoding`, verified against `curl` and against the older `SimpleClientHttpRequestFactory` |
`core-di`, `core-beans`, `custom-starter`, `core-events`, `multi-datasource`, `i18n`, `aot-cache`, `kotlin`, `testing-no-containers` and `hexagonal` are the exception: they have **no `docs/` folder**. Their deeper material lives in `core-di`, `core-beans`, `custom-starter`, `core-events`, `multi-datasource`, `i18n`, `aot-cache`, `kotlin`, `testing-no-containers`, `rest-test-client` and `hexagonal` are the exception: they have **no `docs/NN-topic.md` chapter folder**. Their deeper material lives in
collapsible sections inside the articles themselves, and their captured output sits in a top-level `output/` collapsible sections inside the articles themselves. Where their captured output sits is not
directory instead of `docs/output/` (`hexagonal` is also the one multi-module reactor in this uniform, and is worth checking per module rather than assuming: `core-di`, `core-beans`,
repo, so its output sits one level deeper still, under each module: `core/output/`, `custom-starter`, `core-events`, `multi-datasource`, `i18n` and `aot-cache` keep it in a
`persistence-adapter/output/`, and so on). top-level `output/` directory (`hexagonal` is also the one multi-module reactor in this repo,
so its output sits one level deeper still, under each module: `core/output/`,
`persistence-adapter/output/`, and so on) — but `kotlin`, `testing-no-containers` and
`rest-test-client` still use `docs/output/`, same as the chaptered modules, just without the
`docs/NN-topic.md` chapters themselves.
Articles whose text is kept here rather than only on the blog have it under Articles whose text is kept here rather than only on the blog have it under
`<directory>/post/` — `post.md` for the body and `meta.md` for the title, excerpt and `<directory>/post/` — `post.md` for the body and `meta.md` for the title, excerpt and
+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);
}
}