You're the tech lead on a service whose test suite takes 12 minutes, dominated by dozens of @SpringBootTest classes. How do you diagnose and cut the cost without losing coverage?
answer
- diagnose = count contexts built, cache hit/miss logs
- root cause = context proliferation, not slow bodies
- slices + shared base class => few cache keys
- kill needless @DirtiesContext
- reserve @SpringBootTest for real e2e; then parallelize
basics
~20 sMeasure how many distinct contexts are being built, convert most @SpringBootTest classes to slices or a shared base config, eliminate needless @DirtiesContext and per-class property/mock variations so tests reuse cached contexts, and reserve full @SpringBootTest for a few genuine end-to-end tests.
solid answer
~40 sFirst diagnose: enable context-startup logging / count distinct `MergedContextConfiguration`s (each rebuild logs a context start). The usual finding is *context proliferation* — many almost-identical `@SpringBootTest` configs that can't share the cache because of scattered `@MockBean` sets, `@TestPropertySource`, per-class profiles, or `@DirtiesContext`. Remedies, in order of impact: (1) push layer tests down to **slices** (`@WebMvcTest`, `@DataJpaTest`, `@JsonTest`) — they're cheaper *and* cluster into few shared contexts; (2) introduce a **shared abstract base test config** so full-context tests converge on one cache key; (3) remove `@DirtiesContext` unless a test truly dirties singletons, replacing it with proper rollback/mock reset; (4) consolidate mock/property variations; (5) keep true e2e `@SpringBootTest(RANDOM_PORT)` to a small, deliberate set. Parallelize what's left. Measure before/after by counting contexts and wall-clock.
code
java · 21 lines// Funnel all full-context tests through ONE shared config => one cached context.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test")
@Testcontainers
abstract class AbstractIntegrationTest {
@Container
static final PostgreSQLContainer<?> DB =
new PostgreSQLContainer<>("postgres:16"); // static => shared across subclasses
@DynamicPropertySource
static void props(DynamicPropertyRegistry r) {
r.add("spring.datasource.url", DB::getJdbcUrl);
r.add("spring.datasource.username", DB::getUsername);
r.add("spring.datasource.password", DB::getPassword);
}
}
// Subclasses add NO new @MockBean/@TestPropertySource -> identical cache key -> reuse.
class OrderFlowIT extends AbstractIntegrationTest { /* ... */ }
class PaymentFlowIT extends AbstractIntegrationTest { /* ... */ }go deeper
Would likely just suggest 'use slices' without diagnosis.
Converts tests to slices and removes obvious @DirtiesContext but may miss context-key consolidation.
Diagnoses via context counts, consolidates configs, balances slice vs full coverage.
Owns a suite-wide strategy: measurement, shared base configs, conventions/guardrails to prevent regression, and an explicit speed-vs-confidence trade-off.
## Diagnose before you cut The symptom ("12 minutes") is almost always **too many distinct application contexts being built**, not slow test bodies. Quantify it: - Turn on context lifecycle logging: each fresh context logs a Spring Boot **startup banner / "Started ... in X seconds"**. Count how many times you see it — that's how many contexts were built. - Enable `logging.level.org.springframework.test.context.cache=DEBUG` to see **cache hit/miss/size** statistics (the cache logs stats like size, hitCount, missCount, parentContextCount). - The ideal end-state: **N distinct configs ≪ number of test classes**, with a high hit ratio. Common root causes of proliferation: - Every test annotated `@SpringBootTest` with a slightly different `@MockBean` set → each is a unique cache key. - Per-class `@TestPropertySource` / inlined `properties`. - Scattered `@ActiveProfiles`. - `@DirtiesContext` closing/rebuilding contexts. - Exceeding the default **cache maxSize = 32** → LRU thrash rebuilding the same contexts. ## Remedies, ranked by leverage 1. **Convert layer tests to slices.** A controller mapping test doesn't need the whole app — `@WebMvcTest` is faster and, because slices share configuration, many of them reuse **one** cached web context. Same for `@DataJpaTest`, `@JsonTest`, `@RestClientTest`. 2. **One shared full-context configuration.** For tests that genuinely need `@SpringBootTest`, funnel them through a common **abstract base class** with the same profile, the same properties, and (ideally) a shared, static Testcontainers instance via `@DynamicPropertySource`. Identical keys ⇒ one cached context reused across all of them. 3. **Eliminate needless `@DirtiesContext`.** It closes the context and forces a rebuild — expensive. Replace with cheaper isolation: `@Transactional` rollback, resetting mocks (`Mockito.reset` / fresh `@MockBean` semantics), or clearing caches in `@AfterEach`. Keep it only where singleton state is truly and irreversibly mutated. 4. **Consolidate mock/property variation.** Move `@MockBean` into the base config where the same mocks recur; parameterize test data instead of forking configs. Fewer unique keys ⇒ better reuse. 5. **Right-size and monitor the cache.** If you legitimately have many configs, raise `spring.test.context.cache.maxSize`; but prefer *reducing* the number of configs so you stay under 32 without thrash. 6. **Parallelize** the residual suite (JUnit 5 parallel execution / Gradle `maxParallelForks`) — but only after cutting context count, since parallel forks each build their own cache. 7. **Reserve real e2e** `@SpringBootTest(webEnvironment = RANDOM_PORT)` for a small, curated set that validates wiring and HTTP round-trips. ## Guardrails so it doesn't regress - A **testing convention doc / ArchUnit-style check**: slices by default, `@SpringBootTest` requires justification. - Track **context count** as a CI signal; alert if it grows. - Standardize base classes so new tests inherit a cache-friendly config. ## Trade-offs to acknowledge - Slices give *less* integration confidence than full context — keep enough `@SpringBootTest` coverage for true wiring/regression risk. - Sharing one context across many tests means tests must avoid mutating shared singletons (or you're back to `@DirtiesContext`). - Embedded H2 in `@DataJpaTest` can mask real-DB behavior; balance speed vs. fidelity (Testcontainers with `Replace.NONE`). ## Expected outcome Collapsing dozens of unique full contexts into a handful of shared slice/base configs typically turns minutes of repeated startup into a single build each — often cutting suite time by more than half — with coverage preserved because you moved tests to the *appropriate* level rather than deleting them.
- How would you actually measure the number of contexts your suite builds?Count Spring Boot 'Started … in Xs' startup log lines across the run, and enable DEBUG on org.springframework.test.context.cache to read the cache's size/hitCount/missCount statistics. A high miss count and many startups indicate context proliferation.
- What's the risk of collapsing everything into slices?You lose full-wiring integration confidence — slices mock away layers and skip auto-config, so genuine wiring/config regressions can slip through. Keep a curated set of full @SpringBootTest / RANDOM_PORT e2e tests for that risk.
saying these in an interview costs you the question
- Jumping to parallelization first without reducing context count (each fork rebuilds its own contexts).
- Assuming the fix is faster hardware rather than fewer/shared contexts.
- Deleting integration tests to save time instead of moving them to the right level.
- Adding @DirtiesContext to 'be safe', which makes the suite slower.