diff --git a/README.md b/README.md index 9a3cfbc..3b2d29d 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ Runnable companion code for the testing posts on [ankurm.com](https://ankurm.com | [`assertj/`](assertj) | AssertJ Complete Guide: Soft Assertions, Recursive Comparison and Custom Assertions | AssertJ 3.27.7, JUnit 6.1.3, JDK 25 | | [`instancio/`](instancio) | Instancio: Generate Realistic Test Data Automatically in Java | Instancio 6.1.0, JUnit 6.1.3, JDK 25 | | [`gatling-spring-boot/`](gatling-spring-boot) | Load Testing Spring Boot with Gatling's Java DSL (and How It Compares to k6) | Gatling 3.16.0, Spring Boot 4.1.2, k6 v2.3.0, JDK 25 | +| [`spring-test-slices/`](spring-test-slices) | Spring Boot 4 Test Slices: @WebMvcTest, @JsonTest, @RestClientTest and @DataJpaTest Compared | Spring Boot 4.1.2, JDK 25 | Every module keeps the real output of its tests in `output/`. Each test writes the transcript and asserts the same facts, so the numbers in the posts fail the build if they stop being true. @@ -16,4 +17,5 @@ Every module keeps the real output of its tests in `output/`. Each test writes t cd mockito && ./scripts/run-all.sh cd ../junit-6-migration && ./scripts/run-all.sh cd ../assertj && ./scripts/run-all.sh +cd ../spring-test-slices && ./scripts/run-all.sh ``` diff --git a/spring-test-slices/README.md b/spring-test-slices/README.md new file mode 100644 index 0000000..fbb5df4 --- /dev/null +++ b/spring-test-slices/README.md @@ -0,0 +1,45 @@ +# spring-test-slices + +Companion code for the ankurm.com post *Spring Boot 4 Test Slices: @WebMvcTest, @JsonTest, @RestClientTest and @DataJpaTest Compared*. + +A small book-catalogue app (controller, service, JPA repository, a `RestClient` client for an ISBN service, a Jackson 3 +`@JacksonComponent`) and one test class per slice, plus the same app under a full `@SpringBootTest` as the baseline. +Every test writes the real output to `output/` and asserts the same facts, so the numbers in the post fail the build if +they stop being true. + +| Tool | Version | Source | +|---|---|---| +| Spring Boot | 4.1.2 | `maven-metadata.xml` (latest GA when this was written; 4.2.0 is not GA yet) | +| Spring Framework | 7.0.10 | resolved by the Boot BOM | +| Hibernate ORM | 7.4.13.Final | resolved by the Boot BOM | +| JDK | 25 | | + +## Run + +```bash +./scripts/run-all.sh # about a minute; regenerates everything in output/ +mvn test # just the suite +``` + +Startup times (`output/30-startup-times.txt`) vary by machine and by run: treat them as indicative. + +## Files + +- `src/main/java/lab/slices/` the app: `BookController`, `ApiExceptionHandler`, `BookService`, `BookRepository`, + `IsbnLookupClient`, `PriceJson` (`@JacksonComponent`), `AuditLogger` (a plain `@Component`, the canary) +- `src/test/java/lab/slices/` `WebMvcSliceTest`, `JsonSliceTest`, `RestClientSliceTest`, `DataJpaSliceTest`, `FullContextTest`, + `PackageMovesTest`, `OldImportsCompileTest`, `FailureCaptureTest`, `ZMatrixTest` +- `src/test/java/lab/slices/failing/` test classes that fail on purpose (tag `failure-demo`, excluded from the normal run; + `FailureCaptureTest` runs them through the JUnit Launcher and records the real exception) +- `src/broken/` Boot 3 style tests that no longer compile (not part of the Maven build; `OldImportsCompileTest` hands them to javac) +- `output/` captured real output of the last run + +| Output | What it shows | +|---|---| +| `01` to `06` | one transcript per slice, plus the full context: requests, responses, and which beans exist | +| `07`, `08` | the exceptions a missing mock, a missing scan and `@EnableJpaAuditing` produce | +| `09` | where every test annotation lives in Boot 4.1.2, asked of the classpath | +| `10` | javac's own errors for Boot 3 imports | +| `20` | the bean-presence table, one column per slice | +| `11`, `12` | which Jackson each JSON tester wraps; which test jars `spring-boot-starter-test` alone brings in | +| `30` | context startup time per slice (indicative) | diff --git a/spring-test-slices/output/01-webmvc-slice.txt b/spring-test-slices/output/01-webmvc-slice.txt new file mode 100644 index 0000000..cbed56e --- /dev/null +++ b/spring-test-slices/output/01-webmvc-slice.txt @@ -0,0 +1,19 @@ +@WebMvcTest(BookController.class) with @MockitoBean BookService + +GET /api/books/1 -> 200 {"isbn":"9780134685991","title":"Effective Java","author":"Joshua Bloch","price":"INR 499.00","published_on":"2018-01-06"} +GET /api/books/99 -> 404 {"detail":"No book with id 99","instance":"/api/books/99","status":404,"title":"Book not found"} + +beans in this context: + BookController (@RestController)=yes + ApiExceptionHandler (@ControllerAdvice)=yes + PriceJson (@JacksonComponent)=yes + MockMvc=yes + JsonMapper (Jackson 3)=yes + BookService (@Service)=yes + BookRepository (Spring Data)=no + EntityManagerFactory=no + DataSource=no + RestClient.Builder=no + IsbnLookupClient (@Component)=no + AuditLogger (@Component)=no + bean definitions=126 diff --git a/spring-test-slices/output/02-json-slice.txt b/spring-test-slices/output/02-json-slice.txt new file mode 100644 index 0000000..0d7a044 --- /dev/null +++ b/spring-test-slices/output/02-json-slice.txt @@ -0,0 +1,19 @@ +@JsonTest with JacksonTester + +write(view) -> {"isbn":"9780134685991","title":"Effective Java","author":"Joshua Bloch","price":"INR 499.00","published_on":"2018-01-06"} +parseObject(...).price() -> Price[currency=INR, amount=499.00] + +beans in this context: + BookController (@RestController)=no + ApiExceptionHandler (@ControllerAdvice)=no + PriceJson (@JacksonComponent)=yes + MockMvc=no + JsonMapper (Jackson 3)=yes + BookService (@Service)=no + BookRepository (Spring Data)=no + EntityManagerFactory=no + DataSource=no + RestClient.Builder=no + IsbnLookupClient (@Component)=no + AuditLogger (@Component)=no + bean definitions=35 diff --git a/spring-test-slices/output/03-restclient-slice.txt b/spring-test-slices/output/03-restclient-slice.txt new file mode 100644 index 0000000..98526ec --- /dev/null +++ b/spring-test-slices/output/03-restclient-slice.txt @@ -0,0 +1,19 @@ +@RestClientTest(IsbnLookupClient.class), base URL from application.properties + +lookup(9780134685991) -> Optional[IsbnInfo[isbn=9780134685991, title=Effective Java, author=Joshua Bloch, pages=412]] +lookup(0000000000) -> Optional.empty + +beans in this context: + BookController (@RestController)=no + ApiExceptionHandler (@ControllerAdvice)=no + PriceJson (@JacksonComponent)=yes + MockMvc=no + JsonMapper (Jackson 3)=yes + BookService (@Service)=no + BookRepository (Spring Data)=no + EntityManagerFactory=no + DataSource=no + RestClient.Builder=yes + IsbnLookupClient (@Component)=yes + AuditLogger (@Component)=no + bean definitions=53 diff --git a/spring-test-slices/output/04-datajpa-slice.txt b/spring-test-slices/output/04-datajpa-slice.txt new file mode 100644 index 0000000..288e6ea --- /dev/null +++ b/spring-test-slices/output/04-datajpa-slice.txt @@ -0,0 +1,20 @@ +@DataJpaTest + +findByAuthorIgnoreCaseOrderByTitle("ANN") -> [Apple, Zebra] +DataSource class -> EmbeddedDataSourceProxy +JDBC URL starts with jdbc:h2:mem: -> true + +beans in this context: + BookController (@RestController)=no + ApiExceptionHandler (@ControllerAdvice)=no + PriceJson (@JacksonComponent)=no + MockMvc=no + JsonMapper (Jackson 3)=no + BookService (@Service)=no + BookRepository (Spring Data)=yes + EntityManagerFactory=yes + DataSource=yes + RestClient.Builder=no + IsbnLookupClient (@Component)=no + AuditLogger (@Component)=no + bean definitions=80 diff --git a/spring-test-slices/output/05-datajpa-rollback.txt b/spring-test-slices/output/05-datajpa-rollback.txt new file mode 100644 index 0000000..dd1eef7 --- /dev/null +++ b/spring-test-slices/output/05-datajpa-rollback.txt @@ -0,0 +1,3 @@ +b_firstTestInsertsOneRow -> count after save = 1 +c_secondTestStillSeesOnlyItsOwnRow -> count after save = 1 +(the row from b_ was rolled back when its test ended) diff --git a/spring-test-slices/output/06-full-context.txt b/spring-test-slices/output/06-full-context.txt new file mode 100644 index 0000000..e3ee44a --- /dev/null +++ b/spring-test-slices/output/06-full-context.txt @@ -0,0 +1,19 @@ +@SpringBootTest + @AutoConfigureMockMvc, @MockitoBean IsbnLookupClient + +POST /api/books/import/9780134685991 -> 200 {"isbn":"9780134685991","title":"Effective Java","author":"Joshua Bloch","price":"INR 499.00","published_on":"2024-01-01"} +GET /api/books?author=joshua bloch -> 200 [{"isbn":"9780134685991","title":"Effective Java","author":"Joshua Bloch","price":"INR 499.00","published_on":"2024-01-01"}] + +beans in this context: + BookController (@RestController)=yes + ApiExceptionHandler (@ControllerAdvice)=yes + PriceJson (@JacksonComponent)=yes + MockMvc=yes + JsonMapper (Jackson 3)=yes + BookService (@Service)=yes + BookRepository (Spring Data)=yes + EntityManagerFactory=yes + DataSource=yes + RestClient.Builder=yes + IsbnLookupClient (@Component)=yes + AuditLogger (@Component)=yes + bean definitions=251 diff --git a/spring-test-slices/output/07-slice-failures.txt b/spring-test-slices/output/07-slice-failures.txt new file mode 100644 index 0000000..7aa49d6 --- /dev/null +++ b/spring-test-slices/output/07-slice-failures.txt @@ -0,0 +1,25 @@ +Each class in lab.slices.failing is run through the JUnit Launcher; this is what its exception says. +(The 'Failed to load ApplicationContext' line carries a long configuration dump with object hashes; it is cut at the first '['. + The module directory is shown as .) + +== MissingMockBeanFailure +exception chain: IllegalStateException -> UnsatisfiedDependencyException -> NoSuchBeanDefinitionException +top message : Failed to load ApplicationContext for +wiring failure : Error creating bean with name 'bookController' defined in file [/target/classes/lab/slices/BookController.class]: Unsatisfied dependency expressed through constructor parameter 0: No qualifying bean of type 'lab.slices.BookService' available: expected at least 1 bean which qualifies as autowire candidate. Dependency annotations: {} + +== ServiceInDataJpaFailure +exception chain: UnsatisfiedDependencyException -> NoSuchBeanDefinitionException +wiring failure : Error creating bean with name 'lab.slices.failing.ServiceInDataJpaFailure': Unsatisfied dependency expressed through field 'service': No qualifying bean of type 'lab.slices.BookService' available: expected at least 1 bean which qualifies as autowire candidate. Dependency annotations: {@org.springframework.beans.factory.annotation.Autowired(required=true)} + +== RepositoryInWebMvcFailure +exception chain: UnsatisfiedDependencyException -> NoSuchBeanDefinitionException +wiring failure : Error creating bean with name 'lab.slices.failing.RepositoryInWebMvcFailure': Unsatisfied dependency expressed through field 'repository': No qualifying bean of type 'lab.slices.BookRepository' available: expected at least 1 bean which qualifies as autowire candidate. Dependency annotations: {@org.springframework.beans.factory.annotation.Autowired(required=true)} + +== PlainComponentInJsonFailure +exception chain: UnsatisfiedDependencyException -> NoSuchBeanDefinitionException +wiring failure : Error creating bean with name 'lab.slices.failing.PlainComponentInJsonFailure': Unsatisfied dependency expressed through field 'audit': No qualifying bean of type 'lab.slices.AuditLogger' available: expected at least 1 bean which qualifies as autowire candidate. Dependency annotations: {@org.springframework.beans.factory.annotation.Autowired(required=true)} + +== ClientWithoutComponentsFailure +exception chain: UnsatisfiedDependencyException -> NoSuchBeanDefinitionException +wiring failure : Error creating bean with name 'lab.slices.failing.ClientWithoutComponentsFailure': Unsatisfied dependency expressed through field 'client': No qualifying bean of type 'lab.slices.IsbnLookupClient' available: expected at least 1 bean which qualifies as autowire candidate. Dependency annotations: {@org.springframework.beans.factory.annotation.Autowired(required=true)} + diff --git a/spring-test-slices/output/08-jpa-auditing-in-web-slice.txt b/spring-test-slices/output/08-jpa-auditing-in-web-slice.txt new file mode 100644 index 0000000..65c84c4 --- /dev/null +++ b/spring-test-slices/output/08-jpa-auditing-in-web-slice.txt @@ -0,0 +1,3 @@ +@WebMvcTest + @EnableJpaAuditing (via @Import), BookService mocked +exception chain: IllegalStateException -> BeanCreationException -> BeanCreationException -> IllegalArgumentException +root cause : JPA metamodel must not be empty diff --git a/spring-test-slices/output/09-package-moves.txt b/spring-test-slices/output/09-package-moves.txt new file mode 100644 index 0000000..08c8bb7 --- /dev/null +++ b/spring-test-slices/output/09-package-moves.txt @@ -0,0 +1,32 @@ +Class.forName on the Boot 4.1.2 test classpath + +@WebMvcTest + old: org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest -> NOT FOUND + new: org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest -> present +@AutoConfigureMockMvc + old: org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc -> NOT FOUND + new: org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc -> present +@DataJpaTest + old: org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest -> NOT FOUND + new: org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest -> present +TestEntityManager + old: org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager -> NOT FOUND + new: org.springframework.boot.jpa.test.autoconfigure.TestEntityManager -> present +@AutoConfigureTestDatabase + old: org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase -> NOT FOUND + new: org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase -> present +@RestClientTest + old: org.springframework.boot.test.autoconfigure.web.client.RestClientTest -> NOT FOUND + new: org.springframework.boot.restclient.test.autoconfigure.RestClientTest -> present +@JsonTest + old: org.springframework.boot.test.autoconfigure.json.JsonTest -> present + new: org.springframework.boot.test.autoconfigure.json.JsonTest -> present +@MockBean + old: org.springframework.boot.test.mock.mockito.MockBean -> NOT FOUND + new: org.springframework.test.context.bean.override.mockito.MockitoBean -> present +@JsonComponent + old: org.springframework.boot.jackson.JsonComponent -> NOT FOUND + new: org.springframework.boot.jackson.JacksonComponent -> present +@SpyBean + old: org.springframework.boot.test.mock.mockito.SpyBean -> NOT FOUND + new: org.springframework.test.context.bean.override.mockito.MockitoSpyBean -> present diff --git a/spring-test-slices/output/10-old-imports-compile-errors.txt b/spring-test-slices/output/10-old-imports-compile-errors.txt new file mode 100644 index 0000000..ef882f6 --- /dev/null +++ b/spring-test-slices/output/10-old-imports-compile-errors.txt @@ -0,0 +1,21 @@ +javac against the Boot 4.1.2 test classpath, sources from src/broken/ + +OldDataJpaTest.java:3: error: package org.springframework.boot.test.autoconfigure.orm.jpa does not exist +OldDataJpaTest.java:4: error: package org.springframework.boot.test.autoconfigure.orm.jpa does not exist +OldDataJpaTest.java:7: error: cannot find symbol + symbol: class DataJpaTest +OldDataJpaTest.java:9: error: cannot find symbol + symbol: class TestEntityManager + location: class lab.slices.OldDataJpaTest +OldRestClientTest.java:3: error: package org.springframework.boot.test.autoconfigure.web.client does not exist +OldRestClientTest.java:6: error: cannot find symbol + symbol: class RestClientTest +OldWebMvcTest.java:3: error: package org.springframework.boot.test.autoconfigure.web.servlet does not exist +OldWebMvcTest.java:4: error: package org.springframework.boot.test.mock.mockito does not exist +OldWebMvcTest.java:7: error: cannot find symbol + symbol: class WebMvcTest +OldWebMvcTest.java:9: error: cannot find symbol + symbol: class MockBean + location: class lab.slices.OldWebMvcTest + +10 errors diff --git a/spring-test-slices/output/11-json-testers.txt b/spring-test-slices/output/11-json-testers.txt new file mode 100644 index 0000000..9b941e1 --- /dev/null +++ b/spring-test-slices/output/11-json-testers.txt @@ -0,0 +1,4 @@ +Single-argument constructor of each tester in spring-boot-test 4.1.2 + +JacksonTester wraps tools.jackson.databind.json.JsonMapper +Jackson2Tester cannot be inspected: com.fasterxml.jackson.databind.ObjectMapper is not on the classpath diff --git a/spring-test-slices/output/12-starter-test-only-jars.txt b/spring-test-slices/output/12-starter-test-only-jars.txt new file mode 100644 index 0000000..fdc171f --- /dev/null +++ b/spring-test-slices/output/12-starter-test-only-jars.txt @@ -0,0 +1,5 @@ +Spring Boot 4.1.2: webmvc + data-jpa + restclient starters, and only spring-boot-starter-test for tests +spring-boot jars with 'test' in the name on that classpath: + spring-boot-starter-test-4.1.2.jar + spring-boot-test-4.1.2.jar + spring-boot-test-autoconfigure-4.1.2.jar diff --git a/spring-test-slices/output/20-slice-matrix.txt b/spring-test-slices/output/20-slice-matrix.txt new file mode 100644 index 0000000..7362154 --- /dev/null +++ b/spring-test-slices/output/20-slice-matrix.txt @@ -0,0 +1,14 @@ +bean WebMvcTest JsonTest RestClientTest DataJpaTest SpringBootTest +BookController (@RestController) yes no no no yes +ApiExceptionHandler (@ControllerAdvice) yes no no no yes +PriceJson (@JacksonComponent) yes yes yes no yes +MockMvc yes no no no yes +JsonMapper (Jackson 3) yes yes yes no yes +BookService (@Service) yes no no no yes +BookRepository (Spring Data) no no no yes yes +EntityManagerFactory no no no yes yes +DataSource no no no yes yes +RestClient.Builder no no yes no yes +IsbnLookupClient (@Component) no no yes no yes +AuditLogger (@Component) no no no no yes +bean definitions 126 35 53 80 251 diff --git a/spring-test-slices/output/30-startup-times.txt b/spring-test-slices/output/30-startup-times.txt new file mode 100644 index 0000000..674b08e --- /dev/null +++ b/spring-test-slices/output/30-startup-times.txt @@ -0,0 +1,7 @@ +Context startup time per test class (one class per mvn run; indicative, varies between runs) + +Started JsonSliceTest in 1.132 seconds +Started RestClientSliceTest in 1.521 seconds +Started WebMvcSliceTest in 2.619 seconds +Started DataJpaSliceTest in 3.173 seconds +Started FullContextTest in 4.344 seconds diff --git a/spring-test-slices/pom.xml b/spring-test-slices/pom.xml new file mode 100644 index 0000000..359879c --- /dev/null +++ b/spring-test-slices/pom.xml @@ -0,0 +1,87 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 4.1.2 + + + + com.ankurm.testing + spring-test-slices-lab + 1.0.0 + + + 25 + 25 + + + + + + org.springframework.boot + spring-boot-starter-webmvc + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.boot + spring-boot-starter-restclient + + + com.h2database + h2 + runtime + + + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.boot + spring-boot-starter-webmvc-test + test + + + org.springframework.boot + spring-boot-starter-data-jpa-test + test + + + org.springframework.boot + spring-boot-starter-restclient-test + test + + + + org.junit.platform + junit-platform-launcher + test + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + + failure-demo + + false + + alphabetical + + + + + diff --git a/spring-test-slices/scripts/run-all.sh b/spring-test-slices/scripts/run-all.sh new file mode 100755 index 0000000..76425ec --- /dev/null +++ b/spring-test-slices/scripts/run-all.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# Regenerates every file in spring-test-slices/output/. +# Needs JDK 25 and Maven on the PATH. Takes about a minute. +set -euo pipefail +cd "$(dirname "$0")/.." + +OUT=output +# keep transcripts free of JVM launcher noise that varies per machine +filter() { grep -vE '^(Picked up JAVA_TOOL_OPTIONS|WARNING: )' || true; } + +rm -f "$OUT"/*.txt +mkdir -p "$OUT" + +# 1. the whole suite: every test writes its own transcript to output/ and asserts the same facts +mvn -B -q clean test 2>&1 | filter | grep -E 'Tests run:|BUILD|ERROR' || true + +# 2. which test jars does spring-boot-starter-test alone bring in? +./scripts/starter-test-only.sh + +# 3. startup time of each context, one test class per JVM so nothing is cached. +# Spring Boot logs "Started in N seconds" for every context it builds. +# Timings vary by machine and by run: read them as indicative, not as a result. +{ + echo "Context startup time per test class (one class per mvn run; indicative, varies between runs)" + echo + for cls in JsonSliceTest RestClientSliceTest WebMvcSliceTest DataJpaSliceTest FullContextTest; do + mvn -B test -Dtest="$cls" 2>&1 | grep -oE "Started $cls in [0-9.]+ seconds" | head -1 + done +} > "$OUT/30-startup-times.txt" + +echo "done: $(ls "$OUT" | wc -l) files in $OUT/" diff --git a/spring-test-slices/scripts/starter-test-only.sh b/spring-test-slices/scripts/starter-test-only.sh new file mode 100755 index 0000000..f0dfc5d --- /dev/null +++ b/spring-test-slices/scripts/starter-test-only.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# Which Boot test jars does spring-boot-starter-test alone bring in? +# Resolves a throwaway project with the three production starters and ONLY spring-boot-starter-test, +# then lists every spring-boot jar with "test" in its name. Writes output/12-starter-test-only-jars.txt. +set -euo pipefail +cd "$(dirname "$0")/.." +BOOT="$(sed -n '/spring-boot-starter-parent<\/artifactId>/{n;s/.*\(.*\)<\/version>.*/\1/p}' pom.xml)" +TMP=target/starter-test-only +rm -rf "$TMP" && mkdir -p "$TMP" +cat > "$TMP/pom.xml" <4.0.0 + org.springframework.bootspring-boot-starter-parent${BOOT} + probestarter-test-only1 + + org.springframework.bootspring-boot-starter-webmvc + org.springframework.bootspring-boot-starter-data-jpa + org.springframework.bootspring-boot-starter-restclient + org.springframework.bootspring-boot-starter-testtest + + +POM +mvn -B -q -f "$TMP/pom.xml" dependency:build-classpath -Dmdep.outputFile=cp.txt > /dev/null 2>&1 +{ + echo "Spring Boot ${BOOT}: webmvc + data-jpa + restclient starters, and only spring-boot-starter-test for tests" + echo "spring-boot jars with 'test' in the name on that classpath:" + tr ':' '\n' < "$TMP/cp.txt" | sed 's#.*/##' | grep -E '^spring-boot.*test' | sed 's/^/ /' +} > output/12-starter-test-only-jars.txt diff --git a/spring-test-slices/src/broken/lab/slices/OldDataJpaTest.java b/spring-test-slices/src/broken/lab/slices/OldDataJpaTest.java new file mode 100644 index 0000000..2f14dc6 --- /dev/null +++ b/spring-test-slices/src/broken/lab/slices/OldDataJpaTest.java @@ -0,0 +1,10 @@ +package lab.slices; + +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; + +/** A Boot 3 style repository test. */ +@DataJpaTest +class OldDataJpaTest { + TestEntityManager em; +} diff --git a/spring-test-slices/src/broken/lab/slices/OldRestClientTest.java b/spring-test-slices/src/broken/lab/slices/OldRestClientTest.java new file mode 100644 index 0000000..9e7b933 --- /dev/null +++ b/spring-test-slices/src/broken/lab/slices/OldRestClientTest.java @@ -0,0 +1,8 @@ +package lab.slices; + +import org.springframework.boot.test.autoconfigure.web.client.RestClientTest; + +/** A Boot 3 style client slice test. */ +@RestClientTest(IsbnLookupClient.class) +class OldRestClientTest { +} diff --git a/spring-test-slices/src/broken/lab/slices/OldWebMvcTest.java b/spring-test-slices/src/broken/lab/slices/OldWebMvcTest.java new file mode 100644 index 0000000..59726db --- /dev/null +++ b/spring-test-slices/src/broken/lab/slices/OldWebMvcTest.java @@ -0,0 +1,11 @@ +package lab.slices; + +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; + +/** A Boot 3 style web slice test: both imports are gone in Boot 4. */ +@WebMvcTest(BookController.class) +class OldWebMvcTest { + @MockBean + BookService service; +} diff --git a/spring-test-slices/src/main/java/lab/slices/ApiExceptionHandler.java b/spring-test-slices/src/main/java/lab/slices/ApiExceptionHandler.java new file mode 100644 index 0000000..e1f60b9 --- /dev/null +++ b/spring-test-slices/src/main/java/lab/slices/ApiExceptionHandler.java @@ -0,0 +1,17 @@ +package lab.slices; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ProblemDetail; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +/** A @ControllerAdvice: @WebMvcTest loads it, so the 404 mapping is testable without a service layer. */ +@RestControllerAdvice +public class ApiExceptionHandler { + @ExceptionHandler(BookNotFoundException.class) + ProblemDetail notFound(BookNotFoundException e) { + ProblemDetail p = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, e.getMessage()); + p.setTitle("Book not found"); + return p; + } +} diff --git a/spring-test-slices/src/main/java/lab/slices/AuditLogger.java b/spring-test-slices/src/main/java/lab/slices/AuditLogger.java new file mode 100644 index 0000000..52fce28 --- /dev/null +++ b/spring-test-slices/src/main/java/lab/slices/AuditLogger.java @@ -0,0 +1,14 @@ +package lab.slices; + +import org.springframework.stereotype.Component; + +/** + * A plain @Component. No slice scans plain components, so this bean exists only in a full + * @SpringBootTest. It is the canary the bean-presence table uses. + */ +@Component +public class AuditLogger { + public void audit(String message) { + System.out.println("AUDIT " + message); + } +} diff --git a/spring-test-slices/src/main/java/lab/slices/Book.java b/spring-test-slices/src/main/java/lab/slices/Book.java new file mode 100644 index 0000000..a8644a9 --- /dev/null +++ b/spring-test-slices/src/main/java/lab/slices/Book.java @@ -0,0 +1,37 @@ +package lab.slices; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import java.math.BigDecimal; +import java.time.LocalDate; + +@Entity +public class Book { + @Id + @GeneratedValue + private Long id; + private String isbn; + private String title; + private String author; + private BigDecimal price; + private LocalDate publishedOn; + + protected Book() { + } + + public Book(String isbn, String title, String author, BigDecimal price, LocalDate publishedOn) { + this.isbn = isbn; + this.title = title; + this.author = author; + this.price = price; + this.publishedOn = publishedOn; + } + + public Long getId() { return id; } + public String getIsbn() { return isbn; } + public String getTitle() { return title; } + public String getAuthor() { return author; } + public BigDecimal getPrice() { return price; } + public LocalDate getPublishedOn() { return publishedOn; } +} diff --git a/spring-test-slices/src/main/java/lab/slices/BookController.java b/spring-test-slices/src/main/java/lab/slices/BookController.java new file mode 100644 index 0000000..88c2a8a --- /dev/null +++ b/spring-test-slices/src/main/java/lab/slices/BookController.java @@ -0,0 +1,37 @@ +package lab.slices; + +import java.util.List; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/books") +public class BookController { + private final BookService service; + + public BookController(BookService service) { + this.service = service; + } + + @GetMapping("/{id}") + BookView one(@PathVariable Long id) { + return BookView.of(service.get(id)); + } + + @GetMapping + List byAuthor(@RequestParam String author) { + return service.byAuthor(author).stream().map(BookView::of).toList(); + } + + @PostMapping("/import/{isbn}") + ResponseEntity importBook(@PathVariable String isbn) { + return service.importByIsbn(isbn) + .map(b -> ResponseEntity.ok(BookView.of(b))) + .orElse(ResponseEntity.notFound().build()); + } +} diff --git a/spring-test-slices/src/main/java/lab/slices/BookNotFoundException.java b/spring-test-slices/src/main/java/lab/slices/BookNotFoundException.java new file mode 100644 index 0000000..3d77310 --- /dev/null +++ b/spring-test-slices/src/main/java/lab/slices/BookNotFoundException.java @@ -0,0 +1,7 @@ +package lab.slices; + +public class BookNotFoundException extends RuntimeException { + public BookNotFoundException(Long id) { + super("No book with id " + id); + } +} diff --git a/spring-test-slices/src/main/java/lab/slices/BookRepository.java b/spring-test-slices/src/main/java/lab/slices/BookRepository.java new file mode 100644 index 0000000..2ec133d --- /dev/null +++ b/spring-test-slices/src/main/java/lab/slices/BookRepository.java @@ -0,0 +1,11 @@ +package lab.slices; + +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface BookRepository extends JpaRepository { + List findByAuthorIgnoreCaseOrderByTitle(String author); + + Optional findByIsbn(String isbn); +} diff --git a/spring-test-slices/src/main/java/lab/slices/BookService.java b/spring-test-slices/src/main/java/lab/slices/BookService.java new file mode 100644 index 0000000..8f143c6 --- /dev/null +++ b/spring-test-slices/src/main/java/lab/slices/BookService.java @@ -0,0 +1,36 @@ +package lab.slices; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; +import org.springframework.stereotype.Service; + +@Service +public class BookService { + private final BookRepository repository; + private final IsbnLookupClient isbnClient; + private final AuditLogger audit; + + public BookService(BookRepository repository, IsbnLookupClient isbnClient, AuditLogger audit) { + this.repository = repository; + this.isbnClient = isbnClient; + this.audit = audit; + } + + public Book get(Long id) { + return repository.findById(id).orElseThrow(() -> new BookNotFoundException(id)); + } + + public List byAuthor(String author) { + return repository.findByAuthorIgnoreCaseOrderByTitle(author); + } + + /** Looks the ISBN up remotely and stores the result. Empty when the remote service has never heard of it. */ + public java.util.Optional importByIsbn(String isbn) { + return isbnClient.lookup(isbn).map(info -> { + audit.audit("import " + isbn); + return repository.save(new Book(info.isbn(), info.title(), info.author(), + BigDecimal.valueOf(499), LocalDate.of(2024, 1, 1))); + }); + } +} diff --git a/spring-test-slices/src/main/java/lab/slices/BookView.java b/spring-test-slices/src/main/java/lab/slices/BookView.java new file mode 100644 index 0000000..dbf02f0 --- /dev/null +++ b/spring-test-slices/src/main/java/lab/slices/BookView.java @@ -0,0 +1,18 @@ +package lab.slices; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.time.LocalDate; + +/** What the API returns for a book. The JSON shape is what the @JsonTest slice pins down. */ +public record BookView( + String isbn, + String title, + String author, + Price price, + @JsonProperty("published_on") LocalDate publishedOn) { + + static BookView of(Book b) { + return new BookView(b.getIsbn(), b.getTitle(), b.getAuthor(), + new Price("INR", b.getPrice()), b.getPublishedOn()); + } +} diff --git a/spring-test-slices/src/main/java/lab/slices/IsbnInfo.java b/spring-test-slices/src/main/java/lab/slices/IsbnInfo.java new file mode 100644 index 0000000..51f9e9b --- /dev/null +++ b/spring-test-slices/src/main/java/lab/slices/IsbnInfo.java @@ -0,0 +1,4 @@ +package lab.slices; + +public record IsbnInfo(String isbn, String title, String author, int pages) { +} diff --git a/spring-test-slices/src/main/java/lab/slices/IsbnLookupClient.java b/spring-test-slices/src/main/java/lab/slices/IsbnLookupClient.java new file mode 100644 index 0000000..629d820 --- /dev/null +++ b/spring-test-slices/src/main/java/lab/slices/IsbnLookupClient.java @@ -0,0 +1,25 @@ +package lab.slices; + +import java.util.Optional; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.RestClient; + +/** Calls an external ISBN service. @RestClientTest replaces the HTTP server behind it. */ +@Component +public class IsbnLookupClient { + private final RestClient client; + + public IsbnLookupClient(RestClient.Builder builder, @Value("${isbn.base-url}") String baseUrl) { + this.client = builder.baseUrl(baseUrl).build(); + } + + public Optional lookup(String isbn) { + try { + return Optional.ofNullable(client.get().uri("/isbn/{isbn}", isbn).retrieve().body(IsbnInfo.class)); + } catch (HttpClientErrorException.NotFound e) { + return Optional.empty(); + } + } +} diff --git a/spring-test-slices/src/main/java/lab/slices/Price.java b/spring-test-slices/src/main/java/lab/slices/Price.java new file mode 100644 index 0000000..8d57464 --- /dev/null +++ b/spring-test-slices/src/main/java/lab/slices/Price.java @@ -0,0 +1,7 @@ +package lab.slices; + +import java.math.BigDecimal; + +/** A money amount. Its JSON form ("INR 499.00") comes from {@link PriceJson}, not from annotations. */ +public record Price(String currency, BigDecimal amount) { +} diff --git a/spring-test-slices/src/main/java/lab/slices/PriceJson.java b/spring-test-slices/src/main/java/lab/slices/PriceJson.java new file mode 100644 index 0000000..a7d7a23 --- /dev/null +++ b/spring-test-slices/src/main/java/lab/slices/PriceJson.java @@ -0,0 +1,32 @@ +package lab.slices; + +import java.math.BigDecimal; +import org.springframework.boot.jackson.JacksonComponent; +import tools.jackson.core.JsonGenerator; +import tools.jackson.core.JsonParser; +import tools.jackson.databind.DeserializationContext; +import tools.jackson.databind.SerializationContext; +import tools.jackson.databind.ValueDeserializer; +import tools.jackson.databind.ValueSerializer; + +/** + * Writes a Price as "INR 499.00". @JacksonComponent is the Boot 4 name of @JsonComponent, and it is one of + * the few things the @JsonTest slice picks up by scanning. + */ +@JacksonComponent +public class PriceJson { + public static class Serializer extends ValueSerializer { + @Override + public void serialize(Price value, JsonGenerator gen, SerializationContext ctxt) { + gen.writeString(value.currency() + " " + value.amount().setScale(2).toPlainString()); + } + } + + public static class Deserializer extends ValueDeserializer { + @Override + public Price deserialize(JsonParser p, DeserializationContext ctxt) { + String[] parts = p.getString().split(" "); + return new Price(parts[0], new BigDecimal(parts[1])); + } + } +} diff --git a/spring-test-slices/src/main/java/lab/slices/SlicesApplication.java b/spring-test-slices/src/main/java/lab/slices/SlicesApplication.java new file mode 100644 index 0000000..2a27796 --- /dev/null +++ b/spring-test-slices/src/main/java/lab/slices/SlicesApplication.java @@ -0,0 +1,16 @@ +package lab.slices; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * Keep this class free of extra annotations. Every slice test starts from the class carrying + * @SpringBootApplication, so an @EnableJpaRepositories or @EnableJpaAuditing placed here is + * applied to every slice, including the ones that have no database. + */ +@SpringBootApplication +public class SlicesApplication { + public static void main(String[] args) { + SpringApplication.run(SlicesApplication.class, args); + } +} diff --git a/spring-test-slices/src/main/resources/application.properties b/spring-test-slices/src/main/resources/application.properties new file mode 100644 index 0000000..1ed6086 --- /dev/null +++ b/spring-test-slices/src/main/resources/application.properties @@ -0,0 +1,2 @@ +isbn.base-url=https://isbn.example.test +spring.jpa.open-in-view=false diff --git a/spring-test-slices/src/test/java/lab/slices/BeanProbe.java b/spring-test-slices/src/test/java/lab/slices/BeanProbe.java new file mode 100644 index 0000000..63e2277 --- /dev/null +++ b/spring-test-slices/src/test/java/lab/slices/BeanProbe.java @@ -0,0 +1,58 @@ +package lab.slices; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import javax.sql.DataSource; +import jakarta.persistence.EntityManagerFactory; +import org.springframework.context.ApplicationContext; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.web.client.RestClient; +import tools.jackson.databind.json.JsonMapper; + +/** + * Asks a live ApplicationContext which of a fixed list of beans it contains and writes the answer to + * target/probe/<slice>.txt. ZMatrixTest then merges those files into output/20-slice-matrix.txt. + */ +final class BeanProbe { + static final Map> PROBES = new LinkedHashMap<>(); + + static { + PROBES.put("BookController (@RestController)", BookController.class); + PROBES.put("ApiExceptionHandler (@ControllerAdvice)", ApiExceptionHandler.class); + PROBES.put("PriceJson (@JacksonComponent)", PriceJson.class); + PROBES.put("MockMvc", MockMvc.class); + PROBES.put("JsonMapper (Jackson 3)", JsonMapper.class); + PROBES.put("BookService (@Service)", BookService.class); + PROBES.put("BookRepository (Spring Data)", BookRepository.class); + PROBES.put("EntityManagerFactory", EntityManagerFactory.class); + PROBES.put("DataSource", DataSource.class); + PROBES.put("RestClient.Builder", RestClient.Builder.class); + PROBES.put("IsbnLookupClient (@Component)", IsbnLookupClient.class); + PROBES.put("AuditLogger (@Component)", AuditLogger.class); + } + + private BeanProbe() { + } + + /** Records presence of every probe plus the bean-definition count, and returns the lines it wrote. */ + static List record(String slice, ApplicationContext ctx) { + List lines = new ArrayList<>(); + PROBES.forEach((label, type) -> + lines.add(label + "=" + (ctx.getBeanNamesForType(type).length > 0 ? "yes" : "no"))); + lines.add("bean definitions=" + ctx.getBeanDefinitionCount()); + try { + Path dir = Path.of("target", "probe"); + Files.createDirectories(dir); + Files.write(dir.resolve(slice + ".txt"), lines); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + return lines; + } +} diff --git a/spring-test-slices/src/test/java/lab/slices/DataJpaSliceTest.java b/spring-test-slices/src/test/java/lab/slices/DataJpaSliceTest.java new file mode 100644 index 0000000..eacf42e --- /dev/null +++ b/spring-test-slices/src/test/java/lab/slices/DataJpaSliceTest.java @@ -0,0 +1,82 @@ +package lab.slices; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.math.BigDecimal; +import java.sql.SQLException; +import java.time.LocalDate; +import javax.sql.DataSource; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; +import org.springframework.boot.jpa.test.autoconfigure.TestEntityManager; +import org.springframework.context.ApplicationContext; + +/** @DataJpaTest: entities, repositories, an embedded database and a transaction rolled back after each test. */ +@DataJpaTest +@TestMethodOrder(MethodOrderer.MethodName.class) +class DataJpaSliceTest { + + @Autowired + BookRepository repository; + + @Autowired + TestEntityManager em; + + @Autowired + DataSource dataSource; + + @Autowired + ApplicationContext ctx; + + @Test + void a_derivedQueryIsCaseInsensitiveAndSorted() throws SQLException { + em.persist(new Book("1", "Zebra", "Ann", BigDecimal.ONE, LocalDate.of(2020, 1, 1))); + em.persist(new Book("2", "Apple", "ann", BigDecimal.TEN, LocalDate.of(2021, 1, 1))); + em.persist(new Book("3", "Other", "Bob", BigDecimal.TEN, LocalDate.of(2022, 1, 1))); + em.flush(); + + var books = repository.findByAuthorIgnoreCaseOrderByTitle("ANN"); + + String url; + try (var c = dataSource.getConnection()) { + url = c.getMetaData().getURL(); + } + Transcript t = new Transcript("04-datajpa-slice.txt") + .line("@DataJpaTest") + .line("") + .line("findByAuthorIgnoreCaseOrderByTitle(\"ANN\") -> %s", + books.stream().map(Book::getTitle).toList()) + .line("DataSource class -> %s", dataSource.getClass().getSimpleName()) + .line("JDBC URL starts with jdbc:h2:mem: -> %s", url.startsWith("jdbc:h2:mem:")) + .line("") + .line("beans in this context:"); + BeanProbe.record("DataJpaTest", ctx).forEach(l -> t.line(" %s", l)); + t.write(); + + assertThat(books).extracting(Book::getTitle).containsExactly("Apple", "Zebra"); + assertThat(ctx.getBeanNamesForType(BookService.class)).isEmpty(); + assertThat(ctx.getBeanNamesForType(BookController.class)).isEmpty(); + } + + // b_ and c_ both insert one row and both see exactly one: the first test's rows were rolled back + + @Test + void b_firstTestInsertsOneRow() { + repository.save(new Book("10", "B", "X", BigDecimal.ONE, LocalDate.of(2020, 1, 1))); + assertThat(repository.count()).isEqualTo(1); + } + + @Test + void c_secondTestStillSeesOnlyItsOwnRow() { + repository.save(new Book("11", "C", "X", BigDecimal.ONE, LocalDate.of(2020, 1, 1))); + assertThat(repository.count()).isEqualTo(1); + new Transcript("05-datajpa-rollback.txt") + .line("b_firstTestInsertsOneRow -> count after save = 1") + .line("c_secondTestStillSeesOnlyItsOwnRow -> count after save = %d", repository.count()) + .line("(the row from b_ was rolled back when its test ended)") + .write(); + } +} diff --git a/spring-test-slices/src/test/java/lab/slices/FailureCaptureTest.java b/spring-test-slices/src/test/java/lab/slices/FailureCaptureTest.java new file mode 100644 index 0000000..8d2db67 --- /dev/null +++ b/spring-test-slices/src/test/java/lab/slices/FailureCaptureTest.java @@ -0,0 +1,105 @@ +package lab.slices; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.platform.engine.discovery.DiscoverySelectors.selectClass; + +import java.util.ArrayList; +import java.util.List; +import lab.slices.failing.ClientWithoutComponentsFailure; +import lab.slices.failing.JpaAuditingInWebSliceFailure; +import lab.slices.failing.MissingMockBeanFailure; +import lab.slices.failing.PlainComponentInJsonFailure; +import lab.slices.failing.RepositoryInWebMvcFailure; +import lab.slices.failing.ServiceInDataJpaFailure; +import org.junit.jupiter.api.Test; +import org.junit.platform.launcher.TestExecutionListener; +import org.junit.platform.launcher.TestIdentifier; +import org.junit.platform.launcher.core.LauncherDiscoveryRequestBuilder; +import org.junit.platform.launcher.core.LauncherFactory; +import org.junit.platform.engine.TestExecutionResult; + +/** + * Runs the deliberately broken test classes in lab.slices.failing through the JUnit Launcher, takes the + * exception each one throws and writes the real message to output/07-slice-failures.txt. + */ +class FailureCaptureTest { + + private static Throwable runAndGetFailure(Class testClass) { + List failures = new ArrayList<>(); + var request = LauncherDiscoveryRequestBuilder.request().selectors(selectClass(testClass)).build(); + LauncherFactory.create().execute(request, new TestExecutionListener() { + @Override + public void executionFinished(TestIdentifier id, TestExecutionResult result) { + result.getThrowable().ifPresent(failures::add); + } + }); + assertThat(failures).as("%s should fail", testClass.getSimpleName()).isNotEmpty(); + return failures.getFirst(); + } + + private static Throwable root(Throwable t) { + while (t.getCause() != null && t.getCause() != t) { + t = t.getCause(); + } + return t; + } + + private static String firstLine(String s) { + return s == null ? "(no message)" : s.strip().split("\n")[0]; + } + + private static String chain(Throwable t) { + List names = new ArrayList<>(); + for (Throwable c = t; c != null; c = c.getCause() == c ? null : c.getCause()) { + names.add(c.getClass().getSimpleName()); + } + return String.join(" -> ", names); + } + + private static Throwable firstOfType(Throwable t, String simpleName) { + for (Throwable c = t; c != null; c = c.getCause() == c ? null : c.getCause()) { + if (c.getClass().getSimpleName().equals(simpleName)) { + return c; + } + } + return null; + } + + @Test + void captureTheMessagesAReaderWillSee() { + Transcript t = new Transcript("07-slice-failures.txt") + .line("Each class in lab.slices.failing is run through the JUnit Launcher; this is what its exception says.") + .line("(The 'Failed to load ApplicationContext' line carries a long configuration dump with object hashes; it is cut at the first '['.") + .line(" The module directory is shown as .)") + .line(""); + Class[] demos = {MissingMockBeanFailure.class, ServiceInDataJpaFailure.class, RepositoryInWebMvcFailure.class, + PlainComponentInJsonFailure.class, ClientWithoutComponentsFailure.class}; + for (Class demo : demos) { + Throwable failure = runAndGetFailure(demo); + Throwable root = root(failure); + Throwable wiring = firstOfType(failure, "UnsatisfiedDependencyException"); + String top = firstLine(failure.getMessage()); + String wiringMessage = wiring == null ? "(none)" + : firstLine(wiring.getMessage()).replace(System.getProperty("user.dir"), ""); + t.line("== %s", demo.getSimpleName()).line("exception chain: %s", chain(failure)); + if (!top.startsWith("Error creating bean")) { + t.line("top message : %s", top.contains("[") ? top.substring(0, top.indexOf('[')).strip() : top); + } + t.line("wiring failure : %s", wiringMessage).line(""); + assertThat(root.getMessage()).contains("No qualifying bean of type"); + } + t.write(); + } + + @Test + void captureTheJpaAuditingTrap() { + Throwable failure = runAndGetFailure(JpaAuditingInWebSliceFailure.class); + Throwable root = root(failure); + new Transcript("08-jpa-auditing-in-web-slice.txt") + .line("@WebMvcTest + @EnableJpaAuditing (via @Import), BookService mocked") + .line("exception chain: %s", chain(failure)) + .line("root cause : %s", firstLine(root.getMessage())) + .write(); + assertThat(root.getMessage()).contains("JPA metamodel must not be empty"); + } +} diff --git a/spring-test-slices/src/test/java/lab/slices/FullContextTest.java b/spring-test-slices/src/test/java/lab/slices/FullContextTest.java new file mode 100644 index 0000000..e379919 --- /dev/null +++ b/spring-test-slices/src/test/java/lab/slices/FullContextTest.java @@ -0,0 +1,58 @@ +package lab.slices; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.context.ApplicationContext; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +/** The baseline: everything is loaded. Only the remote ISBN service is replaced, because it is outside the process. */ +@SpringBootTest +@AutoConfigureMockMvc +class FullContextTest { + + @Autowired + MockMvc mvc; + + @Autowired + ApplicationContext ctx; + + @MockitoBean + IsbnLookupClient isbn; + + @Test + void importsABookThroughEveryLayer() throws Exception { + given(isbn.lookup("9780134685991")) + .willReturn(Optional.of(new IsbnInfo("9780134685991", "Effective Java", "Joshua Bloch", 412))); + + String imported = mvc.perform(post("/api/books/import/9780134685991")) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + String listed = mvc.perform(get("/api/books").param("author", "joshua bloch")) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + Transcript t = new Transcript("06-full-context.txt") + .line("@SpringBootTest + @AutoConfigureMockMvc, @MockitoBean IsbnLookupClient") + .line("") + .line("POST /api/books/import/9780134685991 -> 200 %s", imported) + .line("GET /api/books?author=joshua bloch -> 200 %s", listed) + .line("") + .line("beans in this context:"); + BeanProbe.record("SpringBootTest", ctx).forEach(l -> t.line(" %s", l)); + t.write(); + + assertThat(listed).contains("Effective Java"); + assertThat(ctx.getBeanNamesForType(AuditLogger.class)).hasSize(1); + assertThat(ctx.getBeanNamesForType(BookRepository.class)).hasSize(1); + } +} diff --git a/spring-test-slices/src/test/java/lab/slices/JsonSliceTest.java b/spring-test-slices/src/test/java/lab/slices/JsonSliceTest.java new file mode 100644 index 0000000..be15767 --- /dev/null +++ b/spring-test-slices/src/test/java/lab/slices/JsonSliceTest.java @@ -0,0 +1,48 @@ +package lab.slices; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.math.BigDecimal; +import java.time.LocalDate; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.json.JsonTest; +import org.springframework.boot.test.json.JacksonTester; +import org.springframework.context.ApplicationContext; + +/** @JsonTest: only Jackson. No web server, no MockMvc, no database. */ +@JsonTest +class JsonSliceTest { + + @Autowired + JacksonTester json; + + @Autowired + ApplicationContext ctx; + + @Test + void serialisesTheViewAndReadsItBack() throws Exception { + BookView view = new BookView("9780134685991", "Effective Java", "Joshua Bloch", + new Price("INR", new BigDecimal("499.00")), LocalDate.of(2018, 1, 6)); + + var written = json.write(view); + String text = written.getJson(); + BookView back = json.parseObject(text); + + Transcript t = new Transcript("02-json-slice.txt") + .line("@JsonTest with JacksonTester") + .line("") + .line("write(view) -> %s", text) + .line("parseObject(...).price() -> %s", back.price()) + .line("") + .line("beans in this context:"); + BeanProbe.record("JsonTest", ctx).forEach(l -> t.line(" %s", l)); + t.write(); + + assertThat(written).extractingJsonPathStringValue("$.price").isEqualTo("INR 499.00"); + assertThat(written).extractingJsonPathStringValue("$.published_on").isEqualTo("2018-01-06"); + assertThat(back).isEqualTo(view); + assertThat(ctx.getBeanNamesForType(PriceJson.class)).as("@JacksonComponent is scanned by @JsonTest").isNotEmpty(); + assertThat(ctx.getBeanNamesForType(BookController.class)).isEmpty(); + } +} diff --git a/spring-test-slices/src/test/java/lab/slices/OldImportsCompileTest.java b/spring-test-slices/src/test/java/lab/slices/OldImportsCompileTest.java new file mode 100644 index 0000000..c24a315 --- /dev/null +++ b/spring-test-slices/src/test/java/lab/slices/OldImportsCompileTest.java @@ -0,0 +1,56 @@ +package lab.slices; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Locale; +import java.util.stream.Stream; +import javax.tools.Diagnostic; +import javax.tools.DiagnosticCollector; +import javax.tools.JavaFileObject; +import javax.tools.ToolProvider; +import org.junit.jupiter.api.Test; + +/** + * Compiles the sources in src/broken (outside the Maven build) against the real test classpath and records + * javac's own error messages: what a Boot 3 test looks like after the Boot 4 upgrade. + */ +class OldImportsCompileTest { + + @Test + void javacRejectsTheBoot3Imports() throws IOException { + var compiler = ToolProvider.getSystemJavaCompiler(); + var diagnostics = new DiagnosticCollector(); + Path out = Files.createTempDirectory("broken-classes"); + try (var fm = compiler.getStandardFileManager(diagnostics, Locale.ENGLISH, null); + Stream files = Files.walk(Path.of("src", "broken"))) { + var sources = fm.getJavaFileObjectsFromPaths(files.filter(p -> p.toString().endsWith(".java")).sorted().toList()); + var ok = compiler.getTask(null, fm, diagnostics, + java.util.List.of("-d", out.toString(), "-classpath", System.getProperty("java.class.path"), "-proc:none"), + null, sources).call(); + assertThat(ok).isFalse(); + } + + Transcript t = new Transcript("10-old-imports-compile-errors.txt") + .line("javac against the Boot 4.1.2 test classpath, sources from src/broken/") + .line(""); + long errors = 0; + for (var d : diagnostics.getDiagnostics()) { + if (d.getKind() != Diagnostic.Kind.ERROR) { + continue; + } + errors++; + String file = Path.of(d.getSource().toUri()).getFileName().toString(); + String[] msg = d.getMessage(Locale.ENGLISH).split("\n"); + t.line("%s:%d: error: %s", file, d.getLineNumber(), msg[0]); + for (int i = 1; i < msg.length; i++) { + t.line(" %s", msg[i].strip()); + } + } + t.line("").line("%d errors", errors).write(); + + assertThat(errors).isGreaterThanOrEqualTo(5); + } +} diff --git a/spring-test-slices/src/test/java/lab/slices/PackageMovesTest.java b/spring-test-slices/src/test/java/lab/slices/PackageMovesTest.java new file mode 100644 index 0000000..3684fdc --- /dev/null +++ b/spring-test-slices/src/test/java/lab/slices/PackageMovesTest.java @@ -0,0 +1,92 @@ +package lab.slices; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.LinkedHashMap; +import java.util.Map; +import org.junit.jupiter.api.Test; + +/** Asks the classpath, not the documentation, where each test annotation lives in Spring Boot 4.1.2. */ +class PackageMovesTest { + + private static boolean present(String fqcn) { + try { + Class.forName(fqcn, false, PackageMovesTest.class.getClassLoader()); + return true; + } catch (ClassNotFoundException e) { + return false; + } + } + + @Test + void whichPackageHoldsWhat() { + // old package (the one most blog posts and IDE auto-imports still use) -> new package + Map moves = new LinkedHashMap<>(); + moves.put("@WebMvcTest", new String[] {"org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest", + "org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest"}); + moves.put("@AutoConfigureMockMvc", new String[] {"org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc", + "org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc"}); + moves.put("@DataJpaTest", new String[] {"org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest", + "org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest"}); + moves.put("TestEntityManager", new String[] {"org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager", + "org.springframework.boot.jpa.test.autoconfigure.TestEntityManager"}); + moves.put("@AutoConfigureTestDatabase", new String[] {"org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase", + "org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase"}); + moves.put("@RestClientTest", new String[] {"org.springframework.boot.test.autoconfigure.web.client.RestClientTest", + "org.springframework.boot.restclient.test.autoconfigure.RestClientTest"}); + moves.put("@JsonTest", new String[] {"org.springframework.boot.test.autoconfigure.json.JsonTest", + "org.springframework.boot.test.autoconfigure.json.JsonTest"}); + moves.put("@MockBean", new String[] {"org.springframework.boot.test.mock.mockito.MockBean", + "org.springframework.test.context.bean.override.mockito.MockitoBean"}); + moves.put("@JsonComponent", new String[] {"org.springframework.boot.jackson.JsonComponent", + "org.springframework.boot.jackson.JacksonComponent"}); + moves.put("@SpyBean", new String[] {"org.springframework.boot.test.mock.mockito.SpyBean", + "org.springframework.test.context.bean.override.mockito.MockitoSpyBean"}); + + Transcript t = new Transcript("09-package-moves.txt") + .line("Class.forName on the Boot 4.1.2 test classpath") + .line(""); + moves.forEach((name, p) -> { + boolean oldThere = present(p[0]); + boolean newThere = present(p[1]); + t.line("%s", name) + .line(" old: %s -> %s", p[0], oldThere ? "present" : "NOT FOUND") + .line(" new: %s -> %s", p[1], newThere ? "present" : "NOT FOUND"); + assertThat(newThere).as("new location of %s", name).isTrue(); + assertThat(oldThere).as("old location of %s", name).isEqualTo(name.equals("@JsonTest")); + }); + t.write(); + } + + /** Which Jackson does each tester wrap? Read from the constructor the class declares, not from its name. */ + @Test + void whichJacksonDoesEachTesterWrap() { + Transcript t = new Transcript("11-json-testers.txt") + .line("Single-argument constructor of each tester in spring-boot-test 4.1.2") + .line(""); + for (String name : new String[] {"JacksonTester", "Jackson2Tester"}) { + Class c; + try { + c = Class.forName("org.springframework.boot.test.json." + name); + } catch (ClassNotFoundException e) { + throw new AssertionError(e); + } + java.lang.reflect.Constructor[] ctors; + try { + ctors = c.getDeclaredConstructors(); + } catch (NoClassDefFoundError e) { + // Jackson 2 is not on a Boot 4 classpath, so the JVM cannot even describe Jackson2Tester + t.line("%-15s cannot be inspected: %s is not on the classpath", name, e.getMessage().replace('/', '.')); + assertThat(name).isEqualTo("Jackson2Tester"); + continue; + } + for (var ctor : ctors) { + if (ctor.getParameterCount() == 1) { + t.line("%-15s wraps %s", name, ctor.getParameterTypes()[0].getName()); + assertThat(ctor.getParameterTypes()[0].getName()).startsWith("tools.jackson."); + } + } + } + t.write(); + } +} diff --git a/spring-test-slices/src/test/java/lab/slices/RestClientSliceTest.java b/spring-test-slices/src/test/java/lab/slices/RestClientSliceTest.java new file mode 100644 index 0000000..a1096ca --- /dev/null +++ b/spring-test-slices/src/test/java/lab/slices/RestClientSliceTest.java @@ -0,0 +1,59 @@ +package lab.slices; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.http.MediaType.APPLICATION_JSON; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.method; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withResourceNotFound; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.restclient.test.autoconfigure.RestClientTest; +import org.springframework.context.ApplicationContext; +import org.springframework.http.HttpMethod; +import org.springframework.test.web.client.MockRestServiceServer; + +/** @RestClientTest: one client class, a RestClient.Builder wired to a mock server, nothing else. */ +@RestClientTest(IsbnLookupClient.class) +class RestClientSliceTest { + + @Autowired + IsbnLookupClient client; + + @Autowired + MockRestServiceServer server; + + @Autowired + ApplicationContext ctx; + + @Test + void parsesTheRemoteAnswerAndTreats404AsEmpty() { + server.expect(requestTo("https://isbn.example.test/isbn/9780134685991")) + .andExpect(method(HttpMethod.GET)) + .andRespond(withSuccess(""" + {"isbn":"9780134685991","title":"Effective Java","author":"Joshua Bloch","pages":412}""", + APPLICATION_JSON)); + server.expect(requestTo("https://isbn.example.test/isbn/0000000000")) + .andRespond(withResourceNotFound()); + + var found = client.lookup("9780134685991"); + var missing = client.lookup("0000000000"); + server.verify(); + + Transcript t = new Transcript("03-restclient-slice.txt") + .line("@RestClientTest(IsbnLookupClient.class), base URL from application.properties") + .line("") + .line("lookup(9780134685991) -> %s", found) + .line("lookup(0000000000) -> %s", missing) + .line("") + .line("beans in this context:"); + BeanProbe.record("RestClientTest", ctx).forEach(l -> t.line(" %s", l)); + t.write(); + + assertThat(found).get().extracting(IsbnInfo::pages).isEqualTo(412); + assertThat(missing).isEmpty(); + assertThat(ctx.getBeanNamesForType(BookService.class)).isEmpty(); + assertThat(ctx.getBeanNamesForType(BookController.class)).isEmpty(); + } +} diff --git a/spring-test-slices/src/test/java/lab/slices/Transcript.java b/spring-test-slices/src/test/java/lab/slices/Transcript.java new file mode 100644 index 0000000..46ca557 --- /dev/null +++ b/spring-test-slices/src/test/java/lab/slices/Transcript.java @@ -0,0 +1,57 @@ +package lab.slices; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +/** + * Collects lines during a test and writes them to output/NN-name.txt. + * The same test asserts the same facts, so every figure in the blog post is an assertion. + */ +final class Transcript { + private final String file; + private final List lines = new ArrayList<>(); + + Transcript(String file) { + this.file = file; + } + + Transcript line(String format, Object... args) { + lines.add(args.length == 0 ? format : String.format(format, args)); + return this; + } + + /** A labelled multi-line message (an AssertionError message, usually), indented, unedited. */ + Transcript block(String label, String text) { + lines.add(label); + for (String l : text.strip().split("\n")) { + lines.add(" " + l.stripTrailing()); + } + return this; + } + + /** Like block(), but drops stack-frame lines ("at ..." and "...(n remaining lines...)"). */ + Transcript blockWithoutFrames(String label, String text) { + lines.add(label + " (stack frames omitted)"); + for (String l : text.strip().split("\n")) { + if (l.stripLeading().startsWith("at ") || l.stripLeading().startsWith("...")) { + continue; + } + lines.add(" " + l.stripTrailing()); + } + return this; + } + + void write() { + try { + Path dir = Path.of("output"); + Files.createDirectories(dir); + Files.writeString(dir.resolve(file), String.join("\n", lines) + "\n"); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } +} diff --git a/spring-test-slices/src/test/java/lab/slices/WebMvcSliceTest.java b/spring-test-slices/src/test/java/lab/slices/WebMvcSliceTest.java new file mode 100644 index 0000000..27bd1d4 --- /dev/null +++ b/spring-test-slices/src/test/java/lab/slices/WebMvcSliceTest.java @@ -0,0 +1,63 @@ +package lab.slices; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.math.BigDecimal; +import java.time.LocalDate; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.context.ApplicationContext; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +/** @WebMvcTest: the web layer only. BookService is replaced by a Mockito mock. */ +@WebMvcTest(BookController.class) +class WebMvcSliceTest { + + @Autowired + MockMvc mvc; + + @Autowired + ApplicationContext ctx; + + @MockitoBean + BookService service; + + @Test + void returnsABookAsJson_usingTheJacksonComponentAndTheControllerAdvice() throws Exception { + given(service.get(1L)).willReturn( + new Book("9780134685991", "Effective Java", "Joshua Bloch", new BigDecimal("499"), LocalDate.of(2018, 1, 6))); + given(service.get(99L)).willThrow(new BookNotFoundException(99L)); + + String ok = mvc.perform(get("/api/books/1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.title").value("Effective Java")) + .andExpect(jsonPath("$.price").value("INR 499.00")) + .andExpect(jsonPath("$.published_on").value("2018-01-06")) + .andReturn().getResponse().getContentAsString(); + + String missing = mvc.perform(get("/api/books/99")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.title").value("Book not found")) + .andReturn().getResponse().getContentAsString(); + + Transcript t = new Transcript("01-webmvc-slice.txt") + .line("@WebMvcTest(BookController.class) with @MockitoBean BookService") + .line("") + .line("GET /api/books/1 -> 200 %s", ok) + .line("GET /api/books/99 -> 404 %s", missing) + .line("") + .line("beans in this context:"); + BeanProbe.record("WebMvcTest", ctx).forEach(l -> t.line(" %s", l)); + t.write(); + + assertThat(ctx.getBeanNamesForType(BookService.class)).as("the mock is the only BookService").hasSize(1); + assertThat(ctx.getBeanNamesForType(BookRepository.class)).isEmpty(); + assertThat(ctx.getBeanNamesForType(AuditLogger.class)).isEmpty(); + } +} diff --git a/spring-test-slices/src/test/java/lab/slices/ZMatrixTest.java b/spring-test-slices/src/test/java/lab/slices/ZMatrixTest.java new file mode 100644 index 0000000..e9bce0f --- /dev/null +++ b/spring-test-slices/src/test/java/lab/slices/ZMatrixTest.java @@ -0,0 +1,45 @@ +package lab.slices; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +/** Runs last (alphabetical order, see pom.xml). Merges the per-slice probe files into one table. */ +class ZMatrixTest { + + @Test + void mergeTheProbes() throws IOException { + List slices = List.of("WebMvcTest", "JsonTest", "RestClientTest", "DataJpaTest", "SpringBootTest"); + Map> byslice = new LinkedHashMap<>(); + for (String s : slices) { + Map m = new LinkedHashMap<>(); + for (String line : Files.readAllLines(Path.of("target", "probe", s + ".txt"))) { + int i = line.lastIndexOf('='); + m.put(line.substring(0, i), line.substring(i + 1)); + } + byslice.put(s, m); + } + + Transcript t = new Transcript("20-slice-matrix.txt") + .line("%-42s %-10s %-9s %-15s %-12s %s", "bean", "WebMvcTest", "JsonTest", "RestClientTest", "DataJpaTest", "SpringBootTest"); + for (String bean : BeanProbe.PROBES.keySet()) { + t.line("%-42s %-10s %-9s %-15s %-12s %s", bean, byslice.get("WebMvcTest").get(bean), byslice.get("JsonTest").get(bean), + byslice.get("RestClientTest").get(bean), byslice.get("DataJpaTest").get(bean), byslice.get("SpringBootTest").get(bean)); + } + String k = "bean definitions"; + t.line("%-42s %-10s %-9s %-15s %-12s %s", k, byslice.get("WebMvcTest").get(k), byslice.get("JsonTest").get(k), + byslice.get("RestClientTest").get(k), byslice.get("DataJpaTest").get(k), byslice.get("SpringBootTest").get(k)); + t.write(); + + int full = Integer.parseInt(byslice.get("SpringBootTest").get(k)); + for (String s : slices.subList(0, 4)) { + assertThat(Integer.parseInt(byslice.get(s).get(k))).as("%s has fewer beans than the full context", s).isLessThan(full); + } + } +} diff --git a/spring-test-slices/src/test/java/lab/slices/failing/ClientWithoutComponentsFailure.java b/spring-test-slices/src/test/java/lab/slices/failing/ClientWithoutComponentsFailure.java new file mode 100644 index 0000000..097172b --- /dev/null +++ b/spring-test-slices/src/test/java/lab/slices/failing/ClientWithoutComponentsFailure.java @@ -0,0 +1,19 @@ +package lab.slices.failing; + +import lab.slices.IsbnLookupClient; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.restclient.test.autoconfigure.RestClientTest; + +/** Probe: @RestClientTest without naming the client class. Does the slice still find a plain @Component? */ +@Tag("failure-demo") +@RestClientTest +public class ClientWithoutComponentsFailure { + @Autowired + IsbnLookupClient client; + + @Test + void neverRuns() { + } +} diff --git a/spring-test-slices/src/test/java/lab/slices/failing/JpaAuditingInWebSliceFailure.java b/spring-test-slices/src/test/java/lab/slices/failing/JpaAuditingInWebSliceFailure.java new file mode 100644 index 0000000..3bdf122 --- /dev/null +++ b/spring-test-slices/src/test/java/lab/slices/failing/JpaAuditingInWebSliceFailure.java @@ -0,0 +1,33 @@ +package lab.slices.failing; + +import lab.slices.BookController; +import lab.slices.BookService; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +/** + * Fails on purpose. The classic version of this trap puts @EnableJpaAuditing on the @SpringBootApplication class, + * which every slice starts from. We cannot do that here without breaking the working slices, so the same + * annotation arrives through an @Import: the web slice has no JPA metamodel for it to use. + */ +@Tag("failure-demo") +@WebMvcTest(BookController.class) +@Import(JpaAuditingInWebSliceFailure.AuditingConfig.class) +public class JpaAuditingInWebSliceFailure { + @MockitoBean + BookService service; + + @Test + void neverRuns() { + } + + @Configuration + @EnableJpaAuditing + static class AuditingConfig { + } +} diff --git a/spring-test-slices/src/test/java/lab/slices/failing/MissingMockBeanFailure.java b/spring-test-slices/src/test/java/lab/slices/failing/MissingMockBeanFailure.java new file mode 100644 index 0000000..e362de1 --- /dev/null +++ b/spring-test-slices/src/test/java/lab/slices/failing/MissingMockBeanFailure.java @@ -0,0 +1,15 @@ +package lab.slices.failing; + +import lab.slices.BookController; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; + +/** Fails on purpose: the controller needs a BookService and nothing in a @WebMvcTest provides one. */ +@Tag("failure-demo") +@WebMvcTest(BookController.class) +public class MissingMockBeanFailure { + @Test + void neverRuns() { + } +} diff --git a/spring-test-slices/src/test/java/lab/slices/failing/PlainComponentInJsonFailure.java b/spring-test-slices/src/test/java/lab/slices/failing/PlainComponentInJsonFailure.java new file mode 100644 index 0000000..e2c9382 --- /dev/null +++ b/spring-test-slices/src/test/java/lab/slices/failing/PlainComponentInJsonFailure.java @@ -0,0 +1,19 @@ +package lab.slices.failing; + +import lab.slices.AuditLogger; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.json.JsonTest; + +/** Fails on purpose: a plain @Component is not part of the @JsonTest slice. */ +@Tag("failure-demo") +@JsonTest +public class PlainComponentInJsonFailure { + @Autowired + AuditLogger audit; + + @Test + void neverRuns() { + } +} diff --git a/spring-test-slices/src/test/java/lab/slices/failing/RepositoryInWebMvcFailure.java b/spring-test-slices/src/test/java/lab/slices/failing/RepositoryInWebMvcFailure.java new file mode 100644 index 0000000..4d8d5db --- /dev/null +++ b/spring-test-slices/src/test/java/lab/slices/failing/RepositoryInWebMvcFailure.java @@ -0,0 +1,25 @@ +package lab.slices.failing; + +import lab.slices.BookController; +import lab.slices.BookRepository; +import lab.slices.BookService; +import org.junit.jupiter.api.Tag; +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; + +/** Fails on purpose: the service is mocked, but the test also asks for a repository the web slice never creates. */ +@Tag("failure-demo") +@WebMvcTest(BookController.class) +public class RepositoryInWebMvcFailure { + @MockitoBean + BookService service; + + @Autowired + BookRepository repository; + + @Test + void neverRuns() { + } +} diff --git a/spring-test-slices/src/test/java/lab/slices/failing/ServiceInDataJpaFailure.java b/spring-test-slices/src/test/java/lab/slices/failing/ServiceInDataJpaFailure.java new file mode 100644 index 0000000..e2c5cb7 --- /dev/null +++ b/spring-test-slices/src/test/java/lab/slices/failing/ServiceInDataJpaFailure.java @@ -0,0 +1,19 @@ +package lab.slices.failing; + +import lab.slices.BookService; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; + +/** Fails on purpose: @DataJpaTest does not scan @Service classes. */ +@Tag("failure-demo") +@DataJpaTest +public class ServiceInDataJpaFailure { + @Autowired + BookService service; + + @Test + void neverRuns() { + } +}