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:
Claude
2026-11-14 15:26:55 +05:30
parent c92977b23d
commit d7497c8ab1
53 changed files with 1547 additions and 0 deletions
+45
View File
@@ -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
+87
View File
@@ -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>
+31
View File
@@ -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
View File
@@ -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/&lt;slice&gt;.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);
}
}
}
@@ -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() {
}
}