From 5e4d481c0271a3de80b650f2d214df4379c6d9e5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 09:16:27 +0000 Subject: [PATCH] Add rest-test-client module: RestTestClient across all four binding modes, TestRestTemplate relocation, gzip-hiding HTTP client surprise (post #44) --- README.md | 15 ++- rest-test-client/README.md | 58 ++++++++++++ .../docs/output/01-full-test-run-passing.txt | 15 +++ ...template-missing-restclient-dependency.txt | 56 ++++++++++++ .../03-resttestclient-not-autoconfigured.txt | 23 +++++ ...04-testresttemplate-not-autoconfigured.txt | 25 +++++ .../docs/output/05-curl-sees-real-gzip.txt | 8 ++ .../06-resttestclient-hides-the-gzip.txt | 9 ++ ...07-simplefactory-shows-the-real-header.txt | 12 +++ .../output/08-no-multipart-method-javap.txt | 48 ++++++++++ rest-test-client/pom.xml | 91 +++++++++++++++++++ .../com/ankurm/resttestclient/Person.java | 16 ++++ .../resttestclient/PersonController.java | 44 +++++++++ .../ankurm/resttestclient/PersonService.java | 44 +++++++++ .../RestTestClientApplication.java | 12 +++ .../src/main/resources/application.yml | 10 ++ .../HttpClientChoiceComparisonTest.java | 52 +++++++++++ .../PersonApplicationContextTest.java | 59 ++++++++++++ .../PersonControllerUnitTest.java | 46 ++++++++++ .../resttestclient/PersonEndToEndTest.java | 58 ++++++++++++ .../PersonMockMvcSliceTest.java | 59 ++++++++++++ .../TestRestTemplateMigrationTest.java | 40 ++++++++ 22 files changed, 795 insertions(+), 5 deletions(-) create mode 100644 rest-test-client/README.md create mode 100644 rest-test-client/docs/output/01-full-test-run-passing.txt create mode 100644 rest-test-client/docs/output/02-testresttemplate-missing-restclient-dependency.txt create mode 100644 rest-test-client/docs/output/03-resttestclient-not-autoconfigured.txt create mode 100644 rest-test-client/docs/output/04-testresttemplate-not-autoconfigured.txt create mode 100644 rest-test-client/docs/output/05-curl-sees-real-gzip.txt create mode 100644 rest-test-client/docs/output/06-resttestclient-hides-the-gzip.txt create mode 100644 rest-test-client/docs/output/07-simplefactory-shows-the-real-header.txt create mode 100644 rest-test-client/docs/output/08-no-multipart-method-javap.txt create mode 100644 rest-test-client/pom.xml create mode 100644 rest-test-client/src/main/java/com/ankurm/resttestclient/Person.java create mode 100644 rest-test-client/src/main/java/com/ankurm/resttestclient/PersonController.java create mode 100644 rest-test-client/src/main/java/com/ankurm/resttestclient/PersonService.java create mode 100644 rest-test-client/src/main/java/com/ankurm/resttestclient/RestTestClientApplication.java create mode 100644 rest-test-client/src/main/resources/application.yml create mode 100644 rest-test-client/src/test/java/com/ankurm/resttestclient/HttpClientChoiceComparisonTest.java create mode 100644 rest-test-client/src/test/java/com/ankurm/resttestclient/PersonApplicationContextTest.java create mode 100644 rest-test-client/src/test/java/com/ankurm/resttestclient/PersonControllerUnitTest.java create mode 100644 rest-test-client/src/test/java/com/ankurm/resttestclient/PersonEndToEndTest.java create mode 100644 rest-test-client/src/test/java/com/ankurm/resttestclient/PersonMockMvcSliceTest.java create mode 100644 rest-test-client/src/test/java/com/ankurm/resttestclient/TestRestTemplateMigrationTest.java diff --git a/README.md b/README.md index 350c1c6..c90dd02 100644 --- a/README.md +++ b/README.md @@ -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` 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 `/post/` — `post.md` for the body and `meta.md` for the title, excerpt and diff --git a/rest-test-client/README.md b/rest-test-client/README.md new file mode 100644 index 0000000..f8ea72e --- /dev/null +++ b/rest-test-client/README.md @@ -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. diff --git a/rest-test-client/docs/output/01-full-test-run-passing.txt b/rest-test-client/docs/output/01-full-test-run-passing.txt new file mode 100644 index 0000000..d703f08 --- /dev/null +++ b/rest-test-client/docs/output/01-full-test-run-passing.txt @@ -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 diff --git a/rest-test-client/docs/output/02-testresttemplate-missing-restclient-dependency.txt b/rest-test-client/docs/output/02-testresttemplate-missing-restclient-dependency.txt new file mode 100644 index 0000000..75b2cb4 --- /dev/null +++ b/rest-test-client/docs/output/02-testresttemplate-missing-restclient-dependency.txt @@ -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.(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.(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) diff --git a/rest-test-client/docs/output/03-resttestclient-not-autoconfigured.txt b/rest-test-client/docs/output/03-resttestclient-not-autoconfigured.txt new file mode 100644 index 0000000..708baff --- /dev/null +++ b/rest-test-client/docs/output/03-resttestclient-not-autoconfigured.txt @@ -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) diff --git a/rest-test-client/docs/output/04-testresttemplate-not-autoconfigured.txt b/rest-test-client/docs/output/04-testresttemplate-not-autoconfigured.txt new file mode 100644 index 0000000..ad798b6 --- /dev/null +++ b/rest-test-client/docs/output/04-testresttemplate-not-autoconfigured.txt @@ -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 diff --git a/rest-test-client/docs/output/05-curl-sees-real-gzip.txt b/rest-test-client/docs/output/05-curl-sees-real-gzip.txt new file mode 100644 index 0000000..ef3d17f --- /dev/null +++ b/rest-test-client/docs/output/05-curl-sees-real-gzip.txt @@ -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 diff --git a/rest-test-client/docs/output/06-resttestclient-hides-the-gzip.txt b/rest-test-client/docs/output/06-resttestclient-hides-the-gzip.txt new file mode 100644 index 0000000..435b3fd --- /dev/null +++ b/rest-test-client/docs/output/06-resttestclient-hides-the-gzip.txt @@ -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. diff --git a/rest-test-client/docs/output/07-simplefactory-shows-the-real-header.txt b/rest-test-client/docs/output/07-simplefactory-shows-the-real-header.txt new file mode 100644 index 0000000..abe0664 --- /dev/null +++ b/rest-test-client/docs/output/07-simplefactory-shows-the-real-header.txt @@ -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. diff --git a/rest-test-client/docs/output/08-no-multipart-method-javap.txt b/rest-test-client/docs/output/08-no-multipart-method-javap.txt new file mode 100644 index 0000000..728b437 --- /dev/null +++ b/rest-test-client/docs/output/08-no-multipart-method-javap.txt @@ -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 > org.springframework.test.web.servlet.client.RestTestClient$Builder 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> { + 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>); + 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); + 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>); + 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 { + 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 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. diff --git a/rest-test-client/pom.xml b/rest-test-client/pom.xml new file mode 100644 index 0000000..5662d0b --- /dev/null +++ b/rest-test-client/pom.xml @@ -0,0 +1,91 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 4.1.1 + + + + com.ankurm + rest-test-client + 1.0.0 + rest-test-client + RestTestClient in Spring Framework 7: one client across bindToController, bindToMockMvc, + bindToApplicationContext and bindToServer, vs MockMvcTester, vs the relocated TestRestTemplate + jar + + + 25 + + + + + org.springframework.boot + spring-boot-starter-webmvc + + + + + org.springframework.boot + spring-boot-starter-restclient + test + + + org.springframework.boot + spring-boot-starter-validation + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + org.springframework.boot + spring-boot-resttestclient + test + + + + + org.springframework.boot + spring-boot-webmvc-test + test + + + + + rest-test-client + + + org.springframework.boot + spring-boot-maven-plugin + + + + + diff --git a/rest-test-client/src/main/java/com/ankurm/resttestclient/Person.java b/rest-test-client/src/main/java/com/ankurm/resttestclient/Person.java new file mode 100644 index 0000000..bbb9c7b --- /dev/null +++ b/rest-test-client/src/main/java/com/ankurm/resttestclient/Person.java @@ -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); + } +} diff --git a/rest-test-client/src/main/java/com/ankurm/resttestclient/PersonController.java b/rest-test-client/src/main/java/com/ankurm/resttestclient/PersonController.java new file mode 100644 index 0000000..fd2863a --- /dev/null +++ b/rest-test-client/src/main/java/com/ankurm/resttestclient/PersonController.java @@ -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 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 create(@Valid @RequestBody Person candidate) { + Person saved = people.save(candidate); + return ResponseEntity.created(URI.create("/api/persons/" + saved.id())).body(saved); + } +} diff --git a/rest-test-client/src/main/java/com/ankurm/resttestclient/PersonService.java b/rest-test-client/src/main/java/com/ankurm/resttestclient/PersonService.java new file mode 100644 index 0000000..6bfac58 --- /dev/null +++ b/rest-test-client/src/main/java/com/ankurm/resttestclient/PersonService.java @@ -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 people = new ConcurrentHashMap<>(); + private final AtomicLong ids = new AtomicLong(); + + public PersonService() { + save(new Person(null, "Jane Doe", "jane@example.com")); + save(new Person(null, "Jason Pollack", "jason@example.com")); + } + + public List findAll() { + return people.values().stream() + .sorted((a, b) -> Long.compare(a.id(), b.id())) + .toList(); + } + + public Optional 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; + } +} diff --git a/rest-test-client/src/main/java/com/ankurm/resttestclient/RestTestClientApplication.java b/rest-test-client/src/main/java/com/ankurm/resttestclient/RestTestClientApplication.java new file mode 100644 index 0000000..3b8ef4d --- /dev/null +++ b/rest-test-client/src/main/java/com/ankurm/resttestclient/RestTestClientApplication.java @@ -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); + } +} diff --git a/rest-test-client/src/main/resources/application.yml b/rest-test-client/src/main/resources/application.yml new file mode 100644 index 0000000..31c9d79 --- /dev/null +++ b/rest-test-client/src/main/resources/application.yml @@ -0,0 +1,10 @@ +spring: + mvc: + problemdetails: + enabled: true + +server: + compression: + enabled: true + mime-types: application/json + min-response-size: 1 diff --git a/rest-test-client/src/test/java/com/ankurm/resttestclient/HttpClientChoiceComparisonTest.java b/rest-test-client/src/test/java/com/ankurm/resttestclient/HttpClientChoiceComparisonTest.java new file mode 100644 index 0000000..2aafa60 --- /dev/null +++ b/rest-test-client/src/test/java/com/ankurm/resttestclient/HttpClientChoiceComparisonTest.java @@ -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"); + } +} diff --git a/rest-test-client/src/test/java/com/ankurm/resttestclient/PersonApplicationContextTest.java b/rest-test-client/src/test/java/com/ankurm/resttestclient/PersonApplicationContextTest.java new file mode 100644 index 0000000..509a672 --- /dev/null +++ b/rest-test-client/src/test/java/com/ankurm/resttestclient/PersonApplicationContextTest.java @@ -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. + * + *

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); + } +} diff --git a/rest-test-client/src/test/java/com/ankurm/resttestclient/PersonControllerUnitTest.java b/rest-test-client/src/test/java/com/ankurm/resttestclient/PersonControllerUnitTest.java new file mode 100644 index 0000000..d0e55d7 --- /dev/null +++ b/rest-test-client/src/test/java/com/ankurm/resttestclient/PersonControllerUnitTest.java @@ -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", "jane@example.com"), + new Person(2L, "Jason Pollack", "jason@example.com"))); + + 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(); + } +} diff --git a/rest-test-client/src/test/java/com/ankurm/resttestclient/PersonEndToEndTest.java b/rest-test-client/src/test/java/com/ankurm/resttestclient/PersonEndToEndTest.java new file mode 100644 index 0000000..d1736c2 --- /dev/null +++ b/rest-test-client/src/test/java/com/ankurm/resttestclient/PersonEndToEndTest.java @@ -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. + * + *

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"); + } +} diff --git a/rest-test-client/src/test/java/com/ankurm/resttestclient/PersonMockMvcSliceTest.java b/rest-test-client/src/test/java/com/ankurm/resttestclient/PersonMockMvcSliceTest.java new file mode 100644 index 0000000..92fa49f --- /dev/null +++ b/rest-test-client/src/test/java/com/ankurm/resttestclient/PersonMockMvcSliceTest.java @@ -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", "ada@example.com"))) + .willReturn(new Person(3L, "Ada Lovelace", "ada@example.com")); + + client.post().uri("/api/persons") + .contentType(org.springframework.http.MediaType.APPLICATION_JSON) + .body(new Person(null, "Ada Lovelace", "ada@example.com")) + .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(); + } +} diff --git a/rest-test-client/src/test/java/com/ankurm/resttestclient/TestRestTemplateMigrationTest.java b/rest-test-client/src/test/java/com/ankurm/resttestclient/TestRestTemplateMigrationTest.java new file mode 100644 index 0000000..02f5479 --- /dev/null +++ b/rest-test-client/src/test/java/com/ankurm/resttestclient/TestRestTemplateMigrationTest.java @@ -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. + * + *

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 response = restTemplate.getForEntity("/api/persons", Person[].class); + + assertThat(response.getStatusCode().is2xxSuccessful()).isTrue(); + assertThat(response.getBody()).hasSize(2); + } +}