Spring Framework 7.0.9 深度剖析-20-测试支持:TestContext 框架与 MockMvc

8 阅读45分钟

Spring Framework 7.0.9 深度剖析-20-测试支持:TestContext 框架与 MockMvc

20.1 本章导读

Spring Framework 的 spring-test 模块是整个测试体系的基石,它与应用代码解耦、与具体测试框架(JUnit 4、JUnit Jupiter、TestNG)解耦,通过一个"测试上下文"抽象把 Spring 容器搬进单元/集成测试。本章要回答的问题是:一个标注了 @SpringJUnitConfig 的 JUnit 5 测试方法,从 JUnit 引擎开始,到测试方法执行、事务回滚、上下文被缓存复用,中间到底经过了哪些类、哪些方法、哪些状态流转?为什么两个不同的测试类能共享同一个 ApplicationContext?@DirtiesContext 背后是怎样把一个上下文从缓存里摘掉并关闭的?MockMvc.perform(get("/x")) 这一行链式调用,如何在没有真正 Servlet 容器的情况下,跑完一遍完整的 DispatcherServlet 请求分发?

本章涉及的模块只有一个:spring-test,主体源码位于 spring-test/src/main/java/org/springframework/test/。需要的前置知识包括:Spring 容器的启动与刷新(ApplicationContext 体系)、PlatformTransactionManager 事务抽象、JUnit Jupiter 扩展模型(Extension API)、Spring MVC 的 DispatcherServlet 分发流程。读完本章,你应当能够:说清 TestContextManager 的七个生命周期回调如何遍历 TestExecutionListener 并以"洋葱式"顺序传播异常;解释 ContextCache 的 key 为什么不包含测试类本身;在自定义监听器时正确选择实现哪个回调;判断 webAppContextSetup 与 standaloneSetup 的差异并理解 perform 内部创建 mock 请求/响应、驱动 MockFilterChain 的全过程;定位"上下文重复启动""事务没回滚""MockMvc 断言失败"等高频问题的源码根因。

本章所有类名、路径、方法签名与源码片段均以仓库 spring-framework-7.0.9(Spring Framework 7.0.9)实际代码为准。7.0.9 相比旧版本有若干值得点出的差异,例如 SpringExtension 已要求 JUnit Jupiter 6.0+ 并默认采用 test-method 作用域的 ExtensionContext、ContextCache 在 7.0 引入了上下文"使用计数"与"暂停(pause)"机制、TestContextManager.afterTestClass() 在 7.0 会主动把上下文标记为未使用——这些差异都会在对应源码处明确标注。

20.2 模块概览:spring-test 的结构与测试框架支持现状

spring-test 模块在包结构上分为两大块:顶层 org.springframework.test 包下按职能划分的子包,以及核心的 org.springframework.test.context 测试上下文框架。

顶层子包(spring-test/src/main/java/org/springframework/test/)职责如下:

  • context/:TestContext 框架本体,本章主角。
  • web/:Web 层测试支持,web/servlet/ 下是 MockMvc 全套(MockMvc、MockMvcBuilders、request/、result/、setup/),web/client/ 下是 MockRestServiceServer,用于 mock 掉 RestTemplate/RestClient 的服务端。
  • util/:通用测试工具类,如 ReflectionTestUtils、AopTestUtils、JsonPathExpectationsHelper、XpathExpectationsHelper。
  • annotation/:测试专用注解,如 DirtiesContext、Timed、Repeat、Sql、SqlGroup。
  • jdbc/:@Sql 注解背后的 SQL 脚本执行支持。
  • http/、json/、validation/:HTTP 模拟、JSON 断言、校验相关工具。

此外还有一个经常被忽略但极其重要的包:org.springframework.mock.web(位于 spring-test/src/main/java/org/springframework/mock/web/),它提供了 MockHttpServletRequest、MockHttpServletResponse、MockHttpSession、MockServletContext 等一整套 Servlet API 的内存实现。MockMvc 之所以不需要真正的 Tomcat,靠的就是这组 mock 对象——它们完整实现了 jakarta.servlet 接口,但全部在 JVM 内存里记录请求参数、响应状态、header、body。

在"对测试框架的支持"这一点上,7.0.9 的现状需要以仓库为准,不能凭旧记忆:

  • JUnit Jupiter(JUnit 5):核心集成类是 org.springframework.test.context.junit.jupiter.SpringExtension,路径为 spring-test/src/main/java/org/springframework/test/context/junit/jupiter/SpringExtension.java。它是一个 JUnit Jupiter 扩展(Extension),通过实现一系列回调接口桥接到 TestContextManager。需要特别强调:7.0.9 的 SpringExtension 类级 Javadoc 明确写着 "This class requires JUnit Jupiter 6.0 or higher"——这是与很多老资料(JUnit 5.0/5.x 时代)的重要差异。
  • JUnit 4:仓库中仍然保留 org.springframework.test.context.junit4 包,内含 SpringJUnit4ClassRunner、SpringRunner(后者只是前者的别名)、AbstractJUnit4SpringContextTests、AbstractTransactionalJUnit4SpringContextTests。也就是说 JUnit 4 的支持在 7.0.9 仍在,但它属于"兼容遗留",新代码应使用 JUnit Jupiter。
  • TestNG:context/testng/ 包仍存在(AbstractTestNGSpringContextTests),同样为兼容遗留。

本章聚焦 JUnit Jupiter 路径,因为它是 7.0.9 的一等公民,机制也最完整(JUnit 4 的 SpringMethodRule 甚至不支持 beforeTestExecution/afterTestExecution 两个回调,这一点在 TestExecutionListener 接口 Javadoc 里有明确说明)。

20.3 核心概念:先把抽象对齐

在深入源码前,必须先把本章反复出现的几个核心抽象定义清楚。它们不是名词表,而是后续源码剖析的"坐标系"。

TestContext(测试上下文):接口 org.springframework.test.context.TestContext,路径 spring-test/src/main/java/org/springframework/test/context/TestContext.java。它封装"一次测试执行所处的上下文",与具体测试框架无关。它继承了 AttributeAccessor(属性存取器),并声明了 getApplicationContext()、getTestClass()、getTestInstance()、getTestMethod()、getTestException()、updateState(...)、markApplicationContextDirty(...) 等方法。它的标准实现是 DefaultTestContext。注意它同时继承了 Serializable——这是为了支持并行测试时上下文状态的复制。

TestContextManager(测试上下文管理器):org.springframework.test.context.TestContextManager,是整个框架的主入口。它负责管理一个 TestContext,并在测试执行的各个时间点,把事件分发给所有已注册的 TestExecutionListener。一个测试类对应一个(或一组,用于并行)TestContextManager 实例。

TestExecutionListener(测试执行监听器):接口 org.springframework.test.context.TestExecutionListener。它定义了七个回调方法(beforeTestClass、prepareTestInstance、beforeTestMethod、beforeTestExecution、afterTestExecution、afterTestMethod、afterTestClass),全部带空的 default 实现,因此自定义监听器只需覆盖关心的回调。框架的所有"魔法"——依赖注入、事务包裹、SQL 脚本执行、事件发布——都是这个接口的实现。

SmartContextLoader / ContextLoader(上下文加载器):策略接口,负责真正去创建并刷新 ApplicationContext。默认实现链是 DelegatingSmartContextLoader,它在 XML 与注解配置两种加载器之间分派。

ContextCache(上下文缓存):org.springframework.test.context.cache.ContextCache,接口,标准实现 DefaultContextCache。它以 MergedContextConfiguration 为 key 缓存 ApplicationContext,让多个测试类共享同一个容器,避免每个测试都重新启动 Spring。

MergedContextConfiguration(合并后的上下文配置):org.springframework.test.context.MergedContextConfiguration。它把测试类(含父类、嵌套类)上的 @ContextConfiguration、@ActiveProfiles、@TestPropertySource、初始化器、上下文加载器等配置"合并"成一个不可变对象。它既是加载器的输入,也是缓存的 key——它的 equals/hashCode 决定了"两个测试类的配置是否被视为相同"。

MockMvc:org.springframework.test.web.servlet.MockMvc,服务端 Spring MVC 测试的主入口。它持有一个 TestDispatcherServlet(DispatcherServlet 的测试子类)和一组 Filter,通过 perform(RequestBuilder) 在内存里跑一遍请求分发,再通过 ResultActions 链式做断言。

把这些抽象记牢:TestContextManager 是调度中枢,TestContext 是被调度的状态,TestExecutionListener 是挂在中枢上的插件,ContextLoader+ContextCache 负责上下文的"造与存",MockMvc 则是独立于 TestContext 框架的另一套 Web 测试设施。

20.4 源码剖析(一):TestContextManager 的构造与监听器装配

20.4.1 两个构造器:从测试类到引导器

TestContextManager 是一切事件的调度中枢。它的构造分两步,源码见 spring-test/src/main/java/org/springframework/test/context/TestContextManager.java 第 125-145 行:

public TestContextManager(Class<?> testClass) {
	this(BootstrapUtils.resolveTestContextBootstrapper(testClass));
}

public TestContextManager(TestContextBootstrapper testContextBootstrapper) {
	this.testContext = testContextBootstrapper.buildTestContext();
	this.testContextHolder = ThreadLocal.withInitial(() -> copyTestContext(this.testContext));
	registerTestExecutionListeners(testContextBootstrapper.getTestExecutionListeners());
}

逐段解读:

  1. 单参数构造器只做一件事:把测试类交给 BootstrapUtils.resolveTestContextBootstrapper(testClass),解析出一个 TestContextBootstrapper(测试上下文引导器)。引导器的选择规则在其 Javadoc 中写明:若测试类上直接或作为元注解存在 @BootstrapWith,就用它指定的引导器类型;否则用默认的 DefaultTestContextBootstrapper。这是一个典型的"可替换引导策略"设计——Spring Boot 的 @SpringBootTest 正是通过 @BootstrapWith(SpringBootTestContextBootstrapper.class) 把引导器换成自己的,从而接管整个上下文构建流程。本仓库(纯 Framework)没有 Spring Boot,默认路径就是 DefaultTestContextBootstrapper。

  2. 双参数构造器是真正干活的地方,它做三件事:

    • buildTestContext():让引导器构建出 TestContext。引导器内部会解析 @ContextConfiguration 等注解,合并成 MergedContextConfiguration,再连同一个 CacheAwareContextLoaderDelegate 一起 new 出 DefaultTestContext。注意此时上下文本身还没被加载——DefaultTestContext 只持有 mergedConfig 这个"配方",真正的 ApplicationContext 是延迟到第一次 getApplicationContext() 时才创建的。
    • testContextHolder = ThreadLocal.withInitial(...):这是 6.x 以后为并行测试引入的关键设计。TestContextManager 本身是被多线程共享的(一个测试类一个 manager,存在 JUnit 的 Store 里),但 TestContext 内部的 testInstance/testMethod/testException 是易变状态。如果多个测试线程并发执行同一个测试类的方法,它们必须各自拥有一份 TestContext 副本,否则会互相覆盖当前测试实例。ThreadLocal 保证每个线程拿到的是自己那份副本;withInitial 用 copyTestContext 把原始 TestContext 当模板复制一份。
    • registerTestExecutionListeners(...):把引导器解析出的默认监听器列表注册进来。

20.4.2 copyTestContext:并行测试的复制构造器约定

copyTestContext 的源码在同文件第 654-674 行:

private static TestContext copyTestContext(TestContext testContext) {
	Constructor<? extends TestContext> constructor =
			ClassUtils.getConstructorIfAvailable(testContext.getClass(), testContext.getClass());

	if (constructor != null) {
		try {
			ReflectionUtils.makeAccessible(constructor);
			return constructor.newInstance(testContext);
		}
		catch (Exception ex) {
			if (logger.isInfoEnabled()) {
				logger.info("""
						Failed to invoke copy constructor for [%s]; concurrent test execution \
						is therefore likely not supported.""".formatted(testContext), ex);
			}
		}
	}
	// Fallback to original instance
	return testContext;
}

这段代码揭示了一个重要约定:TestContext 的具体实现强烈建议提供一个"拷贝构造器"(接受自身类型单个参数的构造器)。DefaultTestContext 确实提供了,见 DefaultTestContext.java 第 78-82 行:

public DefaultTestContext(DefaultTestContext testContext) {
	this(testContext.testClass, testContext.mergedConfig,
		testContext.cacheAwareContextLoaderDelegate);
	this.attributes.putAll(testContext.attributes);
}

注意它拷贝了什么、没拷贝什么:testClass、mergedConfig、cacheAwareContextLoaderDelegate 这三个不可变引用被原样共享(它们本来就是只读的,且 cacheAwareContextLoaderDelegate 背后是静态全局的 ContextCache,必须共享才能跨线程复用上下文);attributes(监听器之间传递的属性 Map)被拷贝一份,避免线程间串扰;而 testInstance/testMethod/testException 这三个 volatile 易变字段,拷贝构造器故意不复制——它们在副本里初始化为 null,随后由 updateState 按当前线程的测试填充。

如果某个自定义 TestContext 实现没有拷贝构造器,copyTestContext 会 fallback 到返回原实例本身,并打一条 info 日志提示"并行测试可能不被支持"。这就是为什么规范要求自定义 TestContext 实现拷贝构造器。

20.4.3 监听器注册:追加、不主动去重

监听器注册逻辑在第 159-174 行:

public void registerTestExecutionListeners(List<TestExecutionListener> testExecutionListeners) {
	registerTestExecutionListeners(testExecutionListeners.toArray(new TestExecutionListener[0]));
}

public void registerTestExecutionListeners(TestExecutionListener... testExecutionListeners) {
	for (TestExecutionListener listener : testExecutionListeners) {
		if (logger.isTraceEnabled()) {
			logger.trace("Registering TestExecutionListener: " + typeName(listener));
		}
		this.testExecutionListeners.add(listener);
	}
}

可以看到:注册就是简单地 add 到一个 ArrayList,管理器本身不做去重。那"去重"和"合并用户显式声明的监听器"发生在哪里?发生在引导器 AbstractTestContextBootstrapper.getTestExecutionListeners() 里:它先通过 SpringFactoriesLoader 从 META-INF/spring.factories 加载全部默认监听器,再根据测试类上的 @TestExecutionListeners(mergeMode=...) 决定是 REPLACE_DEFAULTS(替换默认,只留用户指定的)还是 MERGE_WITH_DEFAULTS(与默认合并,并按类型去重)。TestContextManager 收到的已经是合并去重后的最终有序列表。

监听器的顺序非常关键,因为它决定了"洋葱式包裹"。默认监听器的顺序由每个监听器实现的 Ordered.getOrder() 数值决定。以 TransactionalTestExecutionListener 为例,其 ORDER = 4000(TransactionalTestExecutionListener.java 第 154 行),Javadoc 说明它被排在 CommonCachesTestExecutionListener 之后、SqlScriptsTestExecutionListener 之前。这样一来:事务先开,SQL 脚本在事务内执行,最后一起回滚——这正是 TestExecutionListener 接口 Javadoc 里举的经典包裹例子。

20.4.4 默认监听器清单(7.0.9 实际)

7.0.9 的默认监听器清单写在 TestExecutionListener 接口 Javadoc 第 92-117 行,按注册顺序为:

  1. ServletTestExecutionListener(初始化 Servlet mock 环境)
  2. DirtiesContextBeforeModesTestExecutionListener(处理 @DirtiesContext 的 BEFORE_* 模式)
  3. ApplicationEventsTestExecutionListener(支持 ApplicationEvents 注入)
  4. BeanOverrideTestExecutionListener(@MockitoBean 等 bean 覆盖)
  5. DependencyInjectionTestExecutionListener(对测试实例做依赖注入)
  6. MicrometerObservationRegistryTestExecutionListener(Observation 支持)
  7. DirtiesContextTestExecutionListener(处理 @DirtiesContext 的 AFTER_* 模式)
  8. CommonCachesTestExecutionListener(清理 SpEL/Jackson 等公共缓存)
  9. TransactionalTestExecutionListener(事务包裹)
  10. SqlScriptsTestExecutionListener(执行 @Sql 脚本)
  11. EventPublishingTestExecutionListener(把测试事件发布成 Spring ApplicationEvent)
  12. MockitoResetTestExecutionListener(重置 @MockitoBean mock)

这份清单与很多老资料(只有 4-5 个监听器)差别很大,是 7.0.9 的实际现状。后面剖析事务、依赖注入时会反复回到其中几个。

20.5 源码剖析(二):TestContextManager 的生命周期回调与异常传播

TestContextManager 的真正价值在于它把测试执行切成七个时间点,并在每个时间点按固定策略遍历监听器。这里最容易被误解的是异常传播策略:前置回调(before 系)是"遇到异常立即中断",后置回调(after 系)是"全部执行、首个异常 rethrow、后续异常 suppressed",且后置回调要逆序遍历。下面逐个拆开。

20.5.1 beforeTestClass:正序遍历,即抛即停

源码见 TestContextManager.java 第 210-231 行:

public void beforeTestClass() throws Exception {
	try {
		Class<?> testClass = getTestContext().getTestClass();
		if (logger.isTraceEnabled()) {
			logger.trace("beforeTestClass(): class [" + typeName(testClass) + "]");
		}
		getTestContext().updateState(null, null, null);

		for (TestExecutionListener testExecutionListener : getTestExecutionListeners()) {
			try {
				testExecutionListener.beforeTestClass(getTestContext());
			}
			catch (Throwable ex) {
				logException(ex, "beforeTestClass", testExecutionListener, testClass);
				ReflectionUtils.rethrowException(ex);
			}
		}
	}
	finally {
		resetMethodInvoker();
	}
}

逐段解读:

  • 开头 updateState(null, null, null):把当前线程那份 TestContext 的 testInstance/testMethod/testException 全部清空。类级别回调时还没有测试实例和方法,所以重置状态。
  • for 循环按注册正序遍历监听器。每个监听器调用包在自己的 try/catch 里;一旦某个监听器抛 Throwable,先 logException 记录,再 ReflectionUtils.rethrowException(ex) 直接抛出——这意味着后续监听器不再被调用。这是前置回调的核心语义:前面的准备失败了,后面的准备就没意义了,整个测试类直接失败。
  • finally { resetMethodInvoker(); }:把 MethodInvoker 重置为默认值。MethodInvoker 是 6.1 引入的扩展点,允许框架(如 JUnit)把"如何反射调用一个方法"的能力注入 TestContext,供监听器(如执行 @BeforeTransaction 方法)使用;用完即重置,避免影响后续测试。

20.5.2 prepareTestInstance:实例化后立刻注入

第 251-284 行,结构与 beforeTestClass 几乎一致(正序、即抛即停),但有一个针对"跳过异常"的特殊分支:

for (TestExecutionListener testExecutionListener : getTestExecutionListeners()) {
	try {
		testExecutionListener.prepareTestInstance(getTestContext());
	}
	catch (Throwable ex) {
		if (isSkippedException(ex)) {
			if (logger.isInfoEnabled()) {
				logger.info("""
						Caught exception while allowing TestExecutionListener [%s] to \
						prepare test instance [%s]""".formatted(typeName(testExecutionListener), testInstance), ex);
			}
		}
		else if (logger.isWarnEnabled()) {
			logger.warn("""
				Caught exception while allowing TestExecutionListener [%s] to \
				prepare test instance [%s]""".formatted(typeName(testExecutionListener), testInstance), ex);
		}
		ReflectionUtils.rethrowException(ex);
	}
}

这里 isSkippedException(ex) 判断异常是否属于"测试被跳过"类型。它的来源是类顶部第 95-104 行注册的三个异常类型:

private static final Set<Class<? extends Throwable>> skippedExceptionTypes = CollectionUtils.newLinkedHashSet(3);
static {
	// JUnit Jupiter
	registerSkippedExceptionType("org.opentest4j.TestAbortedException");
	// JUnit 4
	registerSkippedExceptionType("org.junit.AssumptionViolatedException");
	// TestNG
	registerSkippedExceptionType("org.testng.SkipException");
}

也就是说,如果准备实例时抛出的是 TestAbortedException(JUnit 5 的 assumption 失败/被中止)等"跳过"异常,它只记 info 日志,然后照样 rethrow——这样测试会被正确标记为"跳过"而不是"失败"。registerSkippedExceptionType 用类名反射加载,加载不到(比如用的是 JUnit 4 环境,没有 opentest4j)就静默忽略,保证框架间兼容。

20.5.3 beforeTestMethod 与 beforeTestExecution:两个"前置"的区别

这两个方法结构完全相同(第 313-330 行与 357-374 行):

public void beforeTestMethod(Object testInstance, Method testMethod) throws Exception {
	try {
		String callbackName = "beforeTestMethod";
		prepareForBeforeCallback(callbackName, testInstance, testMethod);

		for (TestExecutionListener testExecutionListener : getTestExecutionListeners()) {
			try {
				testExecutionListener.beforeTestMethod(getTestContext());
			}
			catch (Throwable ex) {
				handleBeforeException(ex, callbackName, testExecutionListener, testInstance, testMethod);
			}
		}
	}
	finally {
		resetMethodInvoker();
	}
}
  • prepareForBeforeCallback(第 587-592 行)就是 getTestContext().updateState(testInstance, testMethod, null),把当前测试实例与方法写进线程副本。
  • 同样正序遍历、handleBeforeException 即抛即停。

关键在于二者的调用时机(这是 SpringExtension 桥接时严格遵守的约定,见 Javadoc):

  • beforeTestMethod:必须在测试框架自己的 @BeforeEach 回调之前调用。名字虽叫 beforeTestMethod,但 Javadoc 自嘲"历史原因没法改名,其实叫 beforeTestSetUp 更合适"。事务就是在这里开启的——它要包住 @BeforeEach 写的准备代码,保证 @BeforeEach 里插的数据也在事务内、最终也回滚。
  • beforeTestExecution:必须在 @BeforeEach 之后、测试方法马上要执行之前调用。这个回调 5.0 才引入,主要给计时、日志这类"紧贴测试方法本身"的切面用。

记住这个顺序:beforeTestMethod(Spring 侧,开事务/DI)→ JUnit 的 @BeforeEach → beforeTestExecution → 测试方法。

20.5.4 afterTestExecution 与 afterTestMethod:逆序遍历、异常 suppressed

后置回调是另一种语义。以 afterTestMethod 为例(第 476-508 行):

public void afterTestMethod(Object testInstance, Method testMethod, @Nullable Throwable exception)
		throws Exception {

	try {
		String callbackName = "afterTestMethod";
		prepareForAfterCallback(callbackName, testInstance, testMethod, exception);
		Throwable afterTestMethodException = null;

		// Traverse the TestExecutionListeners in reverse order to ensure proper
		// "wrapper"-style execution of listeners.
		for (TestExecutionListener testExecutionListener : getReversedTestExecutionListeners()) {
			try {
				testExecutionListener.afterTestMethod(getTestContext());
			}
			catch (Throwable ex) {
				logException(ex, callbackName, testExecutionListener, testInstance, testMethod);
				if (afterTestMethodException == null) {
					afterTestMethodException = ex;
				}
				else {
					afterTestMethodException.addSuppressed(ex);
				}
			}
		}

		if (afterTestMethodException != null) {
			ReflectionUtils.rethrowException(afterTestMethodException);
		}
	}
	finally {
		resetMethodInvoker();
	}
}

逐段解读三个关键设计:

  1. 逆序遍历:getReversedTestExecutionListeners()(第 190-194 行)把列表拷贝一份再 Collections.reverse。为什么要逆序?这就是"洋葱式包裹":前置按 L1→L2→L3 顺序调用,后置就必须按 L3→L2→L1 逆序调用,才能形成对称的包裹结构。举例:TransactionalTestExecutionListener(order 4000,较靠后)在 before 时开事务,SqlScriptsTestExecutionListener(更靠后)在事务里跑脚本;那么 after 时就必须先让 SqlScripts 收尾,再让 Transactional 回滚——逆序正好保证这一点。

  2. 异常不中断后续监听器:每个监听器的异常被 catch 后只记录、不 rethrow,循环继续。为什么?测试方法已经跑完了,收尾阶段即使某个监听器出错,也不能让后面的监听器失去收尾机会(比如事务还没回滚,不能因为别的监听器报错就悬着)。

  3. 首个异常 rethrow,其余 suppressed:afterTestMethodException 只记录第一个异常;后续每个异常通过 addSuppressed(ex) 挂到第一个异常上,最后统一 rethrow 第一个。这既保证测试失败信息不丢失,又能通过 Throwable.getSuppressed() 看到收尾阶段的全部问题,符合 JDK 7 以来 try-with-resources 的异常处理范式。

afterTestExecution(第 408-440 行)逻辑完全相同,只是回调名不同。

20.5.5 afterTestClass:7.0 新增"标记上下文未使用"

afterTestClass(第 532-576 行)也是逆序遍历、异常 suppressed,但尾部有一段 7.0 新增的逻辑:

try {
	if (getTestContext().hasApplicationContext()) {
		getTestContext().markApplicationContextUnused();
	}
}
catch (Throwable ex) {
	if (afterTestClassException == null) {
		afterTestClassException = ex;
	}
	else {
		afterTestClassException.addSuppressed(ex);
	}
}

this.testContextHolder.remove();

if (afterTestClassException != null) {
	ReflectionUtils.rethrowException(afterTestClassException);
}

逐段解读:

  • 逆序跑完所有监听器的 afterTestClass 后,如果本测试类确实加载过上下文(hasApplicationContext()),调用 markApplicationContextUnused()。这个方法在 7.0 之前是没有的,它通知 ContextCache:"本测试类用完上下文了",缓存据此可以对"已无测试类在用"的上下文执行 pause()(暂停而非关闭,见缓存一节),从而在内存/资源紧张时释放连接池等资源,又不用彻底重启容器。
  • testContextHolder.remove():清理 ThreadLocal,防止线程池复用时串状态。
  • 最后 rethrow 累积的首个异常。

至此七个回调(beforeTestClass/prepareTestInstance/beforeTestMethod/beforeTestExecution/afterTestExecution/afterTestMethod/afterTestClass)全部串完。记住一个总原则:before 系正序、即抛即停;after 系逆序、全执行、首异常 rethrow+余 suppressed。这就是监听器"包裹行为(wrapping behavior)"的源码实现。

20.6 源码剖析(三):TestContext 与 DefaultTestContext——延迟加载与属性存储

20.6.1 接口:一个"与测试框架无关"的状态容器

TestContext 接口(spring-test/src/main/java/org/springframework/test/context/TestContext.java)继承了三个角色:AttributeAccessor(属性存取)、Serializable(可序列化/复制),以及它自身声明的一组方法。它把"一次测试执行的上下文"抽象成:不可变的测试类配置 + 易变的当前测试状态 + 按需加载的 ApplicationContext。

接口里几个方法的语义要特别点出:

  • ApplicationContext getApplicationContext():Javadoc 明确说"实现负责在上下文尚未加载时去加载它,可能同时缓存"。这就是延迟加载的契约——TestContext 构造时不碰容器。
  • default boolean hasApplicationContext()(第 68-70 行):默认返回 false,标准实现 DefaultTestContext 覆盖它,用于高效判断"上下文是否已加载"而不触发加载。
  • void updateState(Object testInstance, Method testMethod, Throwable testException):Javadoc 用全大写 WARNING 标注"只能由 TestContextManager 调用"。它是易变状态的唯一写入入口。
  • void markApplicationContextDirty(HierarchyMode):标记上下文脏,要求缓存把它摘掉关闭。
  • default void markApplicationContextUnused()(第 163-165 行,7.0 新增):默认空实现,标准实现覆盖为通知缓存"本测试类用完了"。
  • setMethodInvoker/getMethodInvoker(6.1 新增):默认空/no-op,用于把 JUnit 的方法调用能力注入监听器。

20.6.2 DefaultTestContext:字段与延迟加载

标准实现 DefaultTestContext(spring-test/src/main/java/org/springframework/test/context/support/DefaultTestContext.java)的字段区(第 52-66 行)很能说明问题:

private final Map<String, Object> attributes = new ConcurrentHashMap<>(4);

private final CacheAwareContextLoaderDelegate cacheAwareContextLoaderDelegate;

private final MergedContextConfiguration mergedConfig;

private final Class<?> testClass;

private volatile @Nullable Object testInstance;

private volatile @Nullable Method testMethod;

private volatile @Nullable Throwable testException;

private volatile MethodInvoker methodInvoker = MethodInvoker.DEFAULT_INVOKER;

字段分两类:

  • 不可变(final):attributes(属性 Map 本身引用不变,内容可增删)、cacheAwareContextLoaderDelegate、mergedConfig(上下文配方)、testClass。这些在拷贝构造时被共享或浅拷贝。
  • 易变(volatile):testInstance、testMethod、testException、methodInvoker。用 volatile 是因为并行测试下,虽然 ThreadLocal 已经保证每个线程一份副本,但同一份副本在不同回调间被读写,volatile 保证可见性。

核心方法 getApplicationContext()(第 127-141 行):

@Override
public ApplicationContext getApplicationContext() {
	ApplicationContext context = this.cacheAwareContextLoaderDelegate.loadContext(this.mergedConfig);
	if (context instanceof ConfigurableApplicationContext cac) {
		Assert.state(cac.isActive(), () -> """
				The ApplicationContext loaded for %s is not active. \
				This may be due to one of the following reasons: \
				1) the context was closed programmatically by user code; \
				2) the context was closed during parallel test execution either \
				according to @DirtiesContext semantics or due to automatic eviction \
				from the ContextCache due to a maximum cache size policy."""
					.formatted(this.mergedConfig));
	}
	this.cacheAwareContextLoaderDelegate.registerContextUsage(this.mergedConfig, this.testClass);
	return context;
}

逐段解读:

  1. 真正的加载被委托给 cacheAwareContextLoaderDelegate.loadContext(mergedConfig)。DefaultTestContext 自己完全不知道怎么造容器,它只拿"配方"(mergedConfig)去找代理。代理背后接的是全局 ContextCache——这就是延迟加载+缓存复用的全部秘密:第一次调用 miss,代理去加载并放入缓存;后续调用(包括其他测试类)命中缓存,直接返回同一个实例。
  2. 拿到后断言 isActive():如果上下文已经不是 active 状态(被关了),就抛出带详细原因的 IllegalStateException。错误信息列了两种典型原因:用户代码自己关了上下文;并行测试下因 @DirtiesContext 语义或缓存 maxSize 自动淘汰被关。这条错误信息是排查"上下文莫名关闭"的第一手线索。
  3. registerContextUsage(mergedConfig, testClass):7.0 新增。每取一次上下文,就登记"测试类 testClass 正在使用它"。这是 7.0 "暂停"机制的计数基础——等所有测试类都 markApplicationContextUnused(取消登记)后,缓存才敢把上下文 pause。

hasApplicationContext()(第 113-115 行)则是纯查询,不触发加载:

@Override
public boolean hasApplicationContext() {
	return this.cacheAwareContextLoaderDelegate.isContextLoaded(this.mergedConfig);
}

20.6.3 updateState 与属性存取

updateState(第 196-200 行)极其简单:

@Override
public void updateState(@Nullable Object testInstance, @Nullable Method testMethod, @Nullable Throwable testException) {
	this.testInstance = testInstance;
	this.testMethod = testMethod;
	this.testException = testException;
}

就是三个易变字段的赋值。它是 TestContextManager 在每个回调开头调用的,用来把"当前正在跑哪个测试"写进上下文。监听器通过 getTestInstance()/getTestMethod() 读到这些值——注意 getTestInstance()(第 177-181 行)在尚未设置时会 Assert.state(testInstance != null, "No test instance") 直接报错,这也是为什么监听器要遵守回调时机约定:在 prepareTestInstance 之前调用方不该去读 testInstance。

属性存取实现了 AttributeAccessor。setAttribute(第 214-224 行)在写时加 synchronized(this.attributes),值为 null 时等于 remove:

@Override
public void setAttribute(String name, @Nullable Object value) {
	Assert.notNull(name, "Name must not be null");
	synchronized (this.attributes) {
		if (value != null) {
			this.attributes.put(name, value);
		}
		else {
			this.attributes.remove(name);
		}
	}
}

computeAttribute(第 234-241 行)则直接用 attributes.computeIfAbsent。这些属性是监听器之间传递信号的通道——最经典的例子是 DependencyInjectionTestExecutionListener.REINJECT_DEPENDENCIES_ATTRIBUTE:某个监听器把这个属性设成 Boolean.TRUE,DI 监听器在 beforeTestMethod 时检测到它,就会在测试方法前重新注入一次依赖(见 20.8 节)。属性机制让监听器之间不必直接耦合。

20.6.4 markApplicationContextDirty 与 markApplicationContextUnused

两个标记方法都是薄封装:

@Override
public void markApplicationContextDirty(@Nullable HierarchyMode hierarchyMode) {
	this.cacheAwareContextLoaderDelegate.closeContext(this.mergedConfig, hierarchyMode);
}

@Override
public void markApplicationContextUnused() {
	this.cacheAwareContextLoaderDelegate.unregisterContextUsage(this.mergedConfig, this.testClass);
}

markApplicationContextDirty 委托 closeContext——即从缓存摘除并关闭上下文,@DirtiesContext 最终走的就是这条路;markApplicationContextUnused 委托 unregisterContextUsage——减引用计数,为 pause 做准备。注意二者的区别:dirty 是"上下文被改坏了,必须丢弃重建";unused 是"上下文没人用了,可以暂停省资源,但以后还可能重启复用"。这是 7.0 在上下文生命周期管理上的精细化。

20.7 源码剖析(四):上下文加载链——从 @ContextConfiguration 到容器刷新

20.7.1 @ContextConfiguration 与 ContextConfigurationAttributes

测试类上最常用的配置注解是 @ContextConfiguration(spring-test/src/main/java/org/springframework/test/context/ContextConfiguration.java)。它的核心属性包括:locations/value(XML 或 Groovy 资源路径)、classes(注解配置类)、initializers(ApplicationContextInitializer 数组)、inheritLocations/inheritInitializers(是否继承父类配置,默认 true)、loader(指定自定义加载器,默认 DelegatingSmartContextLoader.class)。

引导器 AbstractTestContextBootstrapper 在解析时,把每个层级(父类、嵌套类)上的 @ContextConfiguration 读出来,包装成 ContextConfigurationAttributes(spring-test/src/main/java/org/springframework/test/context/support/ContextConfigurationAttributes.java),再交给加载器的 processContextConfiguration 回调做"位置推导与处理",最后合并成一个 MergedContextConfiguration。这个合并过程是缓存 key 的来源,后面讲。

20.7.2 AbstractContextLoader:模板方法与 prepareContext

AbstractContextLoader(spring-test/src/main/java/org/springframework/test/context/support/AbstractContextLoader.java)实现了 SmartContextLoader,是所有具体加载器的基类。它用模板方法模式定义了加载骨架。

processContextConfiguration(第 94-99 行)把注解里的 locations 交给 processLocationsInternal 处理,后者会做"默认位置推导"——如果你写 @ContextConfiguration 却不写 locations,它会按约定在测试类同包下找 类名-context.xml。这就是为什么 FooDaoTest 不写任何路径也能加载到 FooDaoTest-context.xml。

更重要的是 prepareContext(Javadoc 见第 102-127 行),它在 bean 定义被读取之前对新建的上下文做三件事:

  1. 把 MergedContextConfiguration 里的 activeProfiles 设置到上下文的 Environment;
  2. 把 @TestPropertySource 指定的属性文件位置与内联属性作为 PropertySource 加到 Environment;
  3. 实例化并按 @Order 排序后调用所有 ApplicationContextInitializer.initialize(context)。

这三步解释了一个常见困惑:为什么 @ActiveProfiles 和 @TestPropertySource 能在容器刷新前就影响 Environment——因为 prepareContext 发生在 refresh() 之前,此时 Environment 还是可写的。

20.7.3 DelegatingSmartContextLoader:默认加载器的分派逻辑

DefaultTestContextBootstrapper.getDefaultContextLoaderClass(DefaultTestContextBootstrapper.java 第 36-38 行)直接返回 DelegatingSmartContextLoader.class。也就是说,除非你在 @ContextConfiguration(loader=...) 里显式指定,否则用的都是它。

它的构造器(DelegatingSmartContextLoader.java 第 52-69 行)揭示了分派策略:

public DelegatingSmartContextLoader() {
	if (GROOVY_PRESENT) {
		try {
			Class<?> loaderClass = ClassUtils.forName(GROOVY_XML_CONTEXT_LOADER_CLASS_NAME,
				DelegatingSmartContextLoader.class.getClassLoader());
			this.xmlLoader = (SmartContextLoader) BeanUtils.instantiateClass(loaderClass);
		}
		catch (Throwable ex) {
			throw new IllegalStateException("Failed to enable support for Groovy scripts; "
					+ "could not load class: " + GROOVY_XML_CONTEXT_LOADER_CLASS_NAME, ex);
		}
	}
	else {
		this.xmlLoader = new GenericXmlContextLoader();
	}

	this.annotationConfigLoader = new AnnotationConfigContextLoader();
}

它内部持有两个加载器:xmlLoader(若 classpath 有 Groovy 则用 GenericGroovyXmlContextLoader,否则用 GenericXmlContextLoader)和 annotationConfigLoader(AnnotationConfigContextLoader)。GROOVY_PRESENT 通过 ClassUtils.isPresent("groovy.lang.Closure", ...) 探测——这是 Spring 一贯的"可选依赖"写法,没有 Groovy 也不报错。

分派逻辑在其父类 AbstractDelegatingSmartContextLoader 中:当你给的是 classes(注解类),就委托 annotationConfigLoader;当你给的是 locations(XML 路径),就委托 xmlLoader;两者都没给,就按"类名-context.xml 是否存在"做智能判断。这就是"Smart"加载器的含义——它替你在 XML 与 Java Config 之间做路由。

20.7.4 AnnotationConfigContextLoader 与 GenericXmlContextLoader

AnnotationConfigContextLoader(spring-test/src/main/java/org/springframework/test/context/support/AnnotationConfigContextLoader.java)负责 @ContextConfiguration(classes=AppConfig.class) 这种 Java 配置场景。它创建一个 GenericApplicationContext,把 classes 注册成 bean 定义,再走 prepareContext→refresh。它与生产环境的 AnnotationConfigApplicationContext 同源,但挂在测试加载体系下,从而能被 MergedContextConfiguration 正确纳入缓存 key。

GenericXmlContextLoader(同包)则负责 XML 资源:它把 locations 解析成 Resource,通过 XmlBeanDefinitionReader 加载 bean 定义。二者最终都继承 AbstractGenericContextLoader,共享"创建上下文→prepareContext→读 bean 定义→customizeContext→refresh"的模板骨架。

这里要点出一个版本事实:7.0.9 中 @SpringJUnitConfig(见 20.10 注解体系)本质上是 @ExtendWith(SpringExtension.class) + @ContextConfiguration 的组合注解,它的 classes 属性是 @ContextConfiguration.classes 的别名(SpringJUnitConfig.java 第 62-63 行 @AliasFor(annotation = ContextConfiguration.class, attribute = "classes"))。所以用 @SpringJUnitConfig(AppConfig.class) 时,加载路径最终落在 AnnotationConfigContextLoader。

20.7.5 加载的总入口:CacheAwareContextLoaderDelegate.loadContext

无论哪个加载器,真正被 DefaultTestContext.getApplicationContext() 调到的是 DefaultCacheAwareContextLoaderDelegate.loadContext(spring-test/src/main/java/org/springframework/test/context/cache/DefaultCacheAwareContextLoaderDelegate.java 第 139-203 行)。这段是"缓存交互 + 加载 + 失败计数"的总控,下一节结合缓存详解。这里先记住结论:测试代码从不直接 new 容器,它只对 TestContext 要上下文;上下文的造、存、淘汰全由代理+缓存负责。

20.8 源码剖析(五):上下文缓存——MergedContextConfiguration 的 key、LRU 淘汰与 7.0 的 pause

20.8.1 缓存的全局静态实例

DefaultCacheAwareContextLoaderDelegate 第 76 行:

static final ContextCache defaultContextCache = new DefaultContextCache();

这个 static final 字段是理解整个缓存机制的钥匙:缓存是 JVM 进程级、全局共享的单例。无参构造器(第 102-104 行)直接 this(defaultContextCache)。这意味着不管你在多少个测试类里 new 多少个 TestContextManager,它们背后连的是同一个 ContextCache。这正是"多个测试类共享同一个 ApplicationContext"的物理基础。

20.8.2 loadContext:同步、先查缓存、失败阈值、LoadFunction

核心方法 loadContext(第 139-203 行):

@Override
public ApplicationContext loadContext(MergedContextConfiguration mergedConfig) {
	mergedConfig = replaceIfNecessary(mergedConfig);
	synchronized (this.contextCache) {
		try {
			ApplicationContext context = this.contextCache.get(mergedConfig);
			if (context != null) {
				if (logger.isTraceEnabled()) {
					logger.trace("Retrieved ApplicationContext [%s] from cache with key %s".formatted(
							System.identityHashCode(context), mergedConfig));
				}
				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;
					if (key instanceof AotMergedContextConfiguration aotMergedConfig) {
						newContext = loadContextInAotMode(aotMergedConfig);
					}
					else {
						newContext = loadContextInternal(key);
					}
					……
					return newContext;
				}
				catch (Exception ex) {
					……
					this.contextCache.incrementFailureCount(key);
					……
					throw new IllegalStateException("Failed to load ApplicationContext for " + key, cause);
				}
			});
		}
		finally {
			this.contextCache.logStatistics();
		}
	}
}

逐段解读:

  1. 整段包在 synchronized (this.contextCache) 里。缓存操作是全局串行化的。这是有意为之:上下文加载很重,串行化避免两个线程并发为同一个 key 加载出两份容器,也保证 LinkedHashMap 的 LRU 访问顺序与使用计数不被并发破坏。代价是上下文加载期间其他测试线程会等——但这正是"只加载一次"想要的效果。

  2. replaceIfNecessary(第 312-325 行):AOT 模式下(AotDetector.useGeneratedArtifacts() 为 true),把普通 MergedContextConfiguration 替换成 AotMergedContextConfiguration,走 AOT 运行时加载路径(用预生成的 ApplicationContextInitializer 而不是动态刷新)。普通 JVM 测试直接原样返回。

  3. 先 contextCache.get:命中就 trace 一下 identityHashCode 返回。trace 日志里打印 System.identityHashCode(context) 是排查"是否复用了同一个上下文"的利器——两个测试类打出相同的 identityHashCode,说明它们共享了容器。

  4. 失败阈值(failure threshold,6.1 引入):miss 后先查 getFailureCount。如果这个 key 之前已经失败过 failureThreshold 次(默认值由 ContextCacheUtils.retrieveContextFailureThreshold() 决定),直接抛 IllegalStateException 跳过重复加载。这避免了一个坏掉的上下文在庞大测试套件里被反复重试、拖慢整个构建——第一次失败后,后续所有引用同一配置的测试快速失败,而不是每次都等几十秒重新刷新容器。

  5. put(key, loadFunction):miss 时把"怎么加载"封装成 LoadFunction 传给缓存。注意 7.0 的 put 新签名接受 LoadFunction 而不是直接传现成 context——这是为了让缓存在执行"LRU 淘汰"之后再真正加载新上下文,保证淘汰与加载的原子性(在 synchronized 块内)。lambda 里区分 AOT 与普通路径调 loadContextInternal;加载异常时 incrementFailureCount 并交给 ApplicationContextFailureProcessor(通过 SpringFactoriesLoader 加载的 SPI,可在失败时做诊断、打印 bean 定义等)。

  6. finally { logStatistics() }:每次加载结束都在 debug/trace 级别打印缓存统计(size、hit/miss、usage count)。开 org.springframework.test.context.cache 这个 logger 到 debug,就能看到缓存命中情况。

20.8.3 DefaultContextCache:LinkedHashMap access-order 的 LRU

缓存实现 DefaultContextCache(spring-test/src/main/java/org/springframework/test/context/cache/DefaultContextCache.java)的核心数据结构(第 71-95 行):

private final Map<MergedContextConfiguration, ApplicationContext> contextMap =
		Collections.synchronizedMap(new LinkedHashMap<>(32, 0.75f, true));

private final Map<MergedContextConfiguration, Set<MergedContextConfiguration>> hierarchyMap =
		new ConcurrentHashMap<>(32);

private final Map<MergedContextConfiguration, Set<Class<?>>> contextUsageMap = new ConcurrentHashMap<>(32);

private final Set<MergedContextConfiguration> unusedContexts = new LinkedHashSet<>(4);

private final Map<MergedContextConfiguration, Integer> failureCounts = new ConcurrentHashMap<>(32);
  • contextMap:new LinkedHashMap<>(32, 0.75f, true)——第三个参数 accessOrder=true 是关键,它让 LinkedHashMap 按访问顺序排序,最近最少访问的排在队首。这就是 LRU 的实现。
  • hierarchyMap:父 key→子 key 集合的树,用于 @ContextHierarchy 层次上下文——删除父上下文时要递归删除整棵子树。
  • contextUsageMap:7.0 新增,key→正在使用它的测试类集合,是 pause 引用计数的基础。
  • unusedContexts:7.0.3 新增,记录"已无人使用、待切换时 pause"的 key。
  • failureCounts:6.1 新增,失败计数。

LRU 淘汰逻辑 evictLruContextIfNecessary(第 224-233 行):

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

当缓存大小达到 maxSize(默认 32,由 spring.test.context.cache.maxSize 配置,见 ContextCache 接口第 77、90 行),取 access-order LinkedHashMap 的队首(最久未用)key,remove 摘掉并关闭。这就是"上下文缓存未命中导致重复启动"问题的另一面:测试类太多、配置太分散,超过 32 个不同配置时,老上下文会被 LRU 挤掉,之后再用到就得重建。

20.8.4 key 的 equals/hashCode:为什么测试类不算 key

缓存能跨测试类复用的根本原因在 MergedContextConfiguration.equals/hashCode(spring-test/src/main/java/org/springframework/test/context/MergedContextConfiguration.java 第 486-550 行)。hashCode 逐行:

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

Javadoc 明确写:"Generate a unique hash code for all properties of this MergedContextConfiguration excluding the test class"。equals 也一样:比较 locations、classes、initializer 类、activeProfiles、propertySource、contextCustomizers、parent、以及 contextLoader 的类名——唯独不比较 testClass 本身。

这意味着:两个不同的测试类,只要它们的 locations/classes/profiles/initializers/customizers/parent/loader 完全一致,就会算出相同的 key,从而共享同一个 ApplicationContext。这是缓存复用的核心设计,也是"上下文缓存未命中"问题的根源——只要上述任何一项不同(比如一个测试类多写了一个 @TestPropertySource、换了一个 active profile、加了一个 @DirtiesContext 之外的 contextCustomizer),key 就变了,容器就得重启。contextLoader 比的是类名而非实例,所以两个类用同一种 loader 不影响 key。

20.8.5 7.0 的 pause:unused → 切换时暂停

7.0 引入的"使用计数+暂停"机制流程如下。DefaultTestContext.getApplicationContext() 每次取上下文都调 registerContextUsage(DefaultContextCache 第 250-257 行,沿父上下文递归登记);TestContextManager.afterTestClass() 调 markApplicationContextUnused → unregisterContextUsage(第 260-279 行):

@Override
public void unregisterContextUsage(MergedContextConfiguration mergedConfig, Class<?> testClass) {
	ApplicationContext context = this.contextMap.get(mergedConfig);
	Assert.state(context != null, "ApplicationContext must not be null for: " + mergedConfig);

	Set<Class<?>> activeTestClasses = getActiveTestClasses(mergedConfig);
	activeTestClasses.remove(testClass);
	if (activeTestClasses.isEmpty()) {
		switch (this.pauseMode) {
			case ALWAYS -> pauseIfNecessary(context);
			case ON_CONTEXT_SWITCH -> this.unusedContexts.add(mergedConfig);
		}
		this.contextUsageMap.remove(mergedConfig);
	}

	// Recurse up the context hierarchy last.
	MergedContextConfiguration parent = mergedConfig.getParent();
	if (parent != null) {
		unregisterContextUsage(parent, testClass);
	}
}

当某上下文的活跃测试类集合变空,按 pauseMode 处理:ALWAYS 立即 pause();ON_CONTEXT_SWITCH(默认)先把它放进 unusedContexts,等下一次取到不同上下文时(pauseOnContextSwitchIfNecessary,第 289-297 行)才统一 pause 这些闲置上下文。pause() 会让 ConfigurableApplicationContext 暂停其生命周期 bean(如关闭嵌入式数据库连接池、停止调度),但不关闭容器;下次 get 命中时 restartContextIfNecessary(第 183-194 行)沿父上下文递归 restart() 恢复。

这套机制的价值:测试套件从一个重型上下文(带着 H2、JPA)切到另一个时,把前一个 pause 掉释放连接/线程资源,切回来再 restart——比"关闭+重建整个容器"便宜得多。可通过 -Dspring.test.context.cache.pause=never 关闭(ContextCache 接口第 109 行)。

20.9 源码剖析(六):默认监听器与注解体系

20.9.1 DependencyInjectionTestExecutionListener:prepareTestInstance 注入

依赖注入监听器 DependencyInjectionTestExecutionListener(spring-test/src/main/java/org/springframework/test/context/support/DependencyInjectionTestExecutionListener.java)是最核心的默认监听器。它在 prepareTestInstance(第 100-113 行)里对测试实例做注入:

@Override
public void prepareTestInstance(TestContext testContext) throws Exception {
	if (logger.isTraceEnabled()) {
		logger.trace("Performing dependency injection for test context " + testContext);
	}
	else {
		logger.debug("Performing dependency injection for test class " + testContext.getTestClass().getName());
	}
	if (runningInAotMode(testContext.getTestClass())) {
		injectDependenciesInAotMode(testContext);
	}
	else {
		injectDependencies(testContext);
	}
}

injectDependencies 内部(Javadoc 见第 86-98 行)通过 AutowireCapableBeanFactory.autowireBeanProperties + initializeBean 把测试实例当作一个"游离 bean"来处理——测试类本身不在容器里,但它的 @Autowired 字段、@Value、@Resource 都会被容器填充。这就是为什么测试类字段能直接 @Autowired 进来一个 bean。

它还有一个精妙的设计:beforeTestMethod(第 122-138 行)会检查 REINJECT_DEPENDENCIES_ATTRIBUTE:

@Override
public void beforeTestMethod(TestContext testContext) throws Exception {
	if (Boolean.TRUE.equals(testContext.getAttribute(REINJECT_DEPENDENCIES_ATTRIBUTE))) {
		……
		injectDependencies(testContext);
	}
}

如果别的监听器(或测试代码)在 TestContext 上把这个属性设成 Boolean.TRUE,它就会在每个测试方法前重新注入一次。典型场景:@DirtiesContext 重建了上下文后,测试实例手里持有的旧 bean 引用已失效,需要重新注入。这就是前面讲的"属性是监听器间信号通道"的真实例子。

20.9.2 DirtiesContext 的双监听器:BEFORE 与 AFTER 分离

@DirtiesContext(spring-test/src/main/java/org/springframework/test/annotation/DirtiesContext.java)有 methodMode(默认 AFTER_METHOD)和 classMode(默认 AFTER_CLASS),以及 HierarchyMode(EXHAUSTIVE/CURRENT_LEVEL,控制层次上下文清理范围)。Javadoc(第 70-74 行)明确:BEFORE_* 模式由 DirtiesContextBeforeModesTestExecutionListener 处理,AFTER_* 模式由 DirtiesContextTestExecutionListener 处理。

为什么拆成两个监听器?因为顺序需求:BEFORE 模式需要在依赖注入之前就把脏上下文清掉(这样注入时拿到的是新容器),而 AFTER 模式要在所有测试收尾后才清。两个监听器在默认列表里分居 DependencyInjectionTestExecutionListener 的前后(见 20.4.4 的顺序),正好满足这个时序。它们最终都调 TestContext.markApplicationContextDirty(hierarchyMode) → DefaultCacheAwareContextLoaderDelegate.closeContext → ContextCache.remove,把上下文从 contextMap 摘掉并 close()。

HierarchyMode.CURRENT_LEVEL 只删当前这一层上下文;EXHAUSTIVE 沿 hierarchyMap 递归删整棵子树。这对应 evictLruContextIfNecessary 里 LRU 淘汰用的就是 CURRENT_LEVEL。

20.9.3 注解体系速览(7.0.9 实际)

  • @SpringJUnitConfig(spring-test/src/main/java/org/springframework/test/context/junit/jupiter/SpringJUnitConfig.java):组合注解,源码第 51-52 行就是 @ExtendWith(SpringExtension.class) @ContextConfiguration。它把"启用 SpringExtension"和"声明上下文配置"合并,value/classes/locations/initializers/inheritLocations 全是 @ContextConfiguration 同名属性的 @AliasFor。@SpringJUnitWebConfig 在 jupiter/web/ 下,额外通过 @WebAppConfiguration 提供 WebApplicationContext。
  • @ActiveProfiles:声明激活的 bean definition profiles。它的值在 prepareContext 阶段被写进 Environment(见 20.7.2),并且参与缓存 key——MergedContextConfiguration.equals 比较 activeProfiles(第 504 行),所以两个测试类即使 classes 相同、activeProfiles 不同,也是两个独立容器。
  • @TestPropertySource:指定属性文件位置与内联属性。同样在 prepareContext 时加为 PropertySource,且参与缓存 key(propertySourceDescriptors/propertySourceProperties)。属性优先级高于系统环境,低于测试类内部的其他 PropertySource。
  • @TestExecutionListeners(mergeMode=...):MERGE_WITH_DEFAULTS(默认)把用户监听器与默认监听器合并并按类型去重;REPLACE_DEFAULTS 完全替换默认列表。合并与去重发生在引导器,不在 TestContextManager(见 20.4.3)。
  • @Sql/@SqlGroup:由 SqlScriptsTestExecutionListener(order 在事务监听器之后)执行。它通过 ScriptUtils 解析 SQL 脚本、用 StatementCallback 执行。关键时序:因为它排在 TransactionalTestExecutionListener 之后,@Sql 脚本是在测试方法之前、事务已开启的状态下跑的,脚本里的数据变更与测试方法里的变更一起在事务回滚时撤销。@SqlGroup 是 @Sql 的 repeatable 容器。
  • @Timed/@Repeat:位于 annotation/ 包,是 JUnit 4 时代的老旧注解,7.0.9 仍在但新代码应改用 JUnit Jupiter 自带的超时与重复机制。

20.9.4 EventPublishingTestExecutionListener:把生命周期变成 Spring 事件

EventPublishingTestExecutionListener 是默认列表最后几个之一,它把每个生命周期回调转成对应的 Spring ApplicationEvent(如 BeforeTestExecutionEvent、AfterTestMethodEvent)发布到上下文。这样你可以在容器里写一个 @EventListener 来感知测试生命周期,而不必写一个完整的 TestExecutionListener。它排在最后(最外层),意味着它发布的"before"事件在所有真正工作完成之后才发出、"after"事件最先发出——依然遵循包裹语义。

20.10 源码剖析(七):事务测试——TransactionalTestExecutionListener 的包裹逻辑

事务测试是 TestContext 框架最常用、也最容易被误解的能力。核心问题:为什么测试方法里 @Transactional 后数据库改动会自动回滚?答案全在 TransactionalTestExecutionListener(spring-test/src/main/java/org/springframework/test/context/transaction/TransactionalTestExecutionListener.java)。

20.10.1 beforeTestMethod:解析事务属性、开事务

beforeTestMethod(第 208-248 行):

@Override
@SuppressWarnings("NullAway")
public void beforeTestMethod(final TestContext testContext) throws Exception {
	Method testMethod = testContext.getTestMethod();
	Class<?> testClass = testContext.getTestClass();
	Assert.notNull(testMethod, "Test method of supplied TestContext must not be null");

	TransactionContext txContext = TransactionContextHolder.removeCurrentTransactionContext();
	Assert.state(txContext == null, "Cannot start new transaction without ending existing transaction");

	PlatformTransactionManager tm = null;
	TransactionAttribute transactionAttribute = this.attributeSource.getTransactionAttribute(testMethod, testClass);

	if (transactionAttribute != null) {
		transactionAttribute = TestContextTransactionUtils.createDelegatingTransactionAttribute(testContext,
			transactionAttribute);

		if (transactionAttribute.getPropagationBehavior() == TransactionDefinition.PROPAGATION_NOT_SUPPORTED ||
				transactionAttribute.getPropagationBehavior() == TransactionDefinition.PROPAGATION_NEVER) {
			return;
		}

		tm = getTransactionManager(testContext, transactionAttribute.getQualifier());
		Assert.state(tm != null,
				() -> "Failed to retrieve PlatformTransactionManager for @Transactional test: " + testContext);
	}

	if (tm != null) {
		txContext = new TransactionContext(testContext, tm, transactionAttribute, isRollback(testContext));
		runBeforeTransactionMethods(testContext);
		txContext.startTransaction();
		TransactionContextHolder.setCurrentTransactionContext(txContext);
	}
}

逐段解读:

  1. 先清理上一个残留:TransactionContextHolder.removeCurrentTransactionContext()。TransactionContextHolder 是一个 ThreadLocal<TransactionContext>。如果不为 null 就直接报错"不能在未结束旧事务时开新事务"——这是防御性编程,正常情况下 afterTestMethod 已经清理干净了。

  2. attributeSource.getTransactionAttribute(testMethod, testClass):这个 attributeSource 是 AnnotationTransactionAttributeSource 的匿名子类(第 160-179 行),它重写了 findTransactionAttribute(Class),除了沿继承层次找 @Transactional,还会沿外层类层次找(findTransactionAttributeInEnclosingClassHierarchy)。这是为了支持 @Nested 嵌套测试类在外层类上标 @Transactional 的场景。注意构造时传的 false(new AnnotationTransactionAttributeSource(false)),注释说"不要求 @Transactional 测试方法是 public"——这与生产环境代理不同,测试里非 public 的 @Test 方法也能被识别。

  3. 传播行为特判:如果解析出 PROPAGATION_NOT_SUPPORTED 或 PROPAGATION_NEVER,直接 return——不开事务,方法非事务执行。

  4. 拿事务管理器:getTransactionManager(testContext, qualifier)。有 qualifier 时用 BeanFactoryAnnotationUtils.qualifiedBeanOfType 按限定符找;否则走 TestContextTransactionUtils.retrieveTransactionManager,它先找 @TransactionManagementConfigurer 指定的,再找容器里唯一的 PlatformTransactionManager bean,多个时报错。

  5. 构造 TransactionContext 并开事务:new TransactionContext(testContext, tm, transactionAttribute, isRollback(testContext)),然后 runBeforeTransactionMethods(执行 @BeforeTransaction 标注的方法),再 txContext.startTransaction(),最后把 txContext 塞进 ThreadLocal。

isRollback(testContext)(第 454-481 行)决定默认是否回滚:先 isDefaultRollback(第 425-441 行)——类上有 @Rollback 就用它的值,没有就默认 return true;再看方法级 @Rollback 是否覆盖。这就是"测试事务默认回滚"的源码出处:不是约定,是 isDefaultRollback 在没找到 @Rollback 时硬编码 true。@Commit 等价于 @Rollback(false)。

20.10.2 afterTestMethod:结束事务、跑 @AfterTransaction

afterTestMethod(第 258-276 行):

@Override
public void afterTestMethod(TestContext testContext) throws Exception {
	Method testMethod = testContext.getTestMethod();
	Assert.notNull(testMethod, "The test method of the supplied TestContext must not be null");

	TransactionContext txContext = TransactionContextHolder.removeCurrentTransactionContext();
	// If there was (or perhaps still is) a transaction...
	if (txContext != null) {
		TransactionStatus transactionStatus = txContext.getTransactionStatus();
		try {
			// If the transaction is still active...
			if (transactionStatus != null && !transactionStatus.isCompleted()) {
				txContext.endTransaction();
			}
		}
		finally {
			runAfterTransactionMethods(testContext);
		}
	}
}

逐段解读:

  1. 从 ThreadLocal 取出 txContext 并移除;为 null 说明根本没开事务,直接返回。
  2. endTransaction():内部根据"是否该回滚"决定 tm.rollback(status) 还是 tm.commit(status)。回滚判定综合了:构造时传入的 isRollback 默认值、测试方法里调用 TestTransaction.flagForRollback() 手动标记、以及测试异常(testContext.getTestException() 不为 null 时通常强制回滚)。
  3. finally { runAfterTransactionMethods(...) }:无论事务结束是否抛异常,@AfterTransaction 方法一定执行——这保证了清理逻辑不漏。runAfterTransactionMethods(第 320 行起)内部对每个 @AfterTransaction 方法独立 catch,首个异常 rethrow、其余记录,与 TestContextManager 的异常聚合风格一致。

20.10.3 TestTransaction 静态 API

TestTransaction(spring-test/src/main/java/org/springframework/test/context/transaction/TestTransaction.java)是给测试代码用的静态门面:TestTransaction.start()、TestTransaction.end()、TestTransaction.flagForRollback()、TestTransaction.flagForCommit()、TestTransaction.isActive()。它本质上是操作当前线程 TransactionContextHolder 里那个 TransactionContext。典型用法:在测试方法里先 TestTransaction.flagForCommit() 让本测试的数据真提交,再断点查库验证;或中途 TestTransaction.end() 提前结束事务验证提交后行为。它是测试代码与事务包裹之间的"手动挡"接口。

20.10.4 事务与连接池的关系(为什么回滚后连接能复用)

一个常被忽略的点:TransactionalTestExecutionListener 用的是标准 PlatformTransactionManager(如 DataSourceTransactionManager),它管理的连接绑定在 TransactionSynchronizationManager 的 ThreadLocal 上。回滚时连接被归还连接池但数据未提交——下一个测试方法开启新事务时拿到的是连接池里另一条(或重置后的)连接,H2/嵌入式库的状态不受上个回滚事务影响。这也是"测试间数据隔离"的实现机制。坑点见 20.14 节。

20.11 源码剖析(八):SpringExtension——JUnit 引擎与 TestContextManager 的桥

JUnit Jupiter 侧的全部集成浓缩在 SpringExtension(spring-test/src/main/java/org/springframework/test/context/junit/jupiter/SpringExtension.java)。它是一个普通的 JUnit 扩展,通过实现多个回调接口,把 JUnit 引擎的每个时间点转发给 TestContextManager。

20.11.1 它实现了哪些接口

类声明(第 114-116 行):

public class SpringExtension implements BeforeAllCallback, AfterAllCallback, TestInstancePostProcessor,
		BeforeEachCallback, AfterEachCallback, BeforeTestExecutionCallback, AfterTestExecutionCallback,
		ParameterResolver {

对照 TestContextManager 的七个回调,桥接关系是:

JUnit 接口方法转发到 TestContextManager
beforeAllbeforeTestClass()
postProcessTestInstanceprepareTestInstance(testInstance)
beforeEachbeforeTestMethod(instance, method)
beforeTestExecutionbeforeTestExecution(instance, method)
afterTestExecutionafterTestExecution(instance, method, exception)
afterEachafterTestMethod(instance, method, exception)
afterAllafterTestClass()

注意它没有实现 TestExecutionExceptionHandler——7.0.9 的 SpringExtension 不接管测试异常处理,异常原样由 JUnit 记录,只是通过 context.getExecutionException().orElse(null) 取出来传给 after 回调,供事务监听器判断是否因异常回滚。

20.11.2 TestContextManager 的存放:ExtensionContext.Store

getTestContextManager(第 462-467 行):

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

getStore 用的是 context.getRoot().getStore(TEST_CONTEXT_MANAGER_NAMESPACE)(第 469-471 行)——存在根 ExtensionContext 的一个自定义 Namespace 里,key 是测试类。computeIfAbsent 保证:一个测试类在整个 JUnit 运行期间只 new 一个 TestContextManager,后续所有回调都复用它。这与 20.4 节"一个测试类一个 manager"对应。afterAll 的 finally 里 getStore(context).remove(context.getRequiredTestClass())(第 225 行)做清理。

20.11.3 回调桥接的标准写法

以 beforeEach(第 314-320 行)为例:

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

每个回调都是这个模板:从 ExtensionContext 取出 testInstance/testMethod/exception,拿到 manager,registerMethodInvoker,转发。afterTestExecution/afterEach 里那行 Throwable testException = context.getExecutionException().orElse(null)(第 341、354 行)就是把测试方法抛出的异常取出来——事务监听器据此决定回滚。

registerMethodInvoker(第 480-482 行):

private static void registerMethodInvoker(TestContextManager testContextManager, ExtensionContext context) {
	testContextManager.getTestContext().setMethodInvoker(context.getExecutableInvoker()::invoke);
}

它把 JUnit 的 ExecutableInvoker(能正确处理参数解析、异常转换的方法调用器)注入 TestContext。这样 TransactionalTestExecutionListener.runBeforeTransactionMethods 执行 @BeforeTransaction 方法时(testContext.getMethodInvoker().invoke(...)),用的是 JUnit 的调用器而不是裸反射——保证一致性。用完 TestContextManager 的 finally 会 resetMethodInvoker 还原。

20.11.4 postProcessTestInstance:DI 时机与两项校验

postProcessTestInstance(第 235-243 行)在测试实例刚被 JUnit 构造完后立刻执行:

@Override
public void postProcessTestInstance(Object testInstance, ExtensionContext context) throws Exception {
	context = findProperlyScopedExtensionContext(testInstance.getClass(), context);

	validateAutowiredConfig(context);
	validateRecordApplicationEventsConfig(context);
	TestContextManager testContextManager = getTestContextManager(context);
	registerMethodInvoker(testContextManager, context);
	testContextManager.prepareTestInstance(testInstance);
}
  • validateAutowiredConfig(第 250-269 行):检查测试方法/生命周期方法上是否误用了 @Autowired,若有直接抛 IllegalStateException——因为方法参数才该用 @Autowired,方法本身不该标。校验结果缓存在 Store 里,避免重复扫描。
  • validateRecordApplicationEventsConfig(第 278-308 行):@RecordApplicationEvents 与 PER_CLASS + 并行执行组合会导致事件收集不确定,这里直接拦截。
  • 最后 prepareTestInstance(testInstance) 触发 DependencyInjectionTestExecutionListener 注入字段。

20.11.5 ParameterResolver:构造器与方法参数注入

supportsParameter(第 391-401 行)与 resolveParameter(第 420-433 行)让 SpringExtension 能像 JUnit 参数解析器一样工作:构造器/方法参数是 ApplicationContext、ApplicationEvents,或经 ParameterResolutionDelegate.isAutowirable 判断可注入时,就从容器 getAutowireCapableBeanFactory() 解析依赖。注意 supportsParameter 刻意不调用 getApplicationContext(Javadoc 第 446-448 行明确警告),避免过早加载容器;只有真正 resolveParameter 时才加载。

20.11.6 7.0 的 test-method 作用域与 JUnit 6 要求

两个 7.0 重要差异:

  1. 类 Javadoc 第 101 行:"This class requires JUnit Jupiter 6.0 or higher。" 7.0.9 的 SpringExtension 基于 JUnit Jupiter 6.x API(如 TestInstantiationAwareExtension),在 JUnit 5.x 环境下无法运行。
  2. 默认 test-method 作用域:getTestInstantiationExtensionContextScope(第 197-202 行)返回 ExtensionContextScope.TEST_METHOD。这影响 @Nested 嵌套测试类层次里 ExtensionContext 的作用域,使得每个测试方法都能拿到属于自己那一层嵌套类的上下文与注入。可通过 @SpringExtensionConfig(useTestClassScopedExtensionContext=true) 或 -Dspring.test.extension.context.scope=test_class 切回旧的 test-class 作用域(第 524-576 行的解析逻辑)。这是与 6.x 行为的一个显著差异。

20.11.7 JUnit 4 的遗留支持

仓库 context/junit4/ 下仍有 SpringJUnit4ClassRunner 与 SpringRunner(后者是前者的别名)。它通过 Runner 机制在 runBefores/runAfters 等节点手动调 TestContextManager 的对应方法。但如前所述,JUnit 4 的 SpringMethodRule 不支持 beforeTestExecution/afterTestExecution。新项目一律用 JUnit Jupiter + @ExtendWith(SpringExtension.class)(或 @SpringJUnitConfig)。

20.12 源码剖析(九):MockMvc——无容器的 Spring MVC 请求分发

MockMvc 是独立于 TestContext 框架的另一套设施,位于 spring-test/src/main/java/org/springframework/test/web/servlet/。它的目标:在没有 Servlet 容器的情况下,完整地跑一遍 Spring MVC 的 DispatcherServlet 分发流程,并对结果做断言。

20.12.1 两种构建方式:webAppContextSetup 与 standaloneSetup

入口 MockMvcBuilders(spring-test/src/main/java/org/springframework/test/web/servlet/setup/MockMvcBuilders.java)只有三个静态方法:

public static DefaultMockMvcBuilder webAppContextSetup(WebApplicationContext context) {
	return new DefaultMockMvcBuilder(context);
}

public static StandaloneMockMvcBuilder standaloneSetup(Object... controllers) {
	return new StandaloneMockMvcBuilder(controllers);
}

public static RouterFunctionMockMvcBuilder routerFunctions(RouterFunction<?>... routerFunctions) {
	return new RouterFunctionMockMvcBuilder(routerFunctions);
}
  • webAppContextSetup(WebApplicationContext):传入一个已刷新的 WebApplicationContext(通常来自 TestContext 框架,即 @SpringJUnitWebConfig 加载出来的那个)。DefaultMockMvcBuilder 会用这个上下文里全部的 MVC 基础设施(@Controller、HandlerMapping、Converter、ViewResolver、拦截器等)构建 DispatcherServlet。这是"全栈集成测试":测的是真实配置起来的整个 MVC 栈。
  • standaloneSetup(Object... controllers):只传你想测的 controller 实例。StandaloneMockMvcBuilder 会程序化地装配最小 MVC 基础设施(默认 RequestMappingHandlerMapping、HandlerAdapter、消息转换器等),等价于手写一个精简版 MVC JavaConfig。这是"聚焦单元测试":一次只测一个 controller,不加载整个应用上下文,启动极快。

二者产物都是 MockMvc,区别在背后上下文的完整度。

20.12.2 perform:一次请求的完整生命周期

MockMvc 是 final 类,构造器包级私有(MockMvc.java 第 94-102 行),持有 TestDispatcherServlet servlet、Filter[] filters、ServletContext servletContext。核心方法 perform(第 165-225 行):

public ResultActions perform(RequestBuilder requestBuilder) throws Exception {
	if (this.defaultRequestBuilder != null && requestBuilder instanceof Mergeable mergeable) {
		requestBuilder = (RequestBuilder) mergeable.merge(this.defaultRequestBuilder);
	}

	MockHttpServletRequest request = requestBuilder.buildRequest(this.servletContext);

	AsyncContext asyncContext = request.getAsyncContext();
	MockHttpServletResponse mockResponse;
	HttpServletResponse servletResponse;
	if (asyncContext != null) {
		servletResponse = (HttpServletResponse) asyncContext.getResponse();
		mockResponse = unwrapResponseIfNecessary(servletResponse);
	}
	else {
		mockResponse = new MockHttpServletResponse();
		servletResponse = mockResponse;
	}

	if (this.defaultResponseCharacterEncoding != null) {
		mockResponse.setDefaultCharacterEncoding(this.defaultResponseCharacterEncoding.name());
	}

	if (requestBuilder instanceof SmartRequestBuilder smartRequestBuilder) {
		request = smartRequestBuilder.postProcessRequest(request);
	}

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

	if (DispatcherType.ASYNC.equals(request.getDispatcherType()) &&
			asyncContext != null && !request.isAsyncStarted()) {
		asyncContext.complete();
	}

	applyDefaultResultActions(mvcResult);
	RequestContextHolder.setRequestAttributes(previousAttributes);

	return new ResultActions() {
		@Override
		public ResultActions andExpect(ResultMatcher matcher) throws Exception {
			matcher.match(mvcResult);
			return this;
		}
		@Override
		public ResultActions andDo(ResultHandler handler) throws Exception {
			handler.handle(mvcResult);
			return this;
		}
		@Override
		public MvcResult andReturn() {
			return mvcResult;
		}
	};
}

逐段解读这个"无容器分发"的关键步骤:

  1. 合并默认请求:若构建时配了 defaultRequest,且当前 builder 实现 Mergeable,就合并——全局默认的 header、参数会叠加到每个请求。
  2. buildRequest(servletContext):MockHttpServletRequestBuilder(request/ 包下,即 MockMvcRequestBuilders.get/post 返回的东西)根据 HTTP 方法、URI、参数、header、body 构造一个 MockHttpServletRequest——它是 org.springframework.mock.web 包下对 HttpServletRequest 的内存实现。
  3. 创建 MockHttpServletResponse:一个 HttpServletResponse 的内存实现,记录状态码、header、body。
  4. SmartRequestBuilder.postProcessRequest:给 builder 一个最后加工请求的机会(如补充分片)。
  5. DefaultMvcResult 与 MVC_RESULT_ATTRIBUTE:把 request/response 包成 MvcResult,并塞进 request 的一个属性里(key 是第 73 行 MVC_RESULT_ATTRIBUTE)。这样 DispatcherServlet 分发过程中产生的 ModelAndView、异常、处理器信息,都能通过这个挂在 request 上的 MvcResult 事后被断言拿到——这是 MockMvc 能"事后断言"的关键。
  6. 绑定 RequestContextHolder:把当前请求/响应设进 ServletRequestAttributes,因为 DispatcherServlet 及其处理链里的代码(如 RequestContextHolder.getRequestAttributes())依赖这个 ThreadLocal;perform 结束后恢复成 previousAttributes,避免污染线程池。
  7. new MockFilterChain(this.servlet, this.filters) 并 doFilter:这是真正的分发。MockFilterChain 先依次跑你注册的 Filter,最后把请求交给 TestDispatcherServlet(一个为测试定制的 DispatcherServlet 子类,位于同包 TestDispatcherServlet.java)的 service→doService。于是 HandlerMapping 找处理器、HandlerAdapter 执行 controller、ViewResolver 解析视图、@ExceptionHandler 处理异常——整个 MVC 流程在内存里完整跑一遍,只是没有真实 socket。
  8. 异步处理:若是 ASYNC 分发且异步已结束,调 asyncContext.complete()。
  9. applyDefaultResultActions:跑构建时配的 alwaysDo/alwaysExpect(全局处理器/断言)。
  10. 返回匿名 ResultActions:andExpect(matcher) 对 MvcResult 做断言、andDo(handler) 做额外处理(如 print() 打印)、andReturn() 拿回原始 MvcResult。三者都返回 this 以链式调用。

20.12.3 RequestBuilders 与 ResultMatchers

  • MockMvcRequestBuilders(request/MockMvcRequestBuilders.java):DSL 工厂,get("/url")、post("/url")、put、delete、multipart、asyncDispatch 等,返回 MockHttpServletRequestBuilder,再链式 .param().header().contentType().content()。
  • ResultMatchers(result/MockMvcResultMatchers.java):断言工厂,status().isOk()、content().contentType(...)、jsonPath("$.user.name").value("...")、model().attributeExists(...)、view().name(...)、forwardedUrl(...)、header().string(...)、cookie().value(...) 等。其中 jsonPath 断言由 JsonPathResultMatchers 委托 JsonPathExpectationsHelper(util/JsonPathExpectationsHelper.java)用 JsonPath 表达式从响应 body 取值比对。
  • MockMvcResultHandlers.print():把请求/响应详情打到控制台,是调试断言失败的第一手段。

20.12.4 MockRestServiceServer:反向的 mock

MockRestServiceServer(web/client/MockRestServiceServer.java)测的是客户端侧:你用 RestTemplate/RestClient 调外部 HTTP 服务时,它拦在请求出口,按 RequestExpectationManager(SimpleRequestExpectationManager/UnorderedRequestExpectationManager)匹配预期请求、返回预置响应。它与 MockMvc 方向相反——MockMvc 模拟服务端接收请求,MockRestServiceServer 模拟客户端要调用的服务端返回响应。二者常配合:webAppContextSetup 测自己的 controller,MockRestServiceServer 挡掉 controller 里 RestTemplate 对外部服务的真实调用。

20.13 源码剖析(十):测试工具类与一次完整执行的时序

20.13.1 ReflectionTestUtils:反射的安全封装

ReflectionTestUtils(spring-test/src/main/java/org/springframework/test/util/ReflectionTestUtils.java)是最常用的反射工具。它的 setField 重载很多,核心签名如第 91 行 public static void setField(Object targetObject, String name, @Nullable Object value)、第 123 行 setField(Class<?> targetClass, String name, ...)(设静态字段)。它内部用 spring-core 的 ReflectionUtils.findField 沿继承层次找字段、makeAccessible 暴力访问私有字段,绕开 Java 访问控制。典型用途:给被测对象注入一个 @Value 私有限制、或在不启动容器时手动塞进 mock 依赖。对应的 getField、invokeMethod(调私有方法)同理。它是 standalone 单元测试里手工装配依赖的主力。

20.13.2 AopTestUtils:拿到代理背后的真实对象

AopTestUtils(同包)解决一个 AOP 测试痛点:bean 被 @Transactional/@Cacheable 等织入成代理后,测试里 getBean 拿到的是代理,直接断言会失败。它提供 getTargetObject(Object) 沿 Advised 链路剥开一层或多层代理,拿到最底层真实对象;getUltimateTargetObject 剥到底。这在验证"事务内到底改了什么真实状态"时非常有用。

20.13.3 其他工具

  • ModelAndViewAssert:对 ModelAndView 做断言(assertViewName、assertModelAttributeValue 等),主要供老式 ModelAndView 测试用。
  • TestPropertyValues:以链式方式构建属性 Map(TestPropertyValues.of("a=b").and("c=d")),常用于在 standalone 或编程式构造 Environment 时注入属性。
  • JsonPathExpectationsHelper/XpathExpectationsHelper:分别供 MockMvc 的 jsonPath/xpath 断言使用,封装了表达式取值与比对。

20.13.4 一次 JUnit 5 测试方法的完整时序

把前面所有源码串起来,一个标注 @SpringJUnitWebConfig(AppConfig.class) 的测试方法 testX() 的完整时序如下:

JUnit 引擎加载测试类
  → SpringExtension.getTestContextManager()
      → Store.computeIfAbsent → new TestContextManager(TestContextBootstrapper)
          → buildTestContext():解析 @ContextConfiguration → MergedContextConfiguration
          → getTestExecutionListeners():加载 12 个默认监听器并按 order 排序
  → beforeAll: SpringExtension.beforeAll
      → manager.beforeTestClass()
          → 正序遍历监听器 beforeTestClass(DirtiesBeforeModes 等)
  → JUnit new 测试实例
  → SpringExtension.postProcessTestInstance
      → manager.prepareTestInstance(instance)
          → DependencyInjectionTestExecutionListener.prepareTestInstance
              → DefaultTestContext.getApplicationContext()
                  → CacheAwareContextLoaderDelegate.loadContext(mergedConfig)
                      → synchronized(contextCache) → contextMap.get(key) miss
                      → DelegatingSmartContextLoader → AnnotationConfigContextLoader
                          → 创建 GenericApplicationContext → prepareContext(profiles/propertySource/initializers)
                          → refresh() → 容器就绪 → contextMap.put(key, ctx)
                      → registerContextUsage(key, testClass)
                  → autowireBeanProperties(instance) 注入 @Autowired 字段
  → SpringExtension.beforeEach
      → manager.beforeTestMethod(instance, method)
          → TransactionalTestExecutionListener.beforeTestMethod
              → 解析 @Transactional → getTransactionManager → startTransaction()
              → (此时 JUnit 的 @BeforeEach 还没跑,事务先开)
  → JUnit 跑 @BeforeEach 方法(在事务内)
  → SpringExtension.beforeTestExecution
      → manager.beforeTestExecution(instance, method)(紧贴测试方法前)
  → JUnit 执行 testX()
  → SpringExtension.afterTestExecution
      → manager.afterTestExecution(instance, method, exception) 逆序遍历
  → JUnit 跑 @AfterEach
  → SpringExtension.afterEach
      → manager.afterTestMethod(instance, method, exception)
          → TransactionalTestExecutionListener.afterTestMethod
              → endTransaction():按 isRollback 默认 true → rollback(数据不落库)
              → runAfterTransactionMethods
  → (所有方法跑完) SpringExtension.afterAll
      → manager.afterTestClass() 逆序遍历
          → markApplicationContextUnused() → contextUsageMap 减计数
              → 若无人用,按 pauseMode 把上下文 pause()(7.0)
      → ThreadLocal.remove(),Store.remove(testClass)

这个时序里最值得记住的三点:

  1. 上下文是懒加载的:直到 prepareTestInstance 第一次要 DI 时才触发 getApplicationContext(),进而 miss→加载→入缓存。如果你的测试类全程不用上下文(纯 POJO 测试),容器根本不会启动。
  2. 事务包住 @BeforeEach 与测试方法:beforeTestMethod(开事务)在 JUnit @BeforeEach 之前,afterTestMethod(回滚)在 @AfterEach 之后,所以准备数据与断言数据都在同一回滚事务里。
  3. 缓存复用发生在 key 维度:下一个测试类 testY() 若 MergedContextConfiguration 与 testX() 相同,contextMap.get(key) 直接命中,整个"创建→prepareContext→refresh"被跳过——这就是测试套件跑得快的原因。

@DirtiesContext 的失效时序则是:在对应回调(BEFORE_* 或 AFTER_*)里调 markApplicationContextDirty → closeContext → contextMap.remove(key, hierarchyMode) 并 close() 该上下文;下一个相同 key 的测试类来时 miss,重新加载。这就是"缓存命中→脏→淘汰→重建"的闭环。

20.17 源码剖析(十一):监听器的解析、合并、去重与排序

20.4.3 节说"合并与去重发生在引导器",这里给出真实源码。AbstractTestContextBootstrapper.getTestExecutionListeners()(spring-test/src/main/java/org/springframework/test/context/support/AbstractTestContextBootstrapper.java 第 122-197 行)是完整逻辑:

@Override
public final List<TestExecutionListener> getTestExecutionListeners() {
	Class<?> clazz = getBootstrapContext().getTestClass();
	Class<TestExecutionListeners> annotationType = TestExecutionListeners.class;
	List<TestExecutionListener> listeners = new ArrayList<>(8);
	boolean usingDefaults = false;

	AnnotationDescriptor<TestExecutionListeners> descriptor =
			TestContextAnnotationUtils.findAnnotationDescriptor(clazz, annotationType);

	// Use defaults?
	if (descriptor == null) {
		usingDefaults = true;
		listeners.addAll(getDefaultTestExecutionListeners());
	}
	else {
		while (descriptor != null) {
			Class<?> declaringClass = descriptor.getDeclaringClass();
			TestExecutionListeners testExecutionListeners = descriptor.getAnnotation();
			boolean inheritListeners = testExecutionListeners.inheritListeners();
			AnnotationDescriptor<TestExecutionListeners> parentDescriptor = descriptor.next();

			if ((!inheritListeners || parentDescriptor == null) &&
					testExecutionListeners.mergeMode() == MergeMode.MERGE_WITH_DEFAULTS) {
				usingDefaults = true;
				listeners.addAll(getDefaultTestExecutionListeners());
			}

			if (testExecutionListeners.listeners().length > 0) {
				listeners.addAll(0, instantiateListeners(testExecutionListeners.listeners()));
			}

			descriptor = (inheritListeners ? parentDescriptor : null);
		}
	}

	if (usingDefaults) {
		List<TestExecutionListener uniqueListeners = new ArrayList<>(listeners.size());
		listeners.forEach(listener -> {
			Class<? extends TestExecutionListener> listenerClass = listener.getClass();
			if (uniqueListeners.stream().map(Object::getClass).noneMatch(listenerClass::equals)) {
				uniqueListeners.add(listener);
			}
		});
		listeners = uniqueListeners;

		AnnotationAwareOrderComparator.sort(listeners);
	}
	return listeners;
}

逐段解读,这回答了"@TestExecutionListeners(mergeMode=...) 到底怎么合并":

  1. 无注解 = 全默认:测试类上没有 @TestExecutionListeners,直接 usingDefaults=true,把 getDefaultTestExecutionListeners()(通过 SpringFactoriesLoader 从 META-INF/spring.factories 加载的那 12 个)全部加入。
  2. 有注解 = 沿类层次向上遍历:while (descriptor != null) 从当前类一直走到父类。每一层读它的 inheritListeners(是否继承父层监听器)与 mergeMode。
  3. MERGE_WITH_DEFAULTS 的触发条件:(!inheritListeners || parentDescriptor == null) && mergeMode == MERGE_WITH_DEFAULTS。即在"最底层"(没有父描述符可继承,或本层声明了不继承)时,若 merge 模式是合并,才把默认监听器加进来。这就是为什么默认 MERGE_WITH_DEFAULTS 下你自定义的监听器和默认监听器都在;而 REPLACE_DEFAULTS 不满足这个条件,默认监听器一行都不加。
  4. 用户监听器插到最前面:listeners.addAll(0, instantiateListeners(...))——注意是 add 到 index 0。用户显式声明的监听器排在列表最前。但这只是临时顺序,最后会被排序。
  5. 去重:usingDefaults 为真时(即加载了默认监听器),按监听器类去重——如果用户重复声明了同一个默认监听器类型,只留一个。去重是按 getClass() 比较,不是按实例。
  6. 排序:AnnotationAwareOrderComparator.sort(listeners)——按 Ordered/@Order 排序。这一步解释了为什么用户自定义监听器即便被 addAll(0) 插到最前,最终位置仍由它的 getOrder() 决定。默认监听器各自实现了固定 order 值(如事务监听器 ORDER=4000),所以无论用户怎么插,相对顺序都稳定。

这套逻辑的意义:TestContextManager.registerTestExecutionListeners 收到的已经是"合并默认+去重+排序"后的最终有序列表,它只管 add,不管顺序——顺序责任完全在引导器。

20.18 源码剖析(十二):层次上下文与缓存的递归删除

@ContextHierarchy 允许多个上下文形成父子关系(例如根上下文放 service/dao,子上下文放 web 层)。缓存用 hierarchyMap(DefaultContextCache 第 80-81 行)维护"父 key → 子 key 集合"的树,在 putInternal(第 235-247 行)里沿 parent 链登记:

private void putInternal(MergedContextConfiguration key, ApplicationContext context) {
	this.contextMap.put(key, context);

	MergedContextConfiguration child = key;
	MergedContextConfiguration parent = child.getParent();
	while (parent != null) {
		Set<MergedContextConfiguration> set = this.hierarchyMap.computeIfAbsent(parent, k -> new HashSet<>());
		set.add(child);
		child = parent;
		parent = child.getParent();
	}
}

删除时 remove(key, hierarchyMode)(第 318-345 行)根据 HierarchyMode 决定从哪一层开始清:

MergedContextConfiguration startKey = key;
if (hierarchyMode == HierarchyMode.EXHAUSTIVE) {
	MergedContextConfiguration parent = startKey.getParent();
	while (parent != null) {
		startKey = parent;
		parent = startKey.getParent();
	}
}

List<MergedContextConfiguration> removedContexts = new ArrayList<>();
remove(removedContexts, startKey);
  • CURRENT_LEVEL:startKey 就是 key 本身,只清这一层及其子树。
  • EXHAUSTIVE:沿 parent 链一直上溯到根,从根开始递归删整棵层次树。

私有 remove(list, key)(第 347-371 行)是后序递归:

private void remove(List<MergedContextConfiguration> removedContexts, MergedContextConfiguration key) {
	Set<MergedContextConfiguration> children = this.hierarchyMap.get(key);
	if (children != null) {
		for (MergedContextConfiguration child : children) {
			remove(removedContexts, child);
		}
		this.hierarchyMap.remove(key);
	}

	ApplicationContext context = this.contextMap.remove(key);
	this.contextUsageMap.remove(key);
	if (pauseOnContextSwitch()) {
		this.unusedContexts.remove(key);
	}
	if (context instanceof ConfigurableApplicationContext cac) {
		cac.close();
	}
	removedContexts.add(key);
}

注意"后序"顺序:先递归删子节点,再 contextMap.remove(key) 并 cac.close() 自己。Javadoc 注释强调"Physically remove and close leaf nodes first(先关叶子,再关父)"——这符合上下文关闭的依赖顺序:子上下文通常依赖父上下文的 bean,先关子再关父才不会出现"父关了、子还在引用父"的悬空状态。@DirtiesContext(hierarchyMode=EXHAUSTIVE) 就是利用这条路一次性作废整条上下文链。

20.19 源码剖析(十三):Web 环境监听器、@Sql 执行器与 MockMvc 构建链

20.19.1 ServletTestExecutionListener:给测试套上 Web 请求上下文

ServletTestExecutionListener(spring-test/src/main/java/org/springframework/test/context/web/ServletTestExecutionListener.java)是默认监听器列表里排第一的,它的职责是:在 @WebAppConfiguration 测试里,于测试方法执行前往 RequestContextHolder 里塞一个 mock 的请求/响应,执行后再清掉。源码关键点(第 138、151 行):它实现 prepareTestInstance 与 beforeTestMethod,在 beforeTestMethod 里创建 MockHttpServletRequest/MockHttpServletResponse/MockHttpSession 并 RequestContextHolder.setRequestAttributes(...);在 afterTestMethod 里 RequestContextHolder.resetRequestAttributes()。

它还用两个属性做幂等控制:RESET_REQUEST_CONTEXT_HOLDER_ATTRIBUTE(第 81 行)标记是否需要重置、POPULATED_REQUEST_CONTEXT_HOLDER_ATTRIBUTE(第 90 行)标记当前 RequestAttributes 是否是本监听器塞的。为什么要这些标记?因为 MockMvc.perform 自己也会临时设置 RequestContextHolder(见 20.12.2 第 195-196 行),两个机制都碰同一个 ThreadLocal,必须靠属性判断"现在这个 RequestAttributes 是谁建的、结束时该不该清",避免互相误清。这是 TestContext 框架里"属性通道"跨监听器协作的又一例证。

20.19.2 SqlScriptsTestExecutionListener:@Sql 的执行

SqlScriptsTestExecutionListener(spring-test/src/main/java/org/springframework/test/context/jdbc/SqlScriptsTestExecutionListener.java)只实现两个回调:beforeTestMethod(第 175 行)与 afterTestMethod(第 184 行),分别在测试方法前、后跑对应阶段的 @Sql 脚本。它内部解析 @Sql(脚本路径、语句分隔符、编码、错误处理模式 ErrorMode),最终委托 org.springframework.jdbc.datasource.init.ScriptUtils(spring-jdbc 包)执行——用 DataSource 拿连接、按 StatementCallback 逐条跑 SQL。

两个与顺序相关的事实:

  1. 它的 order 在 TransactionalTestExecutionListener 之后(见 20.9.3),所以 beforeTestMethod 里跑脚本时,事务已经在 20.10.1 开好了——脚本的 INSERT/DDL 与测试方法在同一事务,默认一起回滚。
  2. @SqlMergeMode(类级/方法级,第 251、258 行)决定方法级 @Sql 是替换还是合并类级 @Sql。这解释了"为什么我在类上配了建表脚本、方法上又配了数据脚本,两个都跑了"——默认合并模式。

20.19.3 DefaultMockMvcBuilder:webAppContextSetup 背后做了什么

webAppContextSetup(wac) 返回的 DefaultMockMvcBuilder(setup/DefaultMockMvcBuilder.java)在 build() 时做这些事:拿传入的 WebApplicationContext,创建一个 TestDispatcherServlet(同包 TestDispatcherServlet.java,它重写了 doService,把 handler 映射、异常、ModelAndView 等结果记录下来,供事后断言),注册用户通过 .addFilters(...)、.addInterceptors(...)、.setCustomViewResolvers(...) 等配置的组件,最后 new 出 MockMvc(servlet, filters)。

StandaloneMockMvcBuilder 则不同:它不消费外部 WebApplicationContext,而是新建一个 StubWebApplicationContext(setup/StubWebApplicationContext.java),把传入的 controller 实例注册进去,再程序化装配 RequestMappingHandlerMapping、RequestHandlerAdapter、默认 FormattingConversionService、Validator、必要的 ViewResolver——也就是"手写一个最小可用的 MVC 环境"。这就是 standalone 启动快的原因:它绕过了整个 Spring 容器刷新,只建 MVC 必需的那几个基础设施 bean。

理解这个差异对排查很关键:standalone 下你在应用 @Configuration 里注册的全局 HandlerInterceptor、MessageConverter、ControllerAdvice 都不存在——它们只在 webAppContextSetup 的真实上下文里。如果你在 standalone 测试里发现某个全局异常处理器没生效,不是 bug,是 standalone 本就不加载它。

20.19.4 contextCustomizers:@MockBean 等如何影响缓存 key

20.8.4 提到 MergedContextConfiguration.equals 比较 contextCustomizers。这是 4.3 以后引入的扩展点:实现 ContextCustomizerFactory 的扩展可以为上下文定制(典型如 Spring Boot 的 @MockBean),每个 customizer 的 equals/hashCode 参与缓存 key。含义是:两个测试类即便 classes 完全相同,只要其中一个多了一个 @MockBean(产生了一个 contextCustomizer),key 就不同——这是有意的,因为 mock 替换了 bean 定义,必须是不同容器。本仓库(纯 Framework)提供 DynamicPropertiesContextCustomizer(support 包)作为范例,用于把 @DynamicPropertySource 的动态属性纳入定制与缓存 key。

20.20 运行机制补充:并行、AOT 与缓存统计

20.20.1 并行测试的线程模型回顾

把 20.4.2、20.8.2、20.14.5 串起来,并行测试的线程模型是:

  • TestContextManager 单例(按测试类存在 JUnit Store),其 testContextHolder 是 ThreadLocal——每个测试线程拿到一份 DefaultTestContext 副本(共享不可变的 mergedConfig/delegate,拷贝 attributes,易变字段独立)。
  • 所有 loadContext 调用都 synchronized(this.contextCache),全局串行加载,保证同一 key 只建一个容器。
  • 容器本身只读共享,安全;易变状态隔离在 ThreadLocal 副本里。

这也是为什么自定义 TestContext 必须实现拷贝构造器(20.4.2)——否则并行时多个线程会共用同一个易变 testInstance,串状态。

20.20.2 AOT 模式的分支

20.8.1 的 replaceIfNecessary(DefaultCacheAwareContextLoaderDelegate 第 312-325 行)在 AotDetector.useGeneratedArtifacts() 为 true 时,把 MergedContextConfiguration 替换成 AotMergedContextConfiguration。此时 loadContext 走 loadContextInAotMode(第 259-287 行):不再动态刷新容器,而是用 AOT 处理期预生成的 ApplicationContextInitializer(经 AotTestContextInitializers 查找)去初始化一个 GenericApplicationContext。这是 GraalVM native image 下测试的运行路径——测试同样能跑,但上下文是编译期生成的固定结构。

20.20.3 如何观察缓存命中

开 logger org.springframework.test.context.cache(ContextCache.CONTEXT_CACHE_LOGGING_CATEGORY)到 DEBUG,DefaultContextCache.logStatistics() 会在每次 loadContext 结束(DefaultCacheAwareContextLoaderDelegate.loadContext 的 finally)打印:缓存 size、contextUsageCount、parentContextCount、hitCount、missCount。测试套件变慢时,先看 missCount 是否异常高——若远高于不同配置类的数量,说明 key 在意外漂移(多了 contextCustomizer、profile 不一致等),回到 20.8.4 的 equals 字段逐项排查。

20.21 源码剖析(十四):AbstractGenericContextLoader 的加载模板

把 20.7 的加载链落到最具体的模板方法上,是 AbstractGenericContextLoader.loadContext(MergedContextConfiguration)(spring-test/src/main/java/org/springframework/test/context/support/AbstractGenericContextLoader.java 第 110-112 行先委托给私有的 loadContext(mergedConfig, false))。这个 final 模板方法固定了以下步骤(按其 Javadoc 与方法结构):

  1. prepareContext(context, mergedConfig):即 20.7.2 所述——设 active profiles、加 test property sources、调用 ApplicationContextInitializer。
  2. loadBeanDefinitions(context):抽象钩子,子类实现。AnnotationConfigContextLoader 在其中把 mergedConfig.getClasses() 注册为注解 bean 定义;GenericXmlContextLoader 用 XmlBeanDefinitionReader 读 locations。
  3. customizeContext(context, mergedConfig):应用 ContextCustomizer(见 20.19.4)与后置定制。
  4. context.refresh():标准 Spring 容器刷新。
  5. 非 AOT 时 context.registerShutdownHook():注册 JVM 关闭钩子,让测试容器在 JVM 退出时正常销毁。

AOT 分支(loadContextForAotProcessing,第 162-166 行;loadContextForAotRuntime,第 182 行起)与运行时分支共享大部分逻辑,但 AOT 构建期不 refresh()、不注册 shutdown hook——因为它只是为了在构建期生成 bean 定义的反射提示,不需要真正启动容器。这解释了 20.20.2 里运行时为何走 loadContextInAotMode:构建期产出 ApplicationContextInitializer,运行期用它重建容器。

理解这个模板的意义:你自定义 ContextLoader 时,通常不需要重写整个加载流程,只要继承 AbstractGenericContextLoader 并实现 loadBeanDefinitions(甚至直接复用 AnnotationConfigContextLoader),prepareContext/refresh/shutdown hook 这些"测试特有的样板"就自动具备了。

20.22 补充:断言工具与 mock 包的定位

20.22.1 JsonPathExpectationsHelper

JsonPathExpectationsHelper(spring-test/src/main/java/org/springframework/test/util/JsonPathExpectationsHelper.java)是 MockMvcResultMatchers.jsonPath(...) 的底层。它持有一个 JsonPath 表达式,在 assertValue 时从响应 body(转成字符串后用 JsonPath 库)取值,再与期望值用 EqualsResult/Matcher 比对。它还提供 doesNotExist、value(Matcher)、isArray 等断言。断言失败时抛出带"期望路径 X 的值为 Y,实际为 Z"信息的 AssertionError——这就是 20.15 坑 5 里建议先 print() 看 body 的原因:helper 的错误信息只能告诉你路径取到了什么,看不到完整 JSON 时排查很慢。

20.22.2 mock 包:被 MockMvc 依赖的内存 Servlet

org.springframework.mock.web 包(spring-test/src/main/java/org/springframework/mock/web/)是一组完整但极简的 Servlet API 实现:MockHttpServletRequest 用内部 Map 存 parameters/attributes/headers,MockHttpServletResponse 用 ByteArrayOutputStream 缓存 body 并记录 status/headers,MockHttpSession/MockServletContext 同理。它们不做任何网络、不做真正的 session 持久化,只保证"接口契约正确 + 状态可查询"。

这解释了 MockMvc 与真实容器的本质差异(20.15 坑 4):MockMvc 测的是"DispatcherServlet 的分发逻辑在一个行为正确的 mock 请求/响应上跑通",而不是"在真实 Tomcat 的 filter 链、session 复制、异步超时下跑通"。前者快而准,后者只能靠容器集成测试。二者互补,不是替代关系。

20.22.3 AopTestUtils 的两个层次

AopTestUtils.getTargetObject 剥一层代理,getUltimateTargetObject 沿 Advised 链剥到底。在事务测试里,从容器拿到的 bean 几乎都是代理(@Transactional),如果你在测试里直接断言 bean instanceof RealClass 会失败;用 AopTestUtils.getUltimateTargetObject(bean, RealClass.class) 拿到真实对象再断言,或验证其私有状态。这与 ReflectionTestUtils 配合:先剥代理,再反射设私有字段,是 standalone/半集成测试里操作被测对象内部状态的标准组合拳。


至此,本章从模块结构、TestContextManager 生命周期、TestContext 状态、加载链、缓存与 key、默认监听器族、注解体系、事务包裹、JUnit 桥接、MockMvc 无容器分发,一路讲到并行/AOT/缓存统计与高频坑。所有结论均以 spring-framework-7.0.9 实际源码为据;7.0.9 相对旧版的关键差异(SpringExtension 需 JUnit 6、test-method 作用域默认、ContextCache 的 pause 与使用计数、failure threshold、监听器默认清单扩到 12 个)已在对应源码处逐一标注。

20.23 补充:TestTransaction 的静态门面与其余默认监听器

20.23.1 TestTransaction:手动挡的精确语义

TestTransaction(spring-test/src/main/java/org/springframework/test/context/transaction/TestTransaction.java)的静态方法签名已核实:isActive()(第 54 行)、flagForRollback()(第 92 行)、flagForCommit()(第 108 行)、start()(第 121 行)、end()(第 134 行)。它们都通过 TransactionContextHolder.currentTransactionContext() 拿到当前线程那个 TransactionContext 再操作。几个精确语义值得点出:

  • flagForRollback()/flagForCommit() 只设置标记,不立即结束事务。它影响的是 afterTestMethod 时 TransactionContext.endTransaction() 的提交/回滚选择。也就是说你在测试方法中途调 TestTransaction.flagForCommit(),事务仍继续,直到方法跑完由监听器统一提交。
  • start() 在当前无事务时手动开一个;end() 在事务仍活跃时手动结束。这用于"测试方法里想分段控制事务边界"的场景——例如先 start() 插入数据、end() 提交,再另起事务验证。
  • isActive() 返回当前线程是否有进行中的事务。它是只读探针,不触发任何容器操作。

这套 API 与默认回滚(isDefaultRollback 返回 true)并不冲突:默认回滚只是 TransactionContext 构造时的初始 flag,flagForCommit() 随时能覆盖它。

20.23.2 CommonCachesTestExecutionListener

CommonCachesTestExecutionListener(support/CommonCachesTestExecutionListener.java)排在事务监听器之前。它的职责是在测试方法之间清理"框架级公共缓存",典型如 SpEL 表达式解析缓存、Jackson 序列化缓存——这些缓存在并行或参数化测试里若跨方法残留,可能导致一个方法注册的类型影响另一个方法。它在 beforeTestMethod/afterTestMethod 做清理,保证每个测试方法面对干净的缓存状态。它存在的意义是隔离性:业务上下文被事务回滚了,但框架自己的静态缓存不在事务里,必须单独清。

20.23.3 EventPublishingTestExecutionListener 的事件清单

EventPublishingTestExecutionListener(event/ 包)把生命周期转成 org.springframework.test.context.event 下的事件:BeforeTestClassEvent、PrepareTestInstanceEvent、BeforeTestMethodEvent、BeforeTestExecutionEvent、AfterTestExecutionEvent、AfterTestMethodEvent、AfterTestClassEvent。在容器里写一个 @EventListener(或 @TransactionalEventListener)就能感知这些事件,而不必实现完整的 TestExecutionListener。它是默认列表最后几个之一,因此发布"before"事件时所有真正的前置工作已完成、发布"after"事件最早——与包裹语义一致。配合 @RecordApplicationEvents,你还能把这些事件收集到 ApplicationEvents 里在测试中断言"到底发布了哪些事件"。

20.23.4 选型小结

把默认监听器按职能归个类,便于记忆与自定义时选位置:

  • 环境准备:ServletTestExecutionListener(Web 请求上下文)、DirtiesContextBeforeModesTestExecutionListener。
  • 依赖注入与 bean 覆盖:ApplicationEventsTestExecutionListener、BeanOverrideTestExecutionListener、DependencyInjectionTestExecutionListener、MockitoResetTestExecutionListener。
  • 观测与缓存:MicrometerObservationRegistryTestExecutionListener、CommonCachesTestExecutionListener。
  • 上下文失效:DirtiesContextTestExecutionListener。
  • 数据与事务:TransactionalTestExecutionListener、SqlScriptsTestExecutionListener。
  • 事件:EventPublishingTestExecutionListener。

自定义监听器要插入这条链时,先想清楚它应在"事务内还是事务外""DI 前还是 DI 后",再用 Ordered/@Order 把自己放到对应区间——顺序错了,包裹语义就错了。

20.14 扩展用法与最佳实践

20.14.1 自定义 TestExecutionListener

自定义监听器只需继承 AbstractTestExecutionListener(空实现基类)或直接实现 TestExecutionListener,覆盖关心的回调,再通过 @TestExecutionListeners(MyListener.class) 注册。两个要点:

  • 实现 Ordered 或标 @Order:默认列表里监听器顺序是按 order 数值排的(见 20.4.4)。你的监听器若要在事务内做事(比如准备测试数据),order 要小于 TransactionalTestExecutionListener.ORDER(4000) 对应的值区间;若要在事务外、回滚后做事,order 要更大。
  • 用属性通道传信号:需要在监听器间传递状态时,用 testContext.setAttribute(...)/getAttribute(...),而不是静态 Map——TestContext 的属性在并行测试时按线程副本隔离(见 20.6.3)。

20.14.2 @TestPropertySource 与 profile 的组合

@ActiveProfiles 与 @TestPropertySource 都参与缓存 key(见 20.8.4)。最佳实践:把"共享的、稳定的"配置放在公共抽象测试基类上(inheritLocations=true 默认继承),让尽量多的测试类算出同一个 key;只在确有差异的测试类上局部覆盖 profile 或属性,避免 key 碎片化导致缓存反复 miss。反过来,若两个测试类必须隔离(比如一个要 H2、一个要真实 PostgreSQL),就显式给不同配置,让它们自然成为两个 key——这比加 @DirtiesContext 干净。

20.14.3 MockMvc 全栈 vs 独立测试的选择

  • 测 controller 的业务逻辑细节(参数校验、序列化、状态码分支):用 standaloneSetup,启动快、聚焦,能精确注入 mock 依赖。
  • 测 MVC 配置本身(拦截器链、全局异常处理、消息转换器注册、静态资源):用 webAppContextSetup,因为这些只有在真实装配的上下文里才存在。
  • 二者不要混用断言风格:standalone 下没有真实 WebApplicationContext,某些基于 @WebAppConfiguration 的断言(如真实 JSP 视图解析)会失败。

20.14.4 事务测试与 @DirtiesContext 的配合

@DirtiesContext 重建上下文后,测试实例持有的旧 bean 引用失效。此时 DependencyInjectionTestExecutionListener 会通过 REINJECT_DEPENDENCIES_ATTRIBUTE 在下次 beforeTestMethod 重新注入——这是自动的。但如果你在字段里缓存了从旧上下文取来的对象(比如 private JdbcTemplate jt; 在 @BeforeAll 里取的),重建后它已指向关闭的上下文,必须重新获取。经验法则:上下文可能被脏掉的测试,依赖一律在测试方法内或 @BeforeEach 现取,不要缓存到字段。

20.14.5 并行测试与上下文线程安全

7.0.9 支持并行测试(JUnit Jupiter @Execution(CONCURRENT)),其基础是 TestContextManager 的 ThreadLocal 副本(20.4.2)+ 全局 ContextCache 的 synchronized(20.8.2)。要点:

  • ApplicationContext 本身是线程安全的只读容器(bean 单例在刷新后不应再变),所以多线程共享同一个上下文是安全的;但不要在测试里修改单例 bean 的可变状态——那会污染并行的其他测试。
  • 并行时 @RecordApplicationEvents 与 PER_CLASS 生命周期冲突,SpringExtension.validateRecordApplicationEventsConfig 会直接报错拦截(见 20.11.4)。
  • 上下文加载是全局串行的(synchronized(contextCache)),所以并行加速主要体现在"已缓存上下文的测试方法"上;大量首次加载不同上下文的并行测试,收益有限。

20.15 常见问题与坑(源码级解释)

坑 1:上下文缓存未命中,每个测试类都重启容器。 根因在 MergedContextConfiguration.hashCode/equals(20.8.4):locations、classes、initializers、activeProfiles、propertySource、contextCustomizers、parent、loader 类名,任何一项不同都算不同 key。常见触发:某个测试类多写了一行 @TestPropertySource(properties="...")、换了 @ActiveProfiles、加了一个 @MockBean(会产生 contextCustomizer)。排查:开 org.springframework.test.context.cache 到 debug 看 hit/miss;对比两个测试类的 mergedConfig toString(DefaultTestContext.toString 会打印)。另外 maxSize 默认 32,测试类配置超过 32 种时 LRU 会挤掉老上下文(20.8.3),可通过 spring.test.context.cache.maxSize 调大。

坑 2:@DirtiesContext 滥用。 每加一个 @DirtiesContext,后续相同 key 的测试都得重建一次容器,代价几十秒。它只在"真的改坏了单例 bean/嵌入式库状态、且无法用事务回滚覆盖"时才该用。多数"测试间数据污染"问题应优先用事务回滚(默认行为)解决,而不是脏上下文。注意 BEFORE_* 与 AFTER_* 分属两个监听器(20.9.2),顺序错了会导致注入了旧上下文。

坑 3:事务测试中连接池与回滚的坑。 TransactionalTestExecutionListener 默认回滚(isDefaultRollback 硬编码 true,20.10.1)。常见误解:以为回滚了数据库里就查不到数据——这是对的,但如果测试里显式 TestTransaction.flagForCommit() 或 @Commit,数据会真提交;而连接池在并行/复用下,下一个测试若拿到同一物理连接且事务隔离级别设置不当,可能看到未提交脏读。另一个坑:PROPAGATION_NOT_SUPPORTED/NEVER 会让监听器直接跳过开事务(20.10.1 第 232-235 行),此时你的"回滚"预期完全不生效。

坑 4:MockMvc 与真实 Servlet 环境的差异。 MockMvc 用的是 MockHttpServletRequest/Response 与 MockFilterChain,没有真实 socket、没有真实容器的 filter 链顺序、ServletContext 是 mock 的。因此:真实容器里的 Filter(如 Spring Security 的 FilterChainProxy)必须通过 .addFilters(...) 显式注册进 MockMvc,否则它不会跑;依赖真实 HttpSession 持久化、真实异步 dispatch 容器行为的场景,MockMvc 测不到,需要集成测试。

坑 5:JsonPath 断言失败排查。 jsonPath("$.x").value(...) 失败时,先用 andDo(MockMvcResultHandlers.print()) 打出真实响应 body,确认 JSON 结构与路径表达式。JsonPathExpectationsHelper 对类型敏感——$.count 若返回数字,用 .value(3) 而非 .value("3");空数组/缺字段要先 .doesNotExist() 或 value(IsEmptyCollection)。

坑 6:@Sql 脚本的路径与编码。 SqlScriptsTestExecutionListener 排在事务监听器之后,脚本在已开启的事务里执行。路径默认相对 classpath;编码默认取平台编码,含中文 DDL/DML 时要显式 @Sql(scripts=..., encoding="UTF-8"),否则乱码导致脚本执行失败。脚本失败会抛异常并标记失败计数(20.8.2),同一 key 反复失败会触发 failure threshold 快速失败。

坑 7:7.0 下 @Autowired 标在测试方法上。 SpringExtension.validateAutowiredConfig(20.11.4)会直接抛 IllegalStateException。方法级依赖必须标在参数上(由 ParameterResolver 解析),@Autowired 只用于字段/构造器。这是 7.0 强化的校验。

20.16 本章小结

本章从 TestContextManager 的构造与七个生命周期回调出发,逐层拆开了:TestContext/DefaultTestContext 的延迟加载与属性机制;DelegatingSmartContextLoader→AbstractContextLoader 的上下文构建链;DefaultCacheAwareContextLoaderDelegate+DefaultContextCache 基于 MergedContextConfiguration key 的全局缓存、LRU 淘汰与 7.0 的 pause;默认监听器族(DI、事务、SQL、DirtiesContext、事件发布等)的顺序与包裹语义;TransactionalTestExecutionListener 开启/回滚事务的完整逻辑;SpringExtension 作为 JUnit Jupiter 扩展与 TestContextManager 的桥接;以及独立于 TestContext 框架的 MockMvc 无容器请求分发与 MockRestServiceServer。

核心结论三条:第一,一切事件经 TestContextManager 按"before 正序即停、after 逆序全执行+异常 suppressed"的策略分发给监听器,监听器顺序决定包裹语义;第二,上下文复用的本质是 MergedContextConfiguration.hashCode 排除 testClass,使配置相同的测试类共享全局静态 ContextCache 里的同一个容器,@DirtiesContext 与 LRU 是这条缓存的两个出口;第三,测试事务默认回滚是 isDefaultRollback 硬编码 true,且事务包裹住 @BeforeEach 与测试方法。

本章关键类清单

类名模块与相对路径职责一句话
TestContextManagerspring-test/src/main/java/org/springframework/test/context/TestContextManager.java框架主入口:管理 TestContext,按七回调遍历监听器并传播异常
TestContext / DefaultTestContextspring-test/.../context/TestContext.java;.../context/support/DefaultTestContext.java封装测试状态;延迟加载上下文、存监听器属性、支持并行拷贝
TestExecutionListenerspring-test/.../context/TestExecutionListener.java测试事件监听器 SPI,七个空默认回调
DefaultTestContextBootstrapperspring-test/.../context/support/DefaultTestContextBootstrapper.java默认引导器,指定 DelegatingSmartContextLoader 为默认加载器
DelegatingSmartContextLoaderspring-test/.../context/support/DelegatingSmartContextLoader.java默认加载器,在 XML/Groovy 与注解配置加载器间分派
AnnotationConfigContextLoaderspring-test/.../context/support/AnnotationConfigContextLoader.java加载 @ContextConfiguration(classes) 的 Java 配置容器
GenericXmlContextLoaderspring-test/.../context/support/GenericXmlContextLoader.java加载 XML 资源位置的容器
AbstractContextLoaderspring-test/.../context/support/AbstractContextLoader.java加载器模板基类:prepareContext 设 profile/属性源/初始化器
MergedContextConfigurationspring-test/.../context/MergedContextConfiguration.java合并后的不可变上下文配置,既是加载输入也是缓存 key(hashCode 排除 testClass)
DefaultCacheAwareContextLoaderDelegatespring-test/.../context/cache/DefaultCacheAwareContextLoaderDelegate.javaloadContext 总控:同步、查缓存、失败阈值、委托加载器
DefaultContextCachespring-test/.../context/cache/DefaultContextCache.javaLinkedHashMap access-order 的 LRU 缓存、7.0 pause 与使用计数
DependencyInjectionTestExecutionListenerspring-test/.../context/support/DependencyInjectionTestExecutionListener.javaprepareTestInstance 时对测试实例做依赖注入
TransactionalTestExecutionListenerspring-test/.../context/transaction/TransactionalTestExecutionListener.javabeforeTestMethod 开事务、afterTestMethod 按默认 true 回滚
SqlScriptsTestExecutionListenerspring-test/.../context/jdbc/SqlScriptsTestExecutionListener.java在事务内执行 @Sql 脚本
DirtiesContextTestExecutionListener / DirtiesContextBeforeModesTestExecutionListenerspring-test/.../context/support/分别处理 @DirtiesContext 的 AFTER/BEFORE 模式并清缓存
SpringExtensionspring-test/.../context/junit/jupiter/SpringExtension.javaJUnit Jupiter 扩展,把引擎回调桥接到 TestContextManager(需 JUnit 6)
SpringJUnitConfig / SpringJUnitWebConfigspring-test/.../context/junit/jupiter/SpringJUnitConfig.java组合注解:@ExtendWith(SpringExtension)+@ContextConfiguration
MockMvcspring-test/.../web/servlet/MockMvc.java无容器 MockMvc 主入口,perform 驱动 MockFilterChain→TestDispatcherServlet
MockMvcBuildersspring-test/.../web/servlet/setup/MockMvcBuilders.javawebAppContextSetup/standaloneSetup/routerFunctions 构建入口
MockRestServiceServerspring-test/.../web/client/MockRestServiceServer.javamock RestTemplate/RestClient 的服务端响应
ReflectionTestUtils / AopTestUtilsspring-test/src/main/java/org/springframework/test/util/反射设字段/调方法;剥 AOP 代理取真实对象