@SpringBootTest Internals: Application Context Caching, Slice Tests, and Why Your Test Suite Gets Slower Over Time
@SpringBootTest Internals
Every Spring Boot test class that uses @SpringBootTest starts an entire application context. This is not a lightweight operation. The framework scans packages, creates beans, initializes embedded databases, connects to message brokers, and runs auto-configuration. In the SaaS backend, a full context startup takes 8 to 15 seconds depending on the module. Multiply that by the number of unique contexts your test suite creates, and you discover why a 200-test suite takes 25 minutes.
The fix requires understanding what Spring actually does when it sees @SpringBootTest, how the context cache works, what breaks the cache, and when to use slice tests instead.
How @SpringBootTest Triggers Context Creation
@SpringBootTest is a composed annotation. It carries @ExtendWith(SpringExtension.class) to integrate with JUnit 5, and @BootstrapWith(SpringBootTestContextBootstrapper.class) to control how the test context is built.
When JUnit encounters a test class annotated with @SpringBootTest, the execution flows through these steps:
SpringExtension.beforeAll()callsTestContextManager.beforeTestClass().- The
TestContextManagerdelegates toSpringBootTestContextBootstrapperto build aMergedContextConfiguration. - The bootstrapper locates the
@SpringBootConfigurationclass (your main application class) via package scanning. - It merges properties from
@SpringBootTest(properties = ...), active profiles, and context customizers. - The resulting
MergedContextConfigurationis used as a cache key to look up an existing context. - If no cached context exists,
SpringBootContextLoader.loadContext()creates a newSpringApplication, runs it, and stores the context in the cache.
// What @SpringBootTest actually triggers
@SpringBootTest(
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
properties = "spring.datasource.url=jdbc:h2:mem:testdb"
)
class OrderServiceIntegrationTest {
@Autowired
private OrderService orderService;
@Test
void shouldCreateOrder() {
// By the time this runs, the full ApplicationContext is alive.
// Every bean in the SaaS backend has been created.
Order order = orderService.createOrder(
new CreateOrderRequest("tenant-1", "SKU-100", 5)
);
assertThat(order.getId()).isNotNull();
}
}
The SpringBootContextLoader calls SpringApplication.run() with the test class configuration. This triggers the exact same startup sequence as production: auto-configuration, component scanning, bean post-processing. The only difference is the test property sources layered on top.
Context Caching: Keyed by MergedContextConfiguration
Spring’s DefaultCacheAwareContextLoaderDelegate maintains a static Map<MergedContextConfiguration, ApplicationContext> across all test classes in the JVM. The cache lives for the entire test suite execution.
MergedContextConfiguration equality depends on:
- Configuration classes (or XML locations)
- Active profiles
- Property sources (including
@SpringBootTest(properties = ...)) - Context customizers (added by
@MockBean,@SpyBean,@DynamicPropertySource) - Context initializers
- Context loader class
Two test classes that produce identical MergedContextConfiguration share the same context. The second test class skips the entire startup sequence and reuses the already-running context.
// These two test classes share ONE context
@SpringBootTest
class OrderServiceTest {
@Autowired OrderService orderService;
// ...
}
@SpringBootTest
class InventoryServiceTest {
@Autowired InventoryService inventoryService;
// ...
}
Both classes use the same @SpringBootConfiguration, no extra properties, no profiles, no mocks. They produce identical cache keys. The framework boots the context once.
What Invalidates the Cache
Three mechanisms cause new contexts to be created.
@MockBean Changes the Cache Key
@MockBean registers a MockitoContextCustomizer that modifies the MergedContextConfiguration. Each unique set of mocked beans produces a different cache key.
// Context 1: mocks PaymentGateway
@SpringBootTest
class OrderPaymentTest {
@MockBean PaymentGateway paymentGateway;
// ...
}
// Context 2: mocks NotificationService
@SpringBootTest
class OrderNotificationTest {
@MockBean NotificationService notificationService;
// ...
}
// Context 3: mocks both
@SpringBootTest
class OrderFullTest {
@MockBean PaymentGateway paymentGateway;
@MockBean NotificationService notificationService;
// ...
}
Three test classes. Three contexts. Three full application startups. Each takes 10 seconds. That is 30 seconds before a single assertion runs.
@DirtiesContext Forces Recreation
@DirtiesContext marks the context as dirty after the test class (or method) completes. The framework closes the context and removes it from the cache. The next test class that needs the same configuration must create a fresh context.
@SpringBootTest
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
class TenantIsolationTest {
// After this class finishes, the context is destroyed.
// Any subsequent test needing this configuration pays the startup cost again.
}
Different Properties or Profiles
// Context A
@SpringBootTest(properties = "feature.new-pricing=true")
class NewPricingTest { }
// Context B
@SpringBootTest(properties = "feature.new-pricing=false")
class OldPricingTest { }
// Context C
@ActiveProfiles("staging")
@SpringBootTest
class StagingTest { }
Each produces a different MergedContextConfiguration. Three more contexts.
The Real Cost: A Debuggable Demonstration
Consider the SaaS backend test suite with 200 test classes. The team has been writing tests without coordinating mock usage:
Test Suite Analysis (actual output from ContextCacheStatistics):
- Total test classes: 200
- Unique ApplicationContexts created: 15
- Context creation time (average): 10.2 seconds
- Total startup overhead: 153 seconds (2.5 minutes)
- Cache hit rate: 92.5%
Fifteen contexts sounds manageable. But this suite started with 3 contexts six months ago. Every sprint adds test classes with slightly different @MockBean combinations. The trend line points to 30 contexts by year-end.
To see your own numbers, add this to your test configuration:
@SpringBootTest
class ContextCacheDebugTest {
@Autowired
private ApplicationContext context;
@Test
void printCacheStatistics() {
// Access the context cache via reflection (for diagnostics only)
DefaultContextCache cache = getContextCache();
System.out.println("Context cache size: " + cache.size());
System.out.println("Hit count: " + cache.getHitCount());
System.out.println("Miss count: " + cache.getMissCount());
System.out.println(
"Hit rate: " +
(cache.getHitCount() * 100.0 /
(cache.getHitCount() + cache.getMissCount())) + "%"
);
}
}
Enable debug logging to see every cache miss:
# application-test.properties
logging.level.org.springframework.test.context.cache=DEBUG
The log output reveals exactly which test class triggered a new context and why:
DEBUG DefaultCacheAwareContextLoaderDelegate -
Retrieved ApplicationContext [id=1] from cache with key
[[MergedContextConfiguration@abc123 ...]]
DEBUG DefaultCacheAwareContextLoaderDelegate -
Storing ApplicationContext [id=2] in cache with key
[[MergedContextConfiguration@def456
contextCustomizers=[MockitoContextCustomizer{mocks=[
MockDefinition{name='paymentGateway', ...}
]}]
]]
The Failure Mode
// BROKEN: every test class creates its own context
@SpringBootTest
class OrderCreationTest {
@MockBean PaymentGateway paymentGateway;
@MockBean InventoryClient inventoryClient;
// 2 mocks -> context key A
}
@SpringBootTest
class OrderCancellationTest {
@MockBean PaymentGateway paymentGateway;
@MockBean NotificationService notificationService;
// 2 mocks, different set -> context key B
}
@SpringBootTest
class OrderRetryTest {
@MockBean PaymentGateway paymentGateway;
@MockBean RetryableTaskExecutor retryExecutor;
// 2 mocks, different set -> context key C
}
@SpringBootTest
class OrderAuditTest {
@MockBean AuditService auditService;
// 1 mock -> context key D
}
// 50 more classes, each with a slightly different @MockBean combination.
// Result: 54 unique contexts. 54 * 10 seconds = 9 minutes of startup.
The team does not notice at first. Each test runs in under a second. But the suite takes 12 minutes. Developers stop running the full suite locally. CI becomes the only feedback loop. CI takes 25 minutes. Merge queues back up.
The Correct Pattern
// CORRECT: shared test configuration with all mocks in one place
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Import(SharedMockConfiguration.class)
public @interface SaaSIntegrationTest {
}
@TestConfiguration
public class SharedMockConfiguration {
@Bean
public PaymentGateway paymentGateway() {
return Mockito.mock(PaymentGateway.class);
}
@Bean
public NotificationService notificationService() {
return Mockito.mock(NotificationService.class);
}
@Bean
public InventoryClient inventoryClient() {
return Mockito.mock(InventoryClient.class);
}
@Bean
public AuditService auditService() {
return Mockito.mock(AuditService.class);
}
@Bean
public RetryableTaskExecutor retryableTaskExecutor() {
return Mockito.mock(RetryableTaskExecutor.class);
}
}
Every test class uses the same composed annotation:
@SaaSIntegrationTest
class OrderCreationTest {
@Autowired PaymentGateway paymentGateway;
@Autowired InventoryClient inventoryClient;
@BeforeEach
void resetMocks() {
Mockito.reset(paymentGateway, inventoryClient);
}
@Test
void shouldChargeAndDeductInventory() {
when(paymentGateway.charge(any())).thenReturn(ChargeResult.success());
when(inventoryClient.reserve(any())).thenReturn(true);
Order order = orderService.createOrder(
new CreateOrderRequest("tenant-1", "SKU-100", 5)
);
assertThat(order.getStatus()).isEqualTo(OrderStatus.CONFIRMED);
}
}
@SaaSIntegrationTest
class OrderCancellationTest {
@Autowired PaymentGateway paymentGateway;
@Autowired NotificationService notificationService;
@BeforeEach
void resetMocks() {
Mockito.reset(paymentGateway, notificationService);
}
@Test
void shouldRefundAndNotify() {
when(paymentGateway.refund(any())).thenReturn(RefundResult.success());
orderService.cancelOrder("tenant-1", "order-42");
verify(notificationService).sendCancellation(eq("tenant-1"), any());
}
}
All 54 test classes now share a single context. Startup happens once. The suite runs in 2 minutes instead of 12.
The key insight: @MockBean modifies the context key because it changes bean definitions. A @TestConfiguration with manually created mocks does not. The mock beans are part of the configuration class, which is the same across all test classes. Same configuration, same cache key, same context.
Slice Tests: The Targeted Alternative
Full integration tests are not always necessary. When testing only the web layer, or only JPA repositories, slice tests load a subset of auto-configuration:
| Annotation | What it loads | What it skips |
|---|---|---|
@WebMvcTest | Controllers, filters, MockMvc | Services, repositories, data sources |
@DataJpaTest | JPA, Hibernate, embedded DB | Controllers, services, security |
@WebFluxTest | WebFlux controllers, WebTestClient | Everything else |
@JdbcTest | JDBC, embedded DB | JPA, web, services |
@JsonTest | Jackson ObjectMapper | Everything else |
A slice test context starts in 1 to 3 seconds compared to 10 to 15 for a full context. Slice tests also form their own cache groups. A @WebMvcTest(OrderController.class) and @WebMvcTest(InventoryController.class) produce different cache keys because they target different controllers. But each slice context is cheap to create.
The next sections cover context caching mechanics in detail (CH23-S1) and slice test patterns with Testcontainers (CH23-S2).