Sign up for your FREE personalized newsletter featuring insights, trends, and news for America's Active Baby Boomers

Newsletter
New

???? Speeding Up Spring Integration Tests: Lessons From Context Caching

Card image cap

At Pleenk, like in most projects I’ve worked on, the number of tests inevitably grows over time — and with it, the execution time. As feedback loops get longer, developers lose effectiveness, and eventually motivation to test. That’s when issues start to creep in.

One common way to maintain fast feedback is to prioritize unit tests. They are great for testing individual components in isolation and for exploring edge cases. But they’re not enough. Nothing gives me as much confidence as a good integration test — one that verifies that components work correctly together in a real configuration.

This is the story of a developer trying to reconcile confidence and speed, to write effective integration tests and ease the pressure on an overloaded CI pipeline.

⚡ The Myth of the Minimal Context

It all started with a simple intuition:
"If I reduce the Spring context size in my tests, they’ll run faster."

I wrote a test with a minimal Spring context, loading only the configuration and beans strictly necessary. Result: from several dozen seconds (sometimes minutes) of bootstrap time, I went down to just a few seconds. Victory.

Encouraged, I decided to apply this strategy more broadly. But when I tried to generalize it across all tests, the outcome was very different — and that’s when it all fell apart.
Running the full suite led to slower execution overall. A huge letdown.

A single test ran fast, but running multiple tests together had the opposite effect. Why?

???? Understanding Spring’s Test Context Cache

The culprit: Spring’s test context cache, which I previously knew only superficially.

After some digging, I discovered that Spring optimizes test time by sharing application contexts across test classes — if it can. If the framework detects an identical context has already been built, it reuses it. Otherwise, it rebuilds one from scratch, which is costly.

Spring generates a cache key based on many aspects of test configuration:

  • Context Loader / Initializers: via @ContextConfiguration
  • Test-specific customizers: @DynamicPropertySource
  • Context hierarchy: via @ContextHierarchy
  • Active profiles: via @ActiveProfiles("test")
  • Test properties: via @TestPropertySource

If the key differs (or the context is marked @DirtiesContext), Spring creates a new context.

???? Running a test in isolation with a minimal context is fast.

???? But running many tests with slightly different contexts destroys cache efficiency and adds overhead.

Lesson learned: by trying to optimize individual test speed, I had accidentally killed shared context reuse across the suite.

???? The @MockBean Illusion

Another tempting idea is to create a large, generic context that can serve many tests — minimizing the number of context rebuilds.

But quickly, reality hits:

  • You have to initialize everything
  • Deal with irrelevant side effects
  • Prepare data for unrelated layers
  • Understand complex interactions

Tests become brittle, slow, hard to read.

Enter @MockBean, a widely used solution to isolate parts of the application.

And at first glance, it’s perfect:

  • Simulate irrelevant dependencies
  • Keep tests focused
  • Code remains clean and controlled

But here’s the hidden cost: each use of @MockBean alters the context configuration. Even adding or removing a single mock breaks the cache key, causing Spring to rebuild the entire context.

What seems minor for one test becomes a massive overhead across a suite.@MockBean is powerful — but extremely dangerous if you care about fast execution. Yet, it’s ubiquitous. I’ve seen it in most projects, training courses, and online tutorials.

???? Modular Monolith Architecture to the Rescue

At Pleenk, our application is a modular monolith — a single deployable unit structured into business modules. Each module is strictly isolated according to hexagonal architecture principles:

  • Separation of domain logic from infrastructure
  • No direct calls between business modules
  • Communication via ports, gateways, or events
  • Clearly defined and controlled dependencies

We call this a modulith:

A design that combines the deployment simplicity of a monolith with the modularity and maintainability of microservice-inspired architectures.

Most integration tests only target one or two modules, never the entire app.

So why not configure only the modules we care about — and mock the rest?

????️ The Solution: Declare Modules, Mock the Rest

The goal: test a selected part of the application, while mocking all unrelated moduleswithout breaking the context cache.

???? Strategy

  • Dynamically declare which modules to load
  • Mock beans from all other modules
  • Preserve a stable cache key so that contexts are reused across tests

After much research, I found no out-of-the-box tool. But I stumbled on an overlooked Spring API: ContextCustomizerFactory.

1. Custom ContextCustomizerFactory

This interface lets you customize the application context at test time.

Our implementation:

  • Reads the list of target modules from a custom annotation
  • Detects beans belonging to other modules
  • Replaces them with mocks (via Mockito)
  • Defines a cache key based only on the selected modules

???? All tests targeting the same module set reuse the same Spring context.

// This factory is used to customize the test context based on the selected modules  
class ModuleTestContextCustomizerFactory : ContextCustomizerFactory {  
  
    override fun createContextCustomizer(  
        testClass: Class<*>,  
        configAttributes: List<ContextConfigurationAttributes>  
    ): ContextCustomizer? {  
        // Check if the test class is annotated with @ModuleTest  
        return testClass.getAnnotation(ModuleTest::class.java)?.let { annotation ->  
            ModuleTestContextCustomizer(annotation.value)  
        }  
    }  
  
    private class ModuleTestContextCustomizer(  
        private val modules: Array<Module> // These are the modules we want to include  
    ) : ContextCustomizer {  
  
        override fun customizeContext(  
            context: ConfigurableApplicationContext,  
            mergedConfig: MergedContextConfiguration  
        ) {  
            // Inject a BeanFactoryPostProcessor to customize beans before instantiation  
            context.addBeanFactoryPostProcessor(configureBeanFactory())  
        }  
  
        private fun configureBeanFactory(): BeanFactoryPostProcessor = BeanFactoryPostProcessor { factory ->  
            factory.beanDefinitionNames.forEach { beanName ->  
                val beanDefinition = factory.getBeanDefinition(beanName)  
                val className = beanDefinition.beanClassName  
  
                if (shouldBeMocked(className)) {  
                    mockBean(beanName, className!!, factory)  
                }  
            }  
        }  
  
        override fun equals(other: Any?): Boolean {  
            return other is ModuleTestContextCustomizer &&  
                modules.contentEquals(other.modules)  
        }  
  
        override fun hashCode(): Int = modules.contentHashCode()  
  
        private fun shouldBeMocked(className: String?): Boolean {  
            if (className == null || modules.isEmpty()) return false  
            if (!className.startsWith("com.pleenk.backend.modules.")) return false  
  
            val isInTargetModules = modules.any { module ->  
                className.contains(".${module.packageName}.")  
            }  
  
            return !isInTargetModules // Only mock if the bean doesn't belong to the selected modules  
        }  
  
        private fun mockBean(  
            beanName: String,  
            className: String,  
            factory: ConfigurableListableBeanFactory  
        ) {  
            val beanClass = Class.forName(className)  
            val mock = Mockito.mock(beanClass)  
            factory.registerSingleton(beanName, mock)  
        }  
    }  
}  

2. A Clear Annotation: @ModuleTest

We introduced a custom annotation to define the modules needed by the test:

@ModuleTest([Module.NOTIFICATION, Module.PROFILE])  
class NotificationIntegrationTest {  
    ...  
}  

Its Kotlin definition:

@Target(AnnotationTarget.TYPE, AnnotationTarget.CLASS)  
@Retention(AnnotationRetention.RUNTIME)  
@SpringBootTest  
@Transactional  
@AutoConfigureMockMvc  
@ActiveProfiles("test")  
@ContextCustomizerFactories(ModuleTestContextCustomizerFactory::class)  
annotation class ModuleTest(  
    val value: Array<Module> = [],  
)  

It wraps common test setup annotations and passes configuration to the factory.

3. Cleaner Injection: @AutowiredMock

To improve readability, we added an @AutowiredMock annotation — which behaves like @Autowired, but signals that the bean is mock-injected:

@ModuleTest([Module.NOTIFICATION, Module.PROFILE])  
class NotificationIntegrationTest {  
  
    @AutowiredMock  
    private lateinit var kycApi: KycApi  
  
    ...  
}  

This small detail makes it easier to distinguish real vs simulated dependencies at a glance.

???? The Results

With this strategy:

  • 50% faster overall integration test execution
  • Fewer contexts created thanks to smarter reuse
  • Consistent test design across the team

Developers now write tests that are more focused, faster, and easier to maintain.CI is more stable. Feedback loops are tighter. And most importantly: confidence is back.

???? What’s Next?

This strategy is still evolving. Some ideas we’re exploring:

  • Enable org.springframework.test.context.cache logs to track context reuse
  • Avoid loading internal beans from modules that are only accessed via HTTP or controllers
  • Write an internal test strategy guide to align team practices
  • Build a tool to analyze test context reuse: identify which tests share or break contexts, and which config deltas cause unnecessary context rebuilds

Got ideas or feedback? Share them in the comments or reach out — I’m always interested in learning new test strategies for complex Spring apps.

???? Final Thoughts

By combining modular design, precise context control, and a few Spring internals, we’ve found a sweet spot between test speed, confidence, and maintainability.

If your team is facing:

  • Slow CI builds
  • Flaky or long-running integration tests
  • Inconsistent test structure

I hope this experience gives you some inspiration.There’s no silver bullet — but a thoughtful strategy can make a big difference in both product quality and developer happiness.