namalzi

← All posts

Digging into Spring's Source 26: spring-test — SpringExtension, TestContext, MockMvc

Spring · Published

Put one annotation on a test class and a container starts, beans get injected, and transactions roll back. We check in the real source how SpringExtension connects JUnit to Spring, how the context cache and its key start a context only once for the same configuration, how test transactions work, and how MockMvc runs the real DispatcherServlet without a server.

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.

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 {

spring-test/src/main/java/org/springframework/test/context/junit/jupiter/SpringExtension.java lines 117–119

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 structure from JUnit Jupiter down to TestExecutionListener. Jupiter calls SpringExtension, which looks up a TestContextManager in Jupiter's root Store using the test class as the key. The TestContextManager holds one TestContext and a list of TestExecutionListeners sorted by order, and that list contains the dependency injection, transaction, DirtiesContext, and other listeners. 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

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.

The order in which callbacks are called while one test method runs. beforeAll hands off to beforeTestClass, postProcessTestInstance hands off to prepareTestInstance where the dependency injection listener fills fields. beforeEach hands off to beforeTestMethod where the transaction listener opens a transaction, then the BeforeEach methods, beforeTestExecution, the Test method body, afterTestExecution, and the AfterEach methods run in turn. afterEach hands off to afterTestMethod where the transaction listener rolls back or commits, and finally afterAll hands off to afterTestClass. 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);
	}

spring-test/src/main/java/org/springframework/test/context/support/DependencyInjectionTestExecutionListener.java lines 152–160

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

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).

The flow when a TestContextManager is created. BootstrapUtils creates a new DefaultCacheAwareContextLoaderDelegate per test class and picks a bootstrapper. The delegate points at the static defaultContextCache, of which there is one per JVM. The bootstrapper reads the annotations on the test class to build a MergedContextConfiguration and prepares the listeners from the default list in spring.factories. The MergedContextConfiguration and the delegate go into a DefaultTestContext. 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());
	}

spring-test/src/main/java/org/springframework/test/context/support/AbstractTestContextBootstrapper.java lines 106–110

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;
	}

spring-test/src/main/java/org/springframework/test/context/support/DefaultTestContext.java lines 126–141

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();
			}
		}
	}

spring-test/src/main/java/org/springframework/test/context/cache/DefaultCacheAwareContextLoaderDelegate.java lines 138–203

The flow for getting a context. getApplicationContext calls the delegate's loadContext; if the cache has an equal key, the stored context is returned as is. If not, it checks whether the failure count has reached the threshold: if so it throws an exception, and otherwise, when the cache is full, it closes and evicts the least recently used context, then builds a context with the ContextLoader, refreshes it, puts it in the cache, and returns it. Yellow boxes are where the cache is read and written. Most test classes finish on the left path.

  1. Look in the cache with mergedConfig as the key. If it is there, return it and stop.
  2. 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 (CacheAwareContextLoaderDelegate line 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.
  3. Otherwise build a new context with loadContextInternal and put it in the cache. loadContextInternal hands the job to the ContextLoader that the MergedContextConfiguration carries (lines 242–258), and the loader creates a GenericApplicationContext, applies the configuration, and calls refresh() (AbstractGenericContextLoader lines 237–258). It is the same refresh() 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));

spring-test/src/main/java/org/springframework/test/context/cache/DefaultContextCache.java lines 71–72

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;
	}

spring-test/src/main/java/org/springframework/test/context/MergedContextConfiguration.java lines 538–550

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

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);
		}
	}

spring-test/src/main/java/org/springframework/test/context/transaction/TransactionalTestExecutionListener.java lines 208–248

  1. Read @Transactional from the test method (or, failing that, the class). The reader is the same AnnotationTransactionAttributeSource as in part 17 (line 160). Without the annotation, tm stays null and nothing happens.
  2. Find the transaction manager bean in the context.
  3. Decide with isRollback whether to roll back at the end, store that in a TransactionContext, and open the transaction with startTransaction(). Inside, it is exactly the transactionManager.getTransaction(…) from part 17 (TransactionContext line 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;
		}

spring-test/src/main/java/org/springframework/test/context/transaction/TransactionContext.java lines 128–138

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.

The order the two tests of Example 3 go through. insertWithDefaultRollback (no @Rollback, default true): beforeTestMethod first calls @BeforeTransaction through runBeforeTransactionMethods (active=false, rows 0), then txContext.startTransaction() opens a transaction with getTransaction. The test method inserts kim: active=true, rollback planned=true, rows 1. endTransaction in afterTestMethod rolls back because flaggedForRollback is true, and then @AfterTransaction in runAfterTransactionMethods shows active=false, rows 0. insertWithCommit (@Commit): opened the same way; the test method inserts lee: active=true, rollback planned=false, rows 1. endTransaction commits because flaggedForRollback is false, and @AfterTransaction shows active=false, rows 1 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

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).

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);

spring-test/src/main/java/org/springframework/test/web/servlet/MockMvcBuilderSupport.java lines 71–85

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;
	}

spring-test/src/main/java/org/springframework/test/web/servlet/TestDispatcherServlet.java lines 120–129

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

The flow of one MockMvc perform call. The RequestBuilder creates a MockHttpServletRequest, then a MockHttpServletResponse and a DefaultMvcResult are created, and the MockFilterChain passes through the registered filters and finally calls service on the TestDispatcherServlet. There DispatcherServlet's doDispatch calls the controller method with the same code as on a real server, and the response accumulates in the MockHttpServletResponse. Along the way the handler, view, and exception are recorded in the MvcResult, and both come back together as ResultActions. Gray boxes are the parts replaced with fakes (the request and response objects); blue boxes are the real servlet.

  1. Build the request. A builder such as get("/hello") creates a MockHttpServletRequest. This class implements the servlet API's HttpServletRequest interface with nothing but values in memory. There is no socket and no byte stream to parse.
  2. Create a MockHttpServletResponse to receive the response and an MvcResult to take notes in, and attach the latter to the request as an attribute. The TestDispatcherServlet above gets its MvcResult from this attribute (lines 116–118).
  3. Send it through a MockFilterChain, a chain that passes through the registered filters in turn and calls the servlet's service() at the very end (MockFilterChain line 92).
  4. When that is done, return a ResultActions. andExpect(matcher) just calls matcher.match(mvcResult) (lines 209–224). The request already finished inside perform; andExpect only 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

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.

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.

More posts