--- name: spring-testing description: "Write, repair, and modernise tests in an existing Spring Boot 4 project (Maven or Gradle) — Testcontainers 2.x, integration vs unit routing, and the Boot 4 test API (@MockitoBean, MockMvcTester, RestTestClient). Use when asked to add or fix tests, set up Testcontainers, or migrate off Testcontainers 1.x or @MockBean." --- # Spring Testing Skill Owns the testing story for a project that already exists. `spring-scaffold` generates a `BaseIntegrationTest` for new projects and stops there — this skill is what you run afterwards, on every project, for the rest of its life. `SKILL_DIR` = the directory containing this SKILL.md. **Load before doing anything:** - `SKILL_DIR/references/containers.md` — Testcontainers 2.x coordinates, class packages, pinned images, and the commands that verify them. The single source of truth for versions. - `SKILL_DIR/references/conventions.md` — what gets a unit vs integration vs slice test, the Boot 4 API replacements, mocking rules, isolation, flakiness. Never add a test dependency without naming it and saying why. AssertJ, Mockito, JUnit 5 and Awaitility already arrive with the Boot test starters — check before reaching for anything. --- ## Step 0 — Read the project ```bash ls pom.xml build.gradle build.gradle.kts 2>/dev/null grep -m1 -A1 'spring-boot-starter-parent' pom.xml find src/test -name '*.java' | head -30 ``` Establish: build tool, Boot version, whether a `BaseIntegrationTest` (or equivalent) already exists, and what the existing tests actually do. Match what's there — do not restructure someone's suite as a side effect of adding one test. If the project is not on Boot 4, say so and stop before applying Boot 4 API guidance; the replacements in `conventions.md` do not exist on 3.x. --- ## Step 1 — Audit before writing Run all five. Each hit is a finding to report, and the first two are the difference between a suite that is on Testcontainers 2.x and one that only looks like it. ```bash # 1. Testcontainers 1.x coordinates (they resolve, they just stop at 1.21.4 forever) grep -n -A1 'org.testcontainers' pom.xml \ | grep -E '(junit-jupiter|postgresql|kafka|rabbitmq|mongodb|localstack|grafana|mysql|elasticsearch)' || true # 2. deprecated 1.x container imports still compiling against the 2.x shims grep -rn --include=*.java 'org.testcontainers.containers.[A-Z]' src/test/java \ | grep -v GenericContainer || true # 3. APIs removed in Boot 4 grep -rn --include=*.java -e '@MockBean' -e '@SpyBean' -e 'boot.test.web.client.TestRestTemplate' src/test/java || true # 4. unpinned or floating images grep -rn --include=*.java -e ':latest' -e 'DockerImageName.parse("[a-z/]*")' src/test/java || true # 5. JUnit-managed containers in a shared base class (breaks once a second class reuses the context) grep -rln --include=*.java '@Container' src/test/java | xargs -r grep -l 'abstract class' || true ``` Then confirm the resolved Testcontainers version against the BOM using the commands in `references/containers.md` — never pin `testcontainers.version` by hand to fix a mismatch; that is a Boot upgrade. Report the findings as a short list before touching code. Fix them as part of the work only if the user asked for tests in that area; otherwise flag and leave them. --- ## Step 2 — Route the work Use the routing table in `references/conventions.md`. The short version: - touches the database, a broker, or S3 → **integration test with a real container** - pure logic, no IO → **unit test, no Spring context** - HTTP contract only → **`@WebMvcTest` slice with `MockMvcTester`** - outbound HTTP client → **`@RestClientTest` slice with `MockRestServiceServer`** State which kind you're writing and why in one line, then write it. --- ## Step 3 — Ensure a base class exists Every integration test extends one base class that imports one container configuration. If the project has none, create both next to the application class in `src/test/java`: ```java @TestConfiguration(proxyBeanMethods = false) public class IntegrationTestContainers { @Bean @ServiceConnection PostgreSQLContainer postgresContainer() { return new PostgreSQLContainer(DockerImageName.parse("postgres:18-alpine")); } } ``` ```java @SpringBootTest( webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, properties = { "management.otlp.metrics.export.enabled=false", "management.tracing.export.enabled=false", "management.logging.export.otlp.enabled=false" }) @Import(IntegrationTestContainers.class) public abstract class BaseIntegrationTest { } ``` **Containers are Spring beans, never `static @Container` fields in a shared base class.** The JUnit `@Testcontainers` extension stops a static `@Container` after each test class, while Spring caches the application context across classes — the next class reuses a context pointing at a stopped container and fails with `Connection refused`. As a `@Bean`, the container lives exactly as long as the cached context. A base class that already declares a `static @Container` (audit check 5) is a latent failure: report it, and move the container into `IntegrationTestContainers` before adding a second integration test class — that is the moment it breaks. The `properties` disable OTLP export so tests don't warn against a collector that isn't running. Telemetry is exercised in dev via `./mvnw spring-boot:test-run`, not in the test suite — the Grafana LGTM container never belongs in `IntegrationTestContainers`. For any other backing service, add a `@Bean @ServiceConnection` method to `IntegrationTestContainers` with the class, artifact, and pinned tag from `references/containers.md`. One configuration per project; every distinct set of imports is a separate cached context and a separate set of containers. --- ## Step 4 — Write the tests **Repository / persistence:** ```java class JobRepositoryIntegrationTest extends BaseIntegrationTest { @Autowired private JobRepository jobRepository; @Test @Sql("/sql/three-open-jobs.sql") void should_return_only_open_jobs_when_filtering_by_status() { var jobs = jobRepository.findByStatus(JobStatus.OPEN); assertThat(jobs).hasSize(3).allMatch(job -> job.getStatus() == JobStatus.OPEN); } } ``` Field injection is banned in `src/main`; `@Autowired` on a test field is the accepted exception — JUnit constructs the test class, so there is no constructor to inject through. **HTTP contract, no database — `MockMvcTester`, not raw `MockMvc` matchers:** ```java @WebMvcTest(JobController.class) class JobControllerTest { @Autowired private MockMvcTester mvc; @MockitoBean private JobService jobService; @Test void should_return_400_with_problem_detail_when_title_is_blank() { assertThat(mvc.post().uri("/api/jobs").contentType(APPLICATION_JSON).content(""" {"title": "", "description": "x"} """)) .hasStatus(HttpStatus.BAD_REQUEST) .bodyJson().extractingPath("$.title").isEqualTo("Validation failed"); } } ``` **Full flow over real HTTP — `RestTestClient`, the `RestClient`-era replacement for `TestRestTemplate`:** ```java @AutoConfigureRestTestClient class JobApiIntegrationTest extends BaseIntegrationTest { @Autowired private RestTestClient client; @Test void should_persist_and_return_created_job_when_request_is_valid() { client.post().uri("/api/jobs") .contentType(APPLICATION_JSON) .body(new JobRequest("Backend Engineer", "Spring Boot 4")) .exchange() .expectStatus().isCreated() .expectBody().jsonPath("$.title").isEqualTo("Backend Engineer"); } } ``` Rules that apply to all three: `should__when_` names, AssertJ assertions, one behaviour per test, no `@Transactional` on tests that go over HTTP, and error paths assert the `ProblemDetail` fields — status, title, detail — not just that something threw. --- ## Step 5 — Run and report ```bash ./mvnw test -Dtest='' # the new tests alone first ./mvnw test # then the whole suite — new tests must not break neighbours ``` First run pulls images; say so rather than letting it look hung. If Docker isn't running, stop and say exactly that — `Docker daemon not reachable; Testcontainers cannot start` — never fall back to an embedded database to make the run go green. Lead with the verdict, then the board: ``` 3 tests added · suite green · 1 setup finding ━━━ SPRING-TESTING ━━━━━━━━━━━━━━━━━━━━━━━━━ Setup audit ........... ⚠ 1 pom.xml:71 org.testcontainers:junit-jupiter → testcontainers-junit-jupiter Tests written ......... 3 (1 integration, 2 unit) New tests ............. ✅ 3 passing Full suite ............ ✅ 47 passing · 0 failed · 0 skipped Runtime ............... 18s (was 11s — one new container class) ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ Next: fix the 1.x coordinate in pom.xml:71, then run a ship-check. ``` On failure show the failing assertion and its file:line, not the log — `… +180 more lines`, and say the full log is one `./mvnw test` away. Never report a suite green without having run it. Fix a failing test only when the test is what's wrong. A test that caught a real bug is the test doing its job — say so, name the bug, and let the user decide.