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