Making spring boot integration tests fast
A Spring Boot service with Kafka and MongoDB integration tests (Testcontainers) took 3m14s to run.
Removing @DirtiesContext had been tried before and it broke the tests. This post explains why,
what actually cost the time, and what fixed it.
1. Measure the gaps between test classes
JUnit XML reports only record time spent inside test methods. Context startup, context shutdown
and container startup happen between classes and are not shown. Each <testsuite> element does
carry a timestamp and a time, so the gaps can be reconstructed:
import re, glob, datetime as dt
rows = []
for f in glob.glob('build/test-results/test/*.xml'):
s = open(f).read()
m = re.search(r'<testsuite name="([^"]+)" tests="(\d+)".*?timestamp="([^"]+)".*?time="([\d.]+)"', s, re.S)
rows.append((dt.datetime.fromisoformat(m.group(3)), float(m.group(4)), m.group(1).split('.')[-1]))
rows.sort(); prev_end = None
for ts, t, name in rows:
gap = (ts - prev_end).total_seconds() if prev_end else 0
if t > 1 or gap > 1: print(f"gap={gap:5.1f}s dur={t:5.1f}s {name}")
end = ts + dt.timedelta(seconds=t)
prev_end = max(prev_end, end) if prev_end else end
Findings for this suite:
- 31 Spring contexts were started, each in under one second. Context startup was not the problem.
- Three gaps of exactly 10 seconds with no test running.
- Kafka consumer tests took about 2 seconds per test.
2. Background: what a test context is and how Spring caches it
When a Spring Boot application starts, Spring creates every object the application needs
(controllers, services, repositories, Kafka consumers, database clients) and wires them together.
That set of live objects is the ApplicationContext. It is one running copy of the application.
A test annotated with @SpringBootTest needs such a copy to run against, so Spring starts one.
When the test class finishes, Spring does not shut the copy down. It keeps it in a cache and
reuses it for the next test class that asks for the same configuration (reference).
This is what keeps a suite of 100 test classes from booting the application 100 times.
Two test classes get the same copy only if their configuration is identical. Any difference in
@TestPropertySource, @DynamicPropertySource, @MockitoBean, @Import or @ActiveProfiles
results in a separate copy. Both copies stay in the cache.
A cached copy is not paused. Its @KafkaListener containers keep polling and its database
clients stay connected until the copy is closed, which happens either through @DirtiesContext
or at JVM shutdown (reference).
3. What went wrong in this suite
The test classes differed from each other in small ways: one set a feature flag for tenant A, another for tenant B, another mocked an HTTP client, several used randomly generated consumer group ids. Each difference produced another copy. The result was 31 copies of the application running at the same time in one JVM.
Every copy’s Kafka consumers connected to the same Kafka container, subscribed to the same topics, and used the same consumer group names. Kafka delivers each record to exactly one member of a consumer group, and it has no way of knowing that these members belong to 31 different test setups (reference). So a record published by test class 17 could be picked up by a consumer belonging to copy 5, processed with copy 5’s configuration, and written to a different collection than test 17 was checking. Test 17 then timed out.
How @DirtiesContext was masking this
@DirtiesContext on a test class tells Spring to close that copy after the class finishes and
remove it from the cache. With the previous copy gone, only the current copy’s consumers were
alive, and records went to the right place. This is why removing the annotation broke the tests.
It was expensive for two reasons:
- The next class needing that configuration had to boot a fresh copy.
- Closing a copy stops all its Kafka listener containers, and each stop waits for the consumer
thread to exit, up to
shutdownTimeout, which defaults to 10 seconds (reference). Because the other 30 copies were constantly joining and leaving the same groups, consumers were usually mid-rebalance and could not exit promptly. Every close hit the full 10 seconds. Those were the 10-second gaps in the timeline.
@DirtiesContext was not fixing the underlying problem, which was that many copies were
consuming at once. It was destroying copies so that only one was left, and paying 10 seconds
each time.
4. Fix: one listening context at a time
Two changes.
Make fewer copies
Most of the differences between test classes were unnecessary. For example, one group of tests
set feature.enabled-tenants=A and another set it to B, but the first group only ever used
tenant A and the second only tenant B. Setting A,B once in the shared test application.yaml
satisfies both groups with a single copy. Mocks of external HTTP clients were moved into the base
class as shared @MockitoBean fields, so every class asks for the same set of mocks; Mockito
beans are reset after each test by default (reference).
After this, almost every integration test asks for an identical configuration. The count went from 31 copies to 17, and the group of tests that dominated the runtime shares one copy. A few copies still legitimately differ, for example a test that mocks a domain service to force failures.
Let only one copy consume from Kafka
The remaining copies still cannot be allowed to consume at the same time. But they do not need to be destroyed for that. Only their Kafka consumers need to be stopped while another copy is in use. The rest of the copy (beans, database connections, configuration) can stay cached.
A TestExecutionListener (reference) on the base class does this. Before each test class
runs:
- Determine which copy the class needs.
- If it is the copy whose consumers are currently running, do nothing.
- Otherwise, stop the consumers of the currently active copy, start the consumers of the new copy, and remember the new copy as active.
KafkaListenerEndpointRegistry is the Spring Kafka bean that owns all listener containers of a
context; stop(Runnable) stops them in parallel and start() starts them again
(reference). Because copies are never destroyed, switching back to a copy used
earlier only restarts its consumers.
public class KafkaListenersPerTestClass extends AbstractTestExecutionListener {
private static ApplicationContext contextWithLiveListeners;
@Override
public void beforeTestClass(TestContext testContext) throws InterruptedException {
var context = testContext.getApplicationContext();
if (context == contextWithLiveListeners) return;
stopListeners(contextWithLiveListeners);
registry(context).start();
contextWithLiveListeners = context;
}
private static void stopListeners(ApplicationContext context) throws InterruptedException {
if (context == null) return;
var stopped = new CountDownLatch(1);
registry(context).stop(stopped::countDown);
stopped.await(30, TimeUnit.SECONDS);
}
private static KafkaListenerEndpointRegistry registry(ApplicationContext context) {
return context.getBean(KafkaListenerEndpointRegistry.class);
}
}
@SpringBootTest
@TestExecutionListeners(
listeners = KafkaListenersPerTestClass.class,
mergeMode = TestExecutionListeners.MergeMode.MERGE_WITH_DEFAULTS)
public abstract class BaseIntegrationTest { ... }
@DirtiesContext is no longer required. The stop is also fast now: with one copy consuming at a
time there is no rebalance in progress, so consumers exit on their next poll instead of waiting
for the 10-second timeout.
| Before | After | |
|---|---|---|
| Copies of the application running | 31 | 17 cached, 1 consuming at a time |
| How interference was prevented | Destroy the previous copy (@DirtiesContext) |
Stop the previous copy’s consumers, keep the copy |
| Cost per switch | Boot a new copy plus up to 10 s shutdown | Under 1 s |
This assumes test classes run sequentially within a JVM (Gradle’s default). With
maxParallelForks each fork has its own state, which is fine. With JUnit parallel execution
inside one JVM it would not be.
Two smaller items found on the way:
- 27
Awaitility.pollDelay(1, SECONDS)calls added a fixed second before the first check. Awaitility polls every 100 ms by default. For assertions that something does not happen,during(1, SECONDS)expresses the intent correctly (reference). - A test topic was configured with 32 partitions, which only made rebalances slower.
5. Docker on macOS: the 256 file descriptor limit
After the refactor, full runs began to fail mid-way with MongoSocketReadException: Connection reset, Kafka AdminClient timeouts, and docker ps on the host reporting that the daemon was
unreachable. Inside the Colima VM (colima ssh -- docker ps) all containers were healthy.
Sampling lsof during a run showed the cause. Colima forwards published container ports and the
Docker socket through a single ssh ... ssh.sock [mux] process (reference). That
process inherits the shell’s ulimit -n, which is 256 by default on macOS. One forwarded TCP
connection uses one file descriptor. During the run the process sat at 256 to 260 descriptors,
and new connections were reset.
The tests were part of the cause: each @BeforeEach created a new MongoClient and a new Kafka
producer and never closed them. Peak usage was 146 connections to the Kafka port and about 96 to
the Mongo port.
Two changes were needed:
- Tests: one
MongoClientand oneDefaultKafkaProducerFactoryper JVM, held in static fields on the base class. Peak dropped to about 240. - Machine: start Colima with a higher limit, for example
colima stop; (ulimit -n 65536; colima start).
If a Testcontainers suite on macOS loses Docker mid-run, check
lsof -p $(pgrep -f 'ssh.sock \[mux\]') | wc -l while it is running.
Results
| Before | After | |
|---|---|---|
| Wall clock | 3m 14s | 1m 46s |
| Spring contexts started | 31 | 17 |
| Containers started | 7 | 3 |
@DirtiesContext usages |
12 | 0 |
Summary
- Context startup was cheap. Context shutdown, and 31 contexts consuming from the same topics, were the cost.
@DirtiesContextwas compensating for the interference. Keeping only one context’s listeners running removes both the interference and the need to close contexts.- Unclosed clients in tests plus Colima’s 256 descriptor forwarder limit produce symptoms that look like Docker crashing.
Comments