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 |
|
||||
| [`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 |
|
||||
| [`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
|
||||
collapsible sections inside the articles themselves, and their captured output sits in a top-level `output/`
|
||||
directory instead of `docs/output/` (`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).
|
||||
`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. Where their captured output sits is not
|
||||
uniform, and is worth checking per module rather than assuming: `core-di`, `core-beans`,
|
||||
`custom-starter`, `core-events`, `multi-datasource`, `i18n` and `aot-cache` keep it in a
|
||||
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
|
||||
`<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