spring-test-slices: Spring Boot 4.1.2 test slices lab (@WebMvcTest, @JsonTest, @RestClientTest, @DataJpaTest vs @SpringBootTest)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_017ic4b7Jbw5CxUQ13D7gn25
This commit is contained in:
@@ -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 |
|
| [`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 |
|
| [`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 |
|
| [`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.
|
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 mockito && ./scripts/run-all.sh
|
||||||
cd ../junit-6-migration && ./scripts/run-all.sh
|
cd ../junit-6-migration && ./scripts/run-all.sh
|
||||||
cd ../assertj && ./scripts/run-all.sh
|
cd ../assertj && ./scripts/run-all.sh
|
||||||
|
cd ../spring-test-slices && ./scripts/run-all.sh
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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) |
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
@JsonTest with JacksonTester<BookView>
|
||||||
|
|
||||||
|
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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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 <module>.)
|
||||||
|
|
||||||
|
== MissingMockBeanFailure
|
||||||
|
exception chain: IllegalStateException -> UnsatisfiedDependencyException -> NoSuchBeanDefinitionException
|
||||||
|
top message : Failed to load ApplicationContext for
|
||||||
|
wiring failure : Error creating bean with name 'bookController' defined in file [<module>/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)}
|
||||||
|
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
@WebMvcTest + @EnableJpaAuditing (via @Import), BookService mocked
|
||||||
|
exception chain: IllegalStateException -> BeanCreationException -> BeanCreationException -> IllegalArgumentException
|
||||||
|
root cause : JPA metamodel must not be empty
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<parent>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-parent</artifactId>
|
||||||
|
<version>4.1.2</version>
|
||||||
|
<relativePath/>
|
||||||
|
</parent>
|
||||||
|
|
||||||
|
<groupId>com.ankurm.testing</groupId>
|
||||||
|
<artifactId>spring-test-slices-lab</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
|
||||||
|
<properties>
|
||||||
|
<java.version>25</java.version>
|
||||||
|
<maven.compiler.release>25</maven.compiler.release>
|
||||||
|
</properties>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<!-- production code -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-webmvc</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-restclient</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.h2database</groupId>
|
||||||
|
<artifactId>h2</artifactId>
|
||||||
|
<scope>runtime</scope>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Boot 4 splits the test support per technology: one test starter for each starter above -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-webmvc-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-data-jpa-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-restclient-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<!-- lets FailureCaptureTest run the deliberately broken test classes and read their exceptions -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.junit.platform</groupId>
|
||||||
|
<artifactId>junit-platform-launcher</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-surefire-plugin</artifactId>
|
||||||
|
<configuration>
|
||||||
|
<!-- the classes in lab.slices.failing fail on purpose; FailureCaptureTest runs them itself -->
|
||||||
|
<excludedGroups>failure-demo</excludedGroups>
|
||||||
|
<!-- OldImportsCompileTest hands java.class.path to javac, which needs the real classpath -->
|
||||||
|
<useManifestOnlyJar>false</useManifestOnlyJar>
|
||||||
|
<!-- ZMatrixTest reads files the slice tests wrote, so it has to run last -->
|
||||||
|
<runOrder>alphabetical</runOrder>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
</project>
|
||||||
Executable
+31
@@ -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 <TestClass> 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/"
|
||||||
+27
@@ -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 '/<artifactId>spring-boot-starter-parent<\/artifactId>/{n;s/.*<version>\(.*\)<\/version>.*/\1/p}' pom.xml)"
|
||||||
|
TMP=target/starter-test-only
|
||||||
|
rm -rf "$TMP" && mkdir -p "$TMP"
|
||||||
|
cat > "$TMP/pom.xml" <<POM
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0"><modelVersion>4.0.0</modelVersion>
|
||||||
|
<parent><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-parent</artifactId><version>${BOOT}</version><relativePath/></parent>
|
||||||
|
<groupId>probe</groupId><artifactId>starter-test-only</artifactId><version>1</version>
|
||||||
|
<dependencies>
|
||||||
|
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-webmvc</artifactId></dependency>
|
||||||
|
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-jpa</artifactId></dependency>
|
||||||
|
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-restclient</artifactId></dependency>
|
||||||
|
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency>
|
||||||
|
</dependencies>
|
||||||
|
</project>
|
||||||
|
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
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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<BookView> byAuthor(@RequestParam String author) {
|
||||||
|
return service.byAuthor(author).stream().map(BookView::of).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/import/{isbn}")
|
||||||
|
ResponseEntity<BookView> importBook(@PathVariable String isbn) {
|
||||||
|
return service.importByIsbn(isbn)
|
||||||
|
.map(b -> ResponseEntity.ok(BookView.of(b)))
|
||||||
|
.orElse(ResponseEntity.notFound().build());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package lab.slices;
|
||||||
|
|
||||||
|
public class BookNotFoundException extends RuntimeException {
|
||||||
|
public BookNotFoundException(Long id) {
|
||||||
|
super("No book with id " + id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Book, Long> {
|
||||||
|
List<Book> findByAuthorIgnoreCaseOrderByTitle(String author);
|
||||||
|
|
||||||
|
Optional<Book> findByIsbn(String isbn);
|
||||||
|
}
|
||||||
@@ -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<Book> 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<Book> 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)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
package lab.slices;
|
||||||
|
|
||||||
|
public record IsbnInfo(String isbn, String title, String author, int pages) {
|
||||||
|
}
|
||||||
@@ -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<IsbnInfo> lookup(String isbn) {
|
||||||
|
try {
|
||||||
|
return Optional.ofNullable(client.get().uri("/isbn/{isbn}", isbn).retrieve().body(IsbnInfo.class));
|
||||||
|
} catch (HttpClientErrorException.NotFound e) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
}
|
||||||
@@ -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<Price> {
|
||||||
|
@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<Price> {
|
||||||
|
@Override
|
||||||
|
public Price deserialize(JsonParser p, DeserializationContext ctxt) {
|
||||||
|
String[] parts = p.getString().split(" ");
|
||||||
|
return new Price(parts[0], new BigDecimal(parts[1]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
isbn.base-url=https://isbn.example.test
|
||||||
|
spring.jpa.open-in-view=false
|
||||||
@@ -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<String, Class<?>> 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<String> record(String slice, ApplicationContext ctx) {
|
||||||
|
List<String> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Throwable> 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<String> 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 <module>.)")
|
||||||
|
.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"), "<module>");
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<BookView> 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<BookView>")
|
||||||
|
.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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<JavaFileObject>();
|
||||||
|
Path out = Files.createTempDirectory("broken-classes");
|
||||||
|
try (var fm = compiler.getStandardFileManager(diagnostics, Locale.ENGLISH, null);
|
||||||
|
Stream<Path> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String, String[]> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String> slices = List.of("WebMvcTest", "JsonTest", "RestClientTest", "DataJpaTest", "SpringBootTest");
|
||||||
|
Map<String, Map<String, String>> byslice = new LinkedHashMap<>();
|
||||||
|
for (String s : slices) {
|
||||||
|
Map<String, String> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+19
@@ -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() {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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() {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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() {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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() {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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() {
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user