Context Caching and @MockBean Cache Invalidation
Context Caching and @MockBean Cache Invalidation
The Spring TestContext Framework caches application contexts to avoid paying the startup cost for every test class. The cache is a static ConcurrentHashMap inside DefaultContextCache, keyed by MergedContextConfiguration. Understanding this key is the difference between a test suite that finishes in 2 minutes and one that takes 20.
The MergedContextConfiguration Cache Key
MergedContextConfiguration captures every aspect of how a test context is configured. Its equals() and hashCode() methods compare:
// Fields that determine cache key equality
public class MergedContextConfiguration {
private final Class<?>[] classes; // @Configuration classes
private final String[] locations; // XML locations (legacy)
private final Set<Class<? extends
ApplicationContextInitializer<?>>>
contextInitializerClasses;
private final String[] activeProfiles; // @ActiveProfiles
private final String[] propertySourceDescriptors;
private final Set<ContextCustomizer>
contextCustomizers; // @MockBean, @DynamicPropertySource
private final ContextLoader contextLoader;
}
The critical field is contextCustomizers. This is where @MockBean enters the picture.
When Spring processes a test class, it collects ContextCustomizerFactory implementations via spring.factories. MockitoContextCustomizerFactory scans the test class for @MockBean and @SpyBean annotations. It creates a MockitoContextCustomizer containing the set of MockDefinition objects. This customizer is added to the contextCustomizers set in MergedContextConfiguration.
Two test classes with different @MockBean sets produce different MockitoContextCustomizer instances. Different customizers mean different cache keys. Different cache keys mean different contexts.
Tracing the Cache Key Construction
Follow the call chain from test execution to cache lookup:
JUnit Platform
-> SpringExtension.beforeAll()
-> TestContextManager.beforeTestClass()
-> DefaultTestContext.getApplicationContext()
-> DefaultCacheAwareContextLoaderDelegate
.loadContext(MergedContextConfiguration)
-> DefaultContextCache.get(key)
-> if miss: contextLoader.loadContext()
-> cache.put(key, context)
The DefaultContextCache logs every hit and miss at DEBUG level:
logging.level.org.springframework.test.context.cache=DEBUG
Output on a cache miss:
Spring test ApplicationContext cache statistics:
[DefaultContextCache@1a2b3c4d size=3, maxSize=32,
parentContextCount=0, hitCount=7, missCount=3,
failCount=0]
Every miss is a new context. Every new context is 10 seconds of startup.
How @MockBean Modifies the Cache Key
Consider the SaaS backend’s PaymentGateway interface. Two test classes mock it differently:
@SpringBootTest
class OrderPaymentTest {
@MockBean
private PaymentGateway paymentGateway;
// MockDefinition set: {PaymentGateway}
}
@SpringBootTest
class OrderFullFlowTest {
@MockBean
private PaymentGateway paymentGateway;
@MockBean
private InventoryClient inventoryClient;
// MockDefinition set: {PaymentGateway, InventoryClient}
}
The MockitoContextCustomizer for OrderPaymentTest contains one MockDefinition. The customizer for OrderFullFlowTest contains two. These are not equal. The cache produces two separate contexts.
The mechanism works at the BeanDefinition level. When the context starts, MockitoPostProcessor (a BeanFactoryPostProcessor) replaces the original BeanDefinition for the mocked type with a new definition that creates a Mockito mock. This is a destructive modification. The original bean definition is gone. The bean factory now produces a mock instead of the real implementation.
This is why @MockBean cannot share a context with a non-mocked version: the bean definition itself has changed. A test class that expects the real PaymentGateway cannot coexist in the same context as a test class that mocked it.
// Inside MockitoPostProcessor.postProcessBeanFactory()
public void postProcessBeanFactory(
ConfigurableListableBeanFactory beanFactory) {
for (MockDefinition definition : this.definitions) {
// Find the existing bean definition
String beanName = getBeanNameForMock(beanFactory, definition);
// Replace it with a mock-producing definition
BeanDefinition existingDef =
beanFactory.getBeanDefinition(beanName);
RootBeanDefinition mockDef = createMockBeanDefinition(definition);
// The original bean is gone. The factory now produces a mock.
beanFactory.registerBeanDefinition(beanName, mockDef);
}
}
The Combinatorial Explosion
In the SaaS backend, six services are commonly mocked in integration tests:
PaymentGatewayNotificationServiceInventoryClientAuditServiceTenantResolverEmailDispatcher
The theoretical maximum of unique @MockBean combinations from 6 services is $2^6 - 1 = 63$. In practice, teams typically create 10 to 20 unique combinations. Each combination is a separate context.
// BROKEN: scattered @MockBean across test classes
// Context 1: {PaymentGateway}
@SpringBootTest
class PaymentFlowTest {
@MockBean PaymentGateway paymentGateway;
}
// Context 2: {PaymentGateway, NotificationService}
@SpringBootTest
class PaymentNotificationTest {
@MockBean PaymentGateway paymentGateway;
@MockBean NotificationService notificationService;
}
// Context 3: {NotificationService}
@SpringBootTest
class NotificationTest {
@MockBean NotificationService notificationService;
}
// Context 4: {PaymentGateway, AuditService}
@SpringBootTest
class AuditedPaymentTest {
@MockBean PaymentGateway paymentGateway;
@MockBean AuditService auditService;
}
// Context 5: {InventoryClient, TenantResolver}
@SpringBootTest
class TenantInventoryTest {
@MockBean InventoryClient inventoryClient;
@MockBean TenantResolver tenantResolver;
}
// 15 more combinations across 200 test classes...
// Total: 20 contexts * 10 seconds = 200 seconds of startup overhead
The fix is not to avoid mocking. The fix is to standardize which mocks every integration test uses.
Strategy 1: Shared Base Test Class
The simplest approach consolidates all @MockBean declarations in a single base class:
// CORRECT: one base class, one context
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public abstract class BaseIntegrationTest {
@MockBean
protected PaymentGateway paymentGateway;
@MockBean
protected NotificationService notificationService;
@MockBean
protected InventoryClient inventoryClient;
@MockBean
protected AuditService auditService;
@MockBean
protected TenantResolver tenantResolver;
@MockBean
protected EmailDispatcher emailDispatcher;
@BeforeEach
void resetAllMocks() {
Mockito.reset(
paymentGateway, notificationService,
inventoryClient, auditService,
tenantResolver, emailDispatcher
);
}
}
Every test class extends it:
class OrderPaymentTest extends BaseIntegrationTest {
@Autowired
private OrderService orderService;
@Test
void shouldProcessPayment() {
when(paymentGateway.charge(any()))
.thenReturn(ChargeResult.success());
when(tenantResolver.resolve(any()))
.thenReturn(new Tenant("tenant-1"));
Order order = orderService.createOrder(
new CreateOrderRequest("tenant-1", "SKU-100", 5)
);
assertThat(order.getStatus()).isEqualTo(OrderStatus.CONFIRMED);
}
}
All test classes produce the same MockitoContextCustomizer because the @MockBean set is identical (inherited from the base class). One context for the entire suite.
The drawback: every test has access to mocks it does not use. Unused mocks return default values (null, 0, false). If a code path unexpectedly calls a mock you did not configure, the test may pass silently with a null. Add Mockito.verifyNoMoreInteractions() in @AfterEach for critical mocks.
Strategy 2: @TestConfiguration with Manual Mocks
A more explicit approach avoids @MockBean entirely. Instead, a @TestConfiguration class defines mock beans manually:
// CORRECT: @TestConfiguration with manually created mocks
@TestConfiguration
public class IntegrationTestMocks {
@Bean
@Primary
public PaymentGateway paymentGateway() {
return Mockito.mock(PaymentGateway.class);
}
@Bean
@Primary
public NotificationService notificationService() {
return Mockito.mock(NotificationService.class);
}
@Bean
@Primary
public InventoryClient inventoryClient() {
return Mockito.mock(InventoryClient.class);
}
}
Test classes import the configuration:
@SpringBootTest
@Import(IntegrationTestMocks.class)
class OrderPaymentTest {
@Autowired
private PaymentGateway paymentGateway; // this is the mock
@BeforeEach
void resetMocks() {
Mockito.reset(paymentGateway);
}
@Test
void shouldProcessPayment() {
when(paymentGateway.charge(any()))
.thenReturn(ChargeResult.success());
// ...
}
}
This approach has a significant advantage: @TestConfiguration with @Import does not use MockitoContextCustomizer. The mock beans are regular bean definitions from a configuration class. As long as every test class imports the same @TestConfiguration, the MergedContextConfiguration is identical. No MockitoContextCustomizer differences, no cache key divergence.
Use @Primary to ensure the mock takes precedence over the real bean. Without @Primary, Spring will see two beans of the same type and throw a NoUniqueBeanDefinitionException.
Strategy 3: Composed Test Annotation
Wrap the shared configuration in a custom annotation to prevent drift:
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Import(IntegrationTestMocks.class)
@ActiveProfiles("integration")
public @interface SaaSIntegrationTest {
}
Test classes use the annotation:
@SaaSIntegrationTest
class OrderPaymentTest {
@Autowired PaymentGateway paymentGateway;
// ...
}
@SaaSIntegrationTest
class OrderCancellationTest {
@Autowired PaymentGateway paymentGateway;
// ...
}
Every @SaaSIntegrationTest class produces the same cache key. One context, regardless of how many test classes exist.
Measuring the Impact
Before applying the strategies, measure your baseline:
@SpringBootTest
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class ContextCacheMetricsTest {
private static long suiteStartTime;
@BeforeAll
static void recordStart() {
suiteStartTime = System.currentTimeMillis();
}
@Test
@org.junit.jupiter.api.Order(Integer.MAX_VALUE)
void reportCacheStatistics() {
long elapsed = System.currentTimeMillis() - suiteStartTime;
// The cache is accessible via the TestContextManager internals
System.out.println("Suite elapsed: " + elapsed + "ms");
System.out.println(
"Check DEBUG logs for context cache size and hit rate"
);
}
}
After consolidation, typical results for the SaaS backend:
| Metric | Before | After |
|---|---|---|
| Unique contexts | 20 | 2 |
| Startup overhead | 200s | 20s |
| Cache hit rate | 85% | 99% |
| Full suite time | 14 min | 3 min |
Two contexts remain: one for integration tests (via @SaaSIntegrationTest) and one for slice tests. This is the target state.
The @DirtiesContext Trap
Even with a shared configuration, @DirtiesContext can destroy the cache. One test class with @DirtiesContext poisons the pool:
// BROKEN: @DirtiesContext destroys the shared context
@SaaSIntegrationTest
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
class TenantMigrationTest {
// This test modifies static state or in-memory data
// that cannot be reset between tests.
// After this class runs, the context is destroyed.
// Every subsequent test class recreates it.
}
If JUnit runs TenantMigrationTest in the middle of the suite, every test class after it pays the startup cost again. The fix: refactor the test to clean up its own state in @AfterEach instead of destroying the context. Use @DirtiesContext only as a last resort, and push those test classes to run last via JUnit @Order.