What this post covers
This is the twenty-sixth post in the "Digging into Spring's Source" series. Up to part 25 we read the code that does Spring's actual work. This time we read the side that tries that code out: the spring-test module. Put @SpringJUnitConfig on a test class and a container starts, @Autowired fields get filled, and a test marked @Transactional leaves the data as it was. We follow who does all that.
There are four questions to answer.
- JUnit knows nothing about Spring, so when and how does the code that injects beans into a test class get in?
- Why doesn't a suite with hundreds of test classes start hundreds of containers, and what has to change for a new one to start?
- Why does a test marked
@Transactionalroll back when it ends? - How does
MockMvccall a controller with no server and no port?
| Item | Details |
|---|---|
| Source | Spring Framework 7.1.0-SNAPSHOT |
| Commit | f447f3c310 (2026-10-01, main branch) |
| Main module | spring-test |
| Main classes | SpringExtension, TestContextManager, AbstractTestContextBootstrapper, MergedContextConfiguration, DefaultCacheAwareContextLoaderDelegate, DefaultContextCache, TransactionalTestExecutionListener, MockMvc |
Every line number and detail in this post is based on that commit. In other versions the line numbers differ and the details may too. Part 1 explains how to get the source at the same commit.
This post keeps to the main thread that someone new to Spring can follow. What was left out is listed by name and location under "What this post left out" at the end.
Words to know first
JUnit Jupiter is the part of JUnit, the most widely used Java testing tool, that you write and run tests with. It finds methods marked @Test and runs them one at a time. Jupiter leaves openings along the way where outside code can step in. What you plug into those openings is called an extension, registered with @ExtendWith(SomeExtension.class).
An application context (just "context" in this post) is the container that creates and holds beans. The method that makes a context ready to use is refresh(), and because it reads the configuration and creates every bean, it takes time. Part 7 has the details.
The TestContext framework is the name for the part of spring-test that prepares the context a test needs and does the work before and after each test. Sections 1 to 5 of this post are about it.
A listener is an object you register to say "tell me when this happens". The TestExecutionListener in this post is called at moments such as the start of a test class or the start and end of a test method.
A cache is a store that keeps something once it is made and hands it back for the same request instead of making it again. The value that decides what counts as "the same request" is the key.
Transactions, rollback, and commit were explained in part 17, and DispatcherServlet, which sends a request to a controller, in part 21. Sections 5 and 6 lean on those two posts.
1. Where JUnit meets Spring — SpringExtension
A test class usually looks like this.
// An example written for this post (not Spring's code)
@SpringJUnitConfig(AppConfig.class) // start a context from this configuration class
class GreeterTests {
@Autowired Greeter greeter; // put that context's bean here
@Test
void greets() { /* ... */ }
}
@SpringJUnitConfig is two annotations bundled together: @ExtendWith(SpringExtension.class) and @ContextConfiguration (SpringJUnitConfig lines 51–57). The first one speaks to JUnit ("plug in SpringExtension when you run this class"), and the second speaks to Spring ("build the context from this configuration"). Reading an annotation that sits on another annotation, attributes included, is the job of MergedAnnotations from part 13.
SpringExtension implements eight of Jupiter's extension interfaces.
public class SpringExtension implements BeforeAllCallback, AfterAllCallback, TestInstancePostProcessor,
BeforeEachCallback, AfterEachCallback, BeforeTestExecutionCallback, AfterTestExecutionCallback,
ParameterResolver {
Yet every method body is short. Here is beforeEach.
@Override
public void beforeEach(ExtensionContext context) throws Exception {
Object testInstance = context.getRequiredTestInstance();
Method testMethod = context.getRequiredTestMethod();
TestContextManager testContextManager = getTestContextManager(context);
registerMethodInvoker(testContextManager, context);
testContextManager.beforeTestMethod(testInstance, testMethod);
}
SpringExtension.java lines 316–323
It takes the test object and method out of the ExtensionContext JUnit hands over and passes them to the TestContextManager method that means the same thing. That is all. The other callbacks have the same shape.
| Method Jupiter calls | TestContextManager method it hands off to |
Location |
|---|---|---|
beforeAll |
beforeTestClass() |
lines 211–215 |
postProcessTestInstance |
prepareTestInstance(test object) |
lines 238–246 |
beforeEach |
beforeTestMethod(…) |
lines 317–323 |
beforeTestExecution |
beforeTestExecution(…) |
lines 329–335 |
afterTestExecution |
afterTestExecution(…) |
lines 341–348 |
afterEach |
afterTestMethod(…) |
lines 354–361 |
afterAll |
afterTestClass() |
lines 221–230 |
resolveParameter |
(no hand-off; it finds a bean in the context and returns it) | lines 427–449 |
Why make it this thin? TestContextManager lists exception type names not only from JUnit Jupiter but also from JUnit 4 and TestNG (lines 97–104). Whatever the testing tool is, the moments are the same: test class starts, object is prepared, before a method, after a method, class ends. Spring keeps all of its own work under TestContextManager and writes only a thin piece of glue per tool to pass those moments along. SpringExtension is the glue for Jupiter.
One TestContextManager is created per test class and kept in a store that Jupiter provides (Store).
static TestContextManager getTestContextManager(ExtensionContext context) {
Assert.notNull(context, "ExtensionContext must not be null");
Class<?> testClass = context.getRequiredTestClass();
Store store = getStore(context);
return store.computeIfAbsent(testClass, TestContextManager::new, TestContextManager.class);
}
private static Store getStore(ExtensionContext context) {
return context.getRoot().getStore(TEST_CONTEXT_MANAGER_NAMESPACE);
}
SpringExtension.java lines 478–487
computeIfAbsent means "if nothing is stored under this key, create it and store it; otherwise return what is there". The key is the test class, so every callback for one class gets the same TestContextManager.
The two blue boxes are the glue and the manager. The actual work is done by the listeners in the bottom row.
Easy to get wrong
SpringExtensiondoes not create the context. The context is created the first time someone callsTestContext.getApplicationContext()(section 4). The Javadoc ofsupportsParametersays the method deliberately leaves the context alone so that it is not loaded too early (lines 382–385).- At this commit
SpringExtensionrequires JUnit Jupiter 6 or higher. The class Javadoc says so (line 104), andStore.computeIfAbsentin the code above is an API of that version. I actually ran into this while running the examples below, so it comes up again at the end of section 2. - Test method parameters can receive beans too.
resolveParameterfinds a bean that fits the parameter. It takes the same road as the dependency resolution in part 5 (ParameterResolutionDelegate.resolveDependency, lines 447–448).
2. TestContextManager and listeners — the listeners do the work
TestContextManager holds two things: one TestContext, which carries the state of the test being run, and a list of TestExecutionListeners (lines 106–110). The constructor sets up both.
public TestContextManager(TestContextBootstrapper testContextBootstrapper) {
this.testContext = testContextBootstrapper.buildTestContext();
this.testContextHolder = ThreadLocal.withInitial(() -> copyTestContext(this.testContext));
registerTestExecutionListeners(testContextBootstrapper.getTestExecutionListeners());
}
spring-test/src/main/java/org/springframework/test/context/TestContextManager.java lines 141–145
It asks a TestContextBootstrapper to build both. A bootstrapper is "the one that gets things started", and section 3 looks at it.
The methods of TestContextManager all have the same shape. They write the current state (test object, method, exception) into the TestContext, then walk the listeners and call the method of the same name on each (beforeTestClass, lines 210–231). The TestExecutionListener interface has seven methods that get called this way, all with a default implementation that does nothing (lines 141–285), so a listener implements only the moments it cares about.
Left of each arrow is the name Jupiter calls, right of it the TestContextManager name. Blue boxes are where the listeners followed in this post do their work.
Where the default listeners come from
Injection and transactions work even if you list no listeners on the test class, because a default list is written in a file inside the spring-test jar.
org.springframework.test.context.TestExecutionListener = \
org.springframework.test.context.web.ServletTestExecutionListener,\
org.springframework.test.context.support.DirtiesContextBeforeModesTestExecutionListener,\
org.springframework.test.context.event.ApplicationEventsTestExecutionListener,\
org.springframework.test.context.bean.override.BeanOverrideTestExecutionListener,\
org.springframework.test.context.support.DependencyInjectionTestExecutionListener,\
org.springframework.test.context.observation.MicrometerObservationRegistryTestExecutionListener,\
org.springframework.test.context.support.DirtiesContextTestExecutionListener,\
org.springframework.test.context.support.CommonCachesTestExecutionListener,\
org.springframework.test.context.transaction.TransactionalTestExecutionListener,\
org.springframework.test.context.jdbc.SqlScriptsTestExecutionListener,\
org.springframework.test.context.event.EventPublishingTestExecutionListener,\
org.springframework.test.context.bean.override.mockito.MockitoResetTestExecutionListener
spring-test/src/main/resources/META-INF/spring.factories lines 3–15
Count them and you get twelve. META-INF/spring.factories is a file of "interface name = implementation class names", and SpringFactoriesLoader is what reads it and turns the names into objects. When the test class has no @TestExecutionListeners, the bootstrapper reads the list from this file (AbstractTestContextBootstrapper lines 123–130, lines 205–207), removes duplicates, and sorts by order value (lines 164–177). A smaller order value goes first. The sorting rule belongs to the OrderComparator family from part 16.
Here are just four representative listeners.
| Listener | Order value | What it does |
|---|---|---|
DependencyInjectionTestExecutionListener |
2000 (line 46) | Fills the test object's @Autowired fields in prepareTestInstance |
DirtiesContextTestExecutionListener |
3000 (line 61) | If @DirtiesContext is present, removes the context from the cache and closes it after the test |
TransactionalTestExecutionListener |
4000 (line 154) | Opens a transaction before the test method and rolls back or commits after it (section 5) |
SqlScriptsTestExecutionListener |
5000 (line 125) | Runs the SQL scripts named in @Sql |
The heart of the dependency injection listener is six lines.
protected void injectDependencies(TestContext testContext) throws Exception {
Object bean = testContext.getTestInstance();
String beanName = testContext.getTestClass().getName() + AutowireCapableBeanFactory.ORIGINAL_INSTANCE_SUFFIX;
AutowireCapableBeanFactory beanFactory = testContext.getApplicationContext().getAutowireCapableBeanFactory();
beanFactory.autowireBeanProperties(bean, AutowireCapableBeanFactory.AUTOWIRE_NO, false);
beanFactory.initializeBean(bean, beanName);
testContext.removeAttribute(REINJECT_DEPENDENCIES_ATTRIBUTE);
}
The test object was created by JUnit with new, so it is not a bean of the container. The listener therefore asks the container: "this object already exists; just do injection and initialization on it." Of the three steps in part 4, it skips instantiation and goes through injection (autowireBeanProperties) and initialization (initializeBean). And testContext.getApplicationContext() on the third line is the door into the context cache of section 4.
The "after" callbacks run in reverse
The before… family walks the list in order, but the after… family walks a reversed copy (getReversedTestExecutionListeners, lines 190–194; line 486 in afterTestMethod). A comment in the source calls this "wrapper"-style execution (lines 484–485). A listener that starts first has to finish last so that the outer listener wraps what the inner one did. For example, the SQL script listener (5000) starts later and ends earlier than the transaction listener (4000), so when the test is @Transactional and the transaction settings of @Sql are left alone, the scripts run inside the test transaction (SqlScriptsTestExecutionListener lines 368–372).
Example 1: the default listener list and the wrapping order
We print the default listeners, then plug in two listeners of our own (order values 100 and 9000) and watch the call order. All examples in this post were run on the released Spring Framework 7.0.9 with JUnit 6.0.3 to confirm the results (not on a build of the 7.1.0-SNAPSHOT this series reads). To run test classes from a main method, I called JUnit's Launcher directly.
// An example written for this post (not Spring's source)
// A listener that prints its name on every callback (all seven methods look alike, so only two are shown)
static class PrintingListener implements TestExecutionListener, Ordered {
// ...
@Override
public void beforeTestMethod(TestContext testContext) {
System.out.println(" " + this.name + ".beforeTestMethod");
}
@Override
public void afterTestMethod(TestContext testContext) {
System.out.println(" " + this.name + ".afterTestMethod");
}
}
@SpringJUnitConfig(EmptyConfig.class)
@TestExecutionListeners(listeners = {InnerListener.class, OuterListener.class}, // listed backwards on purpose
mergeMode = MergeMode.MERGE_WITH_DEFAULTS) // add to the defaults
static class WrappedTests {
@BeforeEach void setUp() { System.out.println(" @BeforeEach"); }
@Test void test() { System.out.println(" @Test body"); }
@AfterEach void tearDown() { System.out.println(" @AfterEach"); }
}
public static void main(String[] args) {
// create only a TestContextManager, without JUnit, and ask it for its listeners
List<TestExecutionListener> listeners = new TestContextManager(PlainTests.class).getTestExecutionListeners();
// ... print each order value and class name
JupiterRunner.run(WrappedTests.class); // run the test class with the JUnit Launcher
}
The examples print their headings and labels in Korean. In the output blocks of this English version those labels are translated; the values, the order, and anything printed by Spring are the actual output.
[1] Default listeners (asking a TestContextManager created without JUnit)
1,000 ServletTestExecutionListener
1,500 DirtiesContextBeforeModesTestExecutionListener
1,800 ApplicationEventsTestExecutionListener
1,950 BeanOverrideTestExecutionListener
2,000 DependencyInjectionTestExecutionListener
3,000 DirtiesContextTestExecutionListener
3,005 CommonCachesTestExecutionListener
4,000 TransactionalTestExecutionListener
5,000 SqlScriptsTestExecutionListener
10,000 EventPublishingTestExecutionListener
2,147,483,547 MockitoResetTestExecutionListener
11 in total
[2] Running a test class with two listeners plugged in
outer(order=100).beforeTestClass
inner(order=9000).beforeTestClass
outer(order=100).prepareTestInstance
inner(order=9000).prepareTestInstance
outer(order=100).beforeTestMethod
inner(order=9000).beforeTestMethod
@BeforeEach
outer(order=100).beforeTestExecution
inner(order=9000).beforeTestExecution
@Test body
inner(order=9000).afterTestExecution
outer(order=100).afterTestExecution
@AfterEach
inner(order=9000).afterTestMethod
outer(order=100).afterTestMethod
inner(order=9000).afterTestClass
outer(order=100).afterTestClass
=> WrappedTests: 1 succeeded / 0 failed
The annotation lists the inner listener first, but the list was sorted by order value, so the outer one was called first, and in the after… callbacks the order flipped. You can also see that beforeTestMethod comes before @BeforeEach while beforeTestExecution comes after it.
Easy to get wrong
- The file has twelve, but eleven showed up at run time. The missing one is
MicrometerObservationRegistryTestExecutionListener. The failure handler used while loading default listeners does not raise aClassNotFoundExceptionorNoClassDefFoundErrorcaused by a class the listener needs being absent from the classpath; it just skips that candidate (TestContextFailureHandlerlines 41–49). It is not that Micrometer is missing altogether, though.micrometer-observationis a dependency thatspring-contextpulls in, so it is on the example project's classpath (aClass.forNamecheck foundio.micrometer.observation.ObservationRegistryinmicrometer-observation-1.16.7.jar). What is missing isio.micrometer:context-propagation, which has to be added separately. In a static block this listener looks forio.micrometer.context.ThreadLocalAccessorandObservationThreadLocalAccessor(MicrometerObservationRegistryTestExecutionListenerlines 80–103), and if either one cannot be found, its constructor throws aNoClassDefFoundErroritself (lines 106–112). Calling that constructor directly in the example project gives a message that points atio.micrometer.context.ThreadLocalAccessor. The failure handler above caught that error and skipped the candidate. So the answer to "how many default listeners are there" depends on the classpath. - A bare
@TestExecutionListenersdrops the defaults. Sorting and merging with the default list happen only whenmergeModeisMERGE_WITH_DEFAULTS(lines 146–154, line 164). Remove that attribute from the example above and the list no longer even contains the dependency injection listener. - Where I got stuck running the example. I first ran it with JUnit Jupiter 5.13.4; no test ran at all, and I got
NoSuchMethodError: ExtensionContext$Store.computeIfAbsent(...). The method thatgetTestContextManagerfrom section 1 calls does not exist in JUnit 5. Raising JUnit to 6.0.3 fixed it. Part [1], which only asksnew TestContextManager(…)for its listeners, never goes through JUnit, so it printed fine even on 5.13.4. Without meaning to, that confirmed that onlySpringExtensionis the glue.
3. Bootstrapping — how a test class's annotations become a cache key
new TestContextManager(testClass) asks BootstrapUtils to pick a bootstrapper (lines 125–127). If the test class has no @BootstrapWith, it uses WebTestContextBootstrapper when @WebAppConfiguration is present and DefaultTestContextBootstrapper otherwise (BootstrapUtils lines 212–217).
Remember the two yellow cylinders. The one on the right is the cache key, and the one on the left is the cache.
The bootstrapper's code for building the TestContext is one statement.
@Override
public TestContext buildTestContext() {
return new DefaultTestContext(getBootstrapContext().getTestClass(), buildMergedContextConfiguration(),
getCacheAwareContextLoaderDelegate());
}
DefaultTestContext receives three things: the test class, a MergedContextConfiguration, and the CacheAwareContextLoaderDelegate that actually fetches the context. The context itself is not among them. A TestContext does not hold a context; it holds what is needed to get one.
MergedContextConfiguration is what its name says, a "merged context configuration". It reads @ContextConfiguration, @ActiveProfiles, @TestPropertySource, and so on, scattered across the test class, its superclasses, and annotations on annotations, and gathers them into one object. The end of the gathering code looks like this.
MergedTestPropertySources mergedTestPropertySources =
TestPropertySourceUtils.buildMergedTestPropertySources(testClass);
MergedContextConfiguration mergedConfig = new MergedContextConfiguration(testClass,
StringUtils.toStringArray(locations), ClassUtils.toClassArray(classes),
initializers, ActiveProfilesUtils.resolveActiveProfiles(testClass),
mergedTestPropertySources.getPropertySourceDescriptors(),
mergedTestPropertySources.getProperties(),
contextCustomizers, contextLoader, cacheAwareContextLoaderDelegate, parentConfig);
AbstractTestContextBootstrapper.java lines 350–357
Read down the constructor arguments and you have the ingredients that determine one test context: configuration file locations (locations), configuration classes (classes), initializer classes (initializers), active profiles, two kinds of test properties, context customizers, the loader that builds the context (contextLoader), and a parent configuration. Unless you choose otherwise, contextLoader is DelegatingSmartContextLoader (DefaultTestContextBootstrapper lines 35–38).
The next section shows why this object matters. It is the key of the context cache.
4. The context cache — the same configuration starts only once
Starting a context is slow. With 300 test classes, calling refresh() for each one would spend most of the test run switching containers on. But most of those 300 use the same configuration. So spring-test keeps a context once it is started and hands the very same context to every test class whose configuration is the same.
It begins in TestContext.getApplicationContext().
@Override
public ApplicationContext getApplicationContext() {
ApplicationContext context = this.cacheAwareContextLoaderDelegate.loadContext(this.mergedConfig);
// ...
this.cacheAwareContextLoaderDelegate.registerContextUsage(this.mergedConfig, this.testClass);
return context;
}
The method is named loadContext, but it does not build a new one each time. The body shows what the CacheAware in front of the name means. Logging and failure handling are shortened to // ....
@Override
public ApplicationContext loadContext(MergedContextConfiguration mergedConfig) {
mergedConfig = replaceIfNecessary(mergedConfig);
synchronized (this.contextCache) {
try {
ApplicationContext context = this.contextCache.get(mergedConfig);
if (context != null) {
// ...
return context;
}
int failureCount = this.contextCache.getFailureCount(mergedConfig);
if (failureCount >= this.failureThreshold) {
throw new IllegalStateException("""
ApplicationContext failure threshold (%d) exceeded: \
skipping repeated attempt to load context for %s"""
.formatted(this.failureThreshold, mergedConfig));
}
return this.contextCache.put(mergedConfig, key -> {
try {
ApplicationContext newContext;
// ...
newContext = loadContextInternal(key);
// ...
return newContext;
}
// ...
});
}
finally {
this.contextCache.logStatistics();
}
}
}
Yellow boxes are where the cache is read and written. Most test classes finish on the left path.
- Look in the cache with
mergedConfigas the key. If it is there, return it and stop. - If not, check whether the number of failed attempts to build a context for this key has reached the threshold. The default threshold is 1 (
CacheAwareContextLoaderDelegateline 51). A configuration that failed once is not tried again; an exception is thrown right away. This keeps 100 test classes with the same configuration from waiting through 100 failures. - Otherwise build a new context with
loadContextInternaland put it in the cache.loadContextInternalhands the job to theContextLoaderthat theMergedContextConfigurationcarries (lines 242–258), and the loader creates aGenericApplicationContext, applies the configuration, and callsrefresh()(AbstractGenericContextLoaderlines 237–258). It is the samerefresh()as in part 7. Tests do not use a different kind of container.
Where the cache lives — one static field
The diagram in section 3 said a new delegate is created for each test class (BootstrapUtils lines 100–112). Test classes can still share contexts because all those delegates point at one cache.
/**
* Default static cache of Spring application contexts.
*/
static final ContextCache defaultContextCache = new DefaultContextCache();
DefaultCacheAwareContextLoaderDelegate.java lines 73–76
The no-argument constructor uses this static field (lines 102–104). Its Javadoc gives the reason: the cache is static so that each context can be reused by all later tests that declare the same configuration within the same JVM process (lines 94–101). Compare that with part 4, where the map of singleton beans was not static. A bean is one per container; a test context is one (per key) per JVM.
What counts as "the same configuration" — equals and hashCode
The storage inside the cache is a map.
private final Map<MergedContextConfiguration, ApplicationContext> contextMap =
Collections.synchronizedMap(new LinkedHashMap<>(32, 0.75f, true));
A Java map decides what "the same key" is with the key's hashCode() and equals(). So the promise "the same configuration starts a context only once" is in the end kept by those two methods of MergedContextConfiguration.
@Override
public int hashCode() {
int result = Arrays.hashCode(this.locations);
result = 31 * result + Arrays.hashCode(this.classes);
result = 31 * result + this.contextInitializerClasses.hashCode();
result = 31 * result + Arrays.hashCode(this.activeProfiles);
result = 31 * result + this.propertySourceDescriptors.hashCode();
result = 31 * result + Arrays.hashCode(this.propertySourceProperties);
result = 31 * result + this.contextCustomizers.hashCode();
result = 31 * result + (this.parent != null ? this.parent.hashCode() : 0);
result = 31 * result + nullSafeClassName(this.contextLoader).hashCode();
return result;
}
equals() compares the same nine things (lines 485–531). The thing to notice is the field that is not there. testClass is the first field of this object (line 86), yet it appears in neither method. The Javadoc of hashCode() states that the hash is generated from all properties "excluding the test class" (lines 533–537). If the other nine match, the key is the same even when the test class differs, and that is how different test classes share one context.
Conversely, if any one of the nine differs, the key is different and one more context starts.
| What goes into the key | What changes it on the test class |
|---|---|
locations |
@ContextConfiguration(locations = …) |
classes |
@ContextConfiguration(classes = …), @SpringJUnitConfig(…) |
contextInitializerClasses |
@ContextConfiguration(initializers = …) |
activeProfiles |
@ActiveProfiles |
propertySourceDescriptors |
@TestPropertySource(locations = …) |
propertySourceProperties |
@TestPropertySource(properties = …) |
contextCustomizers |
Whatever a ContextCustomizerFactory produces, for example from @MockitoBean or @DynamicPropertySource |
parent |
A parent configuration made with @ContextHierarchy |
class name of contextLoader |
@ContextConfiguration(loader = …) |
Example 2: five runs, three contexts
A counter is incremented in the constructor of a bean, so we can count how many times a context is built.
// An example written for this post (not Spring's source)
static final AtomicInteger contextsCreated = new AtomicInteger(); // goes up by 1 each time a context is built
static class Greeter {
Greeter() { contextsCreated.incrementAndGet(); }
}
@Configuration
static class AppConfig {
@Bean Greeter greeter() { return new Greeter(); }
}
@SpringJUnitConfig(AppConfig.class)
static class FirstTests { // two test methods (one, two)
@Autowired ApplicationContext context;
@Test void one() { report(this, this.context); } // prints the context's identity number and the counter
@Test void two() { report(this, this.context); }
}
@SpringJUnitConfig(AppConfig.class) // configuration identical to FirstTests, character for character
static class SameConfigTests { /* one test method */ }
@SpringJUnitConfig(AppConfig.class)
@ActiveProfiles("slow") // same configuration class, different active profile
static class OtherProfileTests { /* one test method */ }
@SpringJUnitConfig(AppConfig.class)
@TestPropertySource(properties = "greeting=hi") // same configuration class, one extra test property
static class OtherPropertyTests { /* one test method */ }
The output (labels translated, as above).
[1] FirstTests (2 test methods)
FirstTests: context @1ecee32c, contexts built so far: 1
FirstTests: context @1ecee32c, contexts built so far: 1
=> FirstTests: 2 succeeded / 0 failed
[2] SameConfigTests (same configuration as FirstTests)
SameConfigTests: context @1ecee32c, contexts built so far: 1
=> SameConfigTests: 1 succeeded / 0 failed
[3] OtherProfileTests (@ActiveProfiles added)
OtherProfileTests: context @6058e535, contexts built so far: 2
=> OtherProfileTests: 1 succeeded / 0 failed
[4] OtherPropertyTests (@TestPropertySource added)
OtherPropertyTests: context @5b69fd74, contexts built so far: 3
=> OtherPropertyTests: 1 succeeded / 0 failed
[5] SameConfigTests once more
SameConfigTests: context @1ecee32c, contexts built so far: 3
=> SameConfigTests: 1 succeeded / 0 failed
5 test class runs, 3 contexts built
(The number after @ is an object identity number that changes on every run.) FirstTests and SameConfigTests are different classes but received the same context, @1ecee32c. One line of @ActiveProfiles and one line of @TestPropertySource each started one more context, even though nothing in AppConfig uses that profile or that property. The cache does not ask "would the resulting context be the same?"; it asks "are the ingredients of the configuration the same?" In [5], the first context came back unchanged even though two other contexts had been used in between.
When it is full, the least recently used one is closed first
A context holds memory and connections (a database connection pool, for example), so they cannot pile up forever. The LinkedHashMap behind contextMap above is created with true as its third argument. A LinkedHashMap built that way orders its entries not by insertion but by when each was last accessed. So the entry at the head is the one that has gone unused the longest.
private void evictLruContextIfNecessary() {
if (this.contextMap.size() >= this.maxSize) {
Iterator<MergedContextConfiguration> iterator = this.contextMap.keySet().iterator();
Assert.state(iterator.hasNext(), "Failed to retrieve LRU context");
// The least recently used (LRU) key is the first/head in a LinkedHashMap
// configured for access-order iteration order.
MergedContextConfiguration lruKey = iterator.next();
remove(lruKey, HierarchyMode.CURRENT_LEVEL);
}
}
DefaultContextCache.java lines 224–233
This method is called just before a new context is put in (line 212). Evicting "whatever has gone unused the longest" is called LRU (least recently used). remove does more than take the entry out of the map; it also calls close() on the context (lines 367–369). The default maximum size is 32 (ContextCache line 77) and can be changed with the spring.test.context.cache.maxSize property (line 90, ContextCacheUtils lines 41–45).
Easy to get wrong
- The order of configuration classes is part of the key.
classesis an array compared withArrays.equals(line 498), so{A.class, B.class}and{B.class, A.class}are different keys. - A shared context means shared bean state. If one test changes a field of a singleton bean, the next test class that receives the same context sees that value. This is what
@DirtiesContextis for. When the listener callsmarkApplicationContextDirty(AbstractDirtiesContextTestExecutionListenerlines 69–72), the context for that key is removed from the cache and closed (DefaultTestContextlines 166–169), and the next test that looks up the same key starts a new one. That costs time, so use it only where it is needed. - A different JVM means a different cache. The cache is a static field, so it only works within one process. If a build tool splits tests across several processes, each process starts its own contexts. This comes from the comment in the source ("within the same JVM process"); I did not verify build tool behavior for this post.
5. Test transactions — open one, and roll back by default
Tests that use a database have a cleanup problem. If rows inserted by one test stay behind, the next test's result changes. The answer in spring-test is "wrap the test method in one transaction and roll it back at the end".
In part 17, @Transactional worked through a proxy that intercepted the method call and opened and closed the transaction. A test method is in a different situation. JUnit creates the test object and calls it directly, so there is nowhere for a proxy to step in. That is why a listener reads the same annotation.
public void beforeTestMethod(final TestContext testContext) throws Exception {
Method testMethod = testContext.getTestMethod();
Class<?> testClass = testContext.getTestClass();
// ...
PlatformTransactionManager tm = null;
TransactionAttribute transactionAttribute = this.attributeSource.getTransactionAttribute(testMethod, testClass);
if (transactionAttribute != null) {
// ...
tm = getTransactionManager(testContext, transactionAttribute.getQualifier());
// ...
}
if (tm != null) {
txContext = new TransactionContext(testContext, tm, transactionAttribute, isRollback(testContext));
runBeforeTransactionMethods(testContext);
txContext.startTransaction();
TransactionContextHolder.setCurrentTransactionContext(txContext);
}
}
- Read
@Transactionalfrom the test method (or, failing that, the class). The reader is the sameAnnotationTransactionAttributeSourceas in part 17 (line 160). Without the annotation,tmstaysnulland nothing happens. - Find the transaction manager bean in the context.
- Decide with
isRollbackwhether to roll back at the end, store that in aTransactionContext, and open the transaction withstartTransaction(). Inside, it is exactly thetransactionManager.getTransaction(…)from part 17 (TransactionContextline 101).
At the end, afterTestMethod calls endTransaction() (lines 258–276), where a single flag decides.
try {
if (this.flaggedForRollback) {
this.transactionManager.rollback(this.transactionStatus);
}
else {
this.transactionManager.commit(this.transactionStatus);
}
}
finally {
this.transactionStatus = null;
}
The TransactionInterceptor in part 17 decided on rollback by looking at whether an exception was thrown. This is different. It does not look at whether the test passed or failed, only at the flag set at the start. The default for that flag is true. If the class has no @Rollback, isDefaultRollback returns true (lines 425–441), and if the method has @Rollback, its value wins (lines 454–481). There is no separate code that handles @Commit. It is simply an annotation that carries @Rollback(false) (Commit lines 60–61). Because findMergedAnnotation (part 13) finds annotations on annotations, putting @Commit on a method makes @Rollback(false) readable.
Example 3: a test that rolls back and a test with @Commit
// An example written for this post (not Spring's source)
@SpringJUnitConfig(JdbcConfig.class) // beans: H2 DataSource, JdbcTemplate, DataSourceTransactionManager
@Transactional // every test method in this class runs in a transaction
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
static class MemberTests {
@Autowired JdbcTemplate jdbc;
@BeforeTransaction // called before the transaction is opened
void beforeTx() { /* prints whether a transaction is active and the row count */ }
@Test @Order(1)
void insertWithDefaultRollback() {
this.jdbc.update("insert into member(name) values ('kim')");
// prints whether a transaction is active, whether it is flagged for rollback, and the row count
}
@Test @Order(2)
@Commit // only this method commits
void insertWithCommit() {
this.jdbc.update("insert into member(name) values ('lee')");
// prints the same things
}
@AfterTransaction // called after the transaction has ended
void afterTx() { /* prints whether a transaction is active and the row count */ }
}
The output (labels translated, as above).
@BeforeTransaction : transaction active=false, rows 0
insertWithDefaultRollback: transaction active=true, flagged for rollback=true, rows 1
@AfterTransaction : transaction active=false, rows 0
@BeforeTransaction : transaction active=false, rows 0
insertWithCommit: transaction active=true, flagged for rollback=false, rows 1
@AfterTransaction : transaction active=false, rows 1
=> MemberTests: 2 succeeded / 0 failed
Names left in the table after the tests: lee
Both tests passed, but kim from the first one is gone and only lee from the second one, marked @Commit, remains.
Regardless of whether the test passed or failed, one flag decided up front picks rollback or commit. The values in the grey and yellow boxes are the real output of Example 3 above.
Easy to get wrong
- Data is visible inside the test because it is the same transaction. Even the test that was going to roll back saw "1 row" inside the method.
JdbcTemplateuses the connection bound to the test transaction (thread-bound connections are in section 5 of part 17). - If the code under test opens a new transaction and commits it, that is not rolled back. The listener undoes the one transaction it opened. A transaction opened on another thread or with
REQUIRES_NEWis none of its business. This follows from the structure of the code above; I did not run a separate example for it. @Transactionalon a test and on a service are read by different things. The one on a service bean is read by a proxy; the one on a test method is read by the listener. Putting@Transactionalon a test class does not turn the test object into a proxy.
6. MockMvc — the real DispatcherServlet without a server
There are two obvious ways to test a controller. Calling its method as a plain Java method is fast, but none of what Spring MVC does for you (URL mapping, argument conversion, exception handling) gets tested. Starting a real server and calling it over HTTP tests everything, but it is slow and needs a port. MockMvc sits in between. Only the server and the network are replaced with fakes; from DispatcherServlet inward, everything is real.
There are two entry points for building one (MockMvcBuilders lines 51–53, lines 76–78).
webAppContextSetup(context)uses an existingWebApplicationContextas is. Usually you pass the context that the TestContext framework of sections 1 to 5 started.standaloneSetup(controllers…)takes only controller objects, with no context. The builder creates a fake context you can put beans into by hand (StubWebApplicationContext) and builds the MVC parts such as the handler mapping and handler adapter itself (StandaloneMockMvcBuilderlines 363–369, lines 372–417).
Either way, both end up in the same code.
TestDispatcherServlet dispatcherServlet = new TestDispatcherServlet(webAppContext);
if (dispatcherServletCustomizers != null) {
for (DispatcherServletCustomizer customizers : dispatcherServletCustomizers) {
customizers.customize(dispatcherServlet);
}
}
try {
dispatcherServlet.init(servletConfig);
}
catch (ServletException ex) {
// should never happen...
throw new MockMvcBuildException("Failed to initialize TestDispatcherServlet", ex);
}
MockMvc mockMvc = new MockMvc(dispatcherServlet, filters);
TestDispatcherServlet is a class that extends DispatcherServlet (line 53). And the code calls init(servletConfig), the very method a servlet container (such as Tomcat) calls when it switches a servlet on. The initialization from part 21, collecting handler mappings, adapters, and exception resolvers from the context, happens here in exactly the same way.
All the subclass adds is "take notes on the way past".
@Override
protected @Nullable HandlerExecutionChain getHandler(HttpServletRequest request) throws Exception {
HandlerExecutionChain chain = super.getHandler(request);
if (chain != null) {
DefaultMvcResult mvcResult = getMvcResult(request);
mvcResult.setHandler(chain.getHandler());
mvcResult.setInterceptors(chain.getInterceptors());
}
return chain;
}
It lets super.getHandler(request) do the original work and writes the outcome (which handler was chosen) into the MvcResult. render and processHandlerException record the ModelAndView and the resolved exception the same way (lines 132–152). A real HTTP response carries no information such as "which controller method was chosen". A test can check even that because this subclass peeks from the inside and writes it down.
One call to perform()
MockHttpServletRequest request = requestBuilder.buildRequest(this.servletContext);
// ...
mockResponse = new MockHttpServletResponse();
servletResponse = mockResponse;
// ...
MvcResult mvcResult = new DefaultMvcResult(request, mockResponse);
request.setAttribute(MVC_RESULT_ATTRIBUTE, mvcResult);
RequestAttributes previousAttributes = RequestContextHolder.getRequestAttributes();
RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request, servletResponse));
MockFilterChain filterChain = new MockFilterChain(this.servlet, this.filters);
filterChain.doFilter(request, servletResponse);
spring-test/src/main/java/org/springframework/test/web/servlet/MockMvc.java lines 170–199
Gray boxes are the parts replaced with fakes (the request and response objects); blue boxes are the real servlet.
- Build the request. A builder such as
get("/hello")creates aMockHttpServletRequest. This class implements the servlet API'sHttpServletRequestinterface with nothing but values in memory. There is no socket and no byte stream to parse. - Create a
MockHttpServletResponseto receive the response and anMvcResultto take notes in, and attach the latter to the request as an attribute. TheTestDispatcherServletabove gets itsMvcResultfrom this attribute (lines 116–118). - Send it through a
MockFilterChain, a chain that passes through the registered filters in turn and calls the servlet'sservice()at the very end (MockFilterChainline 92). - When that is done, return a
ResultActions.andExpect(matcher)just callsmatcher.match(mvcResult)(lines 209–224). The request already finished insideperform;andExpectonly inspects the result that was left behind.
Example 4: printing the call stack inside the controller
To see "it goes through the real DispatcherServlet" with my own eyes, I printed, from inside the controller method, the frames of the current call stack whose class names contain Servlet, MockMvc, or FilterChain, outermost first.
// An example written for this post (not Spring's source)
@RestController
static class HelloController {
@GetMapping("/hello/{name}")
String hello(@PathVariable String name, HttpServletRequest request) {
// prints the class name of the request object and the call stack on the way here
return "hello, " + name;
}
}
public static void main(String[] args) throws Exception {
MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new HelloController()).build();
MvcResult result = mockMvc.perform(get("/hello/spring"))
.andExpect(status().isOk())
.andReturn();
// prints the response object, status, body, and handler
MvcResult notFound = mockMvc.perform(get("/nope")).andReturn(); // a URL that does not exist
try {
mockMvc.perform(get("/hello/spring")).andExpect(status().isNotFound()); // a deliberately wrong expectation
}
catch (AssertionError error) {
System.out.println(" AssertionError: " + error.getMessage());
}
}
The output (labels translated, as above).
[1] perform(get("/hello/spring"))
Request object the controller received: org.springframework.mock.web.MockHttpServletRequest
MockMvc, filter chain, and servlet frames on the way to the controller:
MockMvc.perform
MockFilterChain.doFilter
MockFilterChain$ServletFilterProxy.doFilter
HttpServlet.service
TestDispatcherServlet.service
FrameworkServlet.service
HttpServlet.service
FrameworkServlet.doGet
FrameworkServlet.processRequest
DispatcherServlet.doService
DispatcherServlet.doDispatch
ServletInvocableHandlerMethod.invokeAndHandle
[2] Result
Response object: org.springframework.mock.web.MockHttpServletResponse
Status 200, body "hello, spring"
Handler: ex.u24.MockMvcStandaloneExample$HelloController#hello(String, HttpServletRequest)
[3] A URL that does not exist
Status 404, handler: null
[4] When the expectation is wrong
AssertionError: Status expected:<404> but was:<200>
(A real run also prints two warning log lines saying "No mapping for GET /nope" at [3] and a Hibernate Validator version log at the top. They are left out above.) FrameworkServlet.processRequest → DispatcherServlet.doService → doDispatch is the path followed in part 21. Pulling spring out of {name} in the URL and passing it as an argument, and answering 404 for a URL that does not exist, were both done by Spring MVC's own code, not by test code.
Easy to get wrong
- What is fake is the servlet container. The "Mock" in
MockMvcdoes not mean controllers or services are faked. It means the request and response objects and the servlet container's place are filled with fakes. So whatever a servlet container would do for you does not happen. There is not a single Tomcat class in the stack above. standaloneSetupdoes not read your context configuration. You handed over only a controller, so the interceptors and message converters registered in your project's MVC configuration class are not there. Add them through the builder's methods, or usewebAppContextSetup.- Where I got stuck running the example. I first wrote the body check as
andExpect(content().string("hello, spring")), and it did not compile (class file for org.hamcrest.Matcher not found). TheContentResultMatchersreturned bycontent()also has methods that take a HamcrestMatcher, so without Hamcrest on the classpath the compiler cannot pick a method. In the example I switched to reading the body straight from theMvcResultand printing it.
For projects that use AssertJ there is a separate MockMvcTester that wraps MockMvc (MockMvcTester line 131; of(…), which builds one from controllers, is at line 221). The servlet running inside is the same; only the syntax for checking results differs. The example project does not include AssertJ, so I did not run it for this post.
What this post left out
To keep to the main thread, the following are listed by name and location only.
- Bean overrides —
@MockitoBeanand@MockitoSpyBean(context/bean/override/mockito) and@TestBean(context/bean/override/convention), which swap a bean in the context for a fake during a test. The overrides become aBeanOverrideContextCustomizerthat goes into the cache key of section 4 (BeanOverrideContextCustomizerFactorylines 42–54; itsequalsis atBeanOverrideContextCustomizerlines 74–83). So a different set of faked beans means a separate context. @DynamicPropertySource— a way to add properties whose values are known only after the test has started (the port of a temporarily started database, for example).DynamicPropertiesContextCustomizerFactoryhandles it.- Test AOT — applying the "compute ahead of time" idea from part 16 to test contexts.
TestContextAotGeneratorin thecontext/aotpackage is the generating side, andreplaceIfNecessary, the first line ofloadContextin section 4 (lines 312–325), is the fork on the run-time side. - Pausing idle contexts — calling
pause()on a context that is in the cache but has no test class using it right now, andrestart()when it is used again (DefaultContextCachelines 183–194,ContextCache.PauseModeline 379).registerContextUsagein section 4 exists for this feature. - Context hierarchies — building parent and child contexts with
@ContextHierarchy(AbstractTestContextBootstrapperlines 220–244). - Copying the
TestContextfor parallel execution — theThreadLocalin the constructor from section 2 (line 143, lines 654–674). - Also
@Sql,@RecordApplicationEvents,TestTransactionfor ending a transaction and starting a new one mid-test,WebTestClientandRestTestClient,asyncDispatchinMockMvcfor asynchronous requests, and the glue for JUnit 4 and TestNG.
Summary
| Concept | One-line summary |
|---|---|
SpringExtension |
Thin glue that receives JUnit Jupiter's callbacks and hands them to TestContextManager |
TestContextManager |
One per test class. Holds one TestContext and the listener list |
TestExecutionListener |
A listener called at seven moments of a test. It does the actual work (injection, transactions, discarding contexts) |
| Default listeners | Twelve in META-INF/spring.factories of spring-test. Sorted by order value; the "after" callbacks run in reverse |
TestContextBootstrapper |
Reads the annotations on the test class and builds the MergedContextConfiguration and the listener list |
MergedContextConfiguration |
The merged configuration and the key of the context cache. equals and hashCode compare nine things and leave out the test class |
DefaultContextCache |
A key → context map. One per JVM (static), 32 entries by default, closes the least recently used one when full |
@DirtiesContext |
Removes the context for that key from the cache and closes it |
| Test transaction | Opened by a listener, not a proxy. Rolls back by default whether the test passes or fails |
@Commit |
An annotation that carries @Rollback(false) |
MockMvc |
Only the request and response objects are fake. TestDispatcherServlet, which extends DispatcherServlet, runs the real code and records the results |
What to read next
This post showed how tests borrow the container. The context goes back to refresh() from part 7, the transaction to the transaction manager from part 17, and MockMvc to DispatcherServlet from part 21. Rather than building new machinery, spring-test turned out to be a module that calls what we saw earlier in time with the rhythm of a test.
One unit of the analysis map remains: the features that sit on top of the container. That means the cache abstraction that stores method results, scheduling that calls methods at set times, validation that checks values against rules, and resilience that retries after a failure. It continues in part 27: Cache, scheduling, validation, resilience — features layered on the container.
- Part 1: How to read source code with AI, and the analysis map
- Part 4: How a bean is created — the creation flow and the three-level cache
- Part 7: The twelve steps of refresh() — the order in which the container starts
- Part 13: ResolvableType and MergedAnnotations — reading types and annotations
- Part 17: @Transactional — who opens the transaction and who closes it
- Part 21: DispatcherServlet — how one request becomes a response