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());
}
逐段解读:
-
单参数构造器只做一件事:把测试类交给
BootstrapUtils.resolveTestContextBootstrapper(testClass),解析出一个TestContextBootstrapper(测试上下文引导器)。引导器的选择规则在其 Javadoc 中写明:若测试类上直接或作为元注解存在@BootstrapWith,就用它指定的引导器类型;否则用默认的DefaultTestContextBootstrapper。这是一个典型的"可替换引导策略"设计——Spring Boot 的@SpringBootTest正是通过@BootstrapWith(SpringBootTestContextBootstrapper.class)把引导器换成自己的,从而接管整个上下文构建流程。本仓库(纯 Framework)没有 Spring Boot,默认路径就是DefaultTestContextBootstrapper。 -
双参数构造器是真正干活的地方,它做三件事:
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 行,按注册顺序为:
ServletTestExecutionListener(初始化 Servlet mock 环境)DirtiesContextBeforeModesTestExecutionListener(处理@DirtiesContext的 BEFORE_* 模式)ApplicationEventsTestExecutionListener(支持ApplicationEvents注入)BeanOverrideTestExecutionListener(@MockitoBean等 bean 覆盖)DependencyInjectionTestExecutionListener(对测试实例做依赖注入)MicrometerObservationRegistryTestExecutionListener(Observation 支持)DirtiesContextTestExecutionListener(处理@DirtiesContext的 AFTER_* 模式)CommonCachesTestExecutionListener(清理 SpEL/Jackson 等公共缓存)TransactionalTestExecutionListener(事务包裹)SqlScriptsTestExecutionListener(执行@Sql脚本)EventPublishingTestExecutionListener(把测试事件发布成 SpringApplicationEvent)MockitoResetTestExecutionListener(重置@MockitoBeanmock)
这份清单与很多老资料(只有 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();
}
}
逐段解读三个关键设计:
-
逆序遍历:
getReversedTestExecutionListeners()(第 190-194 行)把列表拷贝一份再Collections.reverse。为什么要逆序?这就是"洋葱式包裹":前置按 L1→L2→L3 顺序调用,后置就必须按 L3→L2→L1 逆序调用,才能形成对称的包裹结构。举例:TransactionalTestExecutionListener(order 4000,较靠后)在 before 时开事务,SqlScriptsTestExecutionListener(更靠后)在事务里跑脚本;那么 after 时就必须先让 SqlScripts 收尾,再让 Transactional 回滚——逆序正好保证这一点。 -
异常不中断后续监听器:每个监听器的异常被 catch 后只记录、不 rethrow,循环继续。为什么?测试方法已经跑完了,收尾阶段即使某个监听器出错,也不能让后面的监听器失去收尾机会(比如事务还没回滚,不能因为别的监听器报错就悬着)。
-
首个异常 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;
}
逐段解读:
- 真正的加载被委托给
cacheAwareContextLoaderDelegate.loadContext(mergedConfig)。DefaultTestContext自己完全不知道怎么造容器,它只拿"配方"(mergedConfig)去找代理。代理背后接的是全局ContextCache——这就是延迟加载+缓存复用的全部秘密:第一次调用 miss,代理去加载并放入缓存;后续调用(包括其他测试类)命中缓存,直接返回同一个实例。 - 拿到后断言
isActive():如果上下文已经不是 active 状态(被关了),就抛出带详细原因的IllegalStateException。错误信息列了两种典型原因:用户代码自己关了上下文;并行测试下因@DirtiesContext语义或缓存 maxSize 自动淘汰被关。这条错误信息是排查"上下文莫名关闭"的第一手线索。 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 定义被读取之前对新建的上下文做三件事:
- 把
MergedContextConfiguration里的activeProfiles设置到上下文的Environment; - 把
@TestPropertySource指定的属性文件位置与内联属性作为PropertySource加到Environment; - 实例化并按
@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();
}
}
}
逐段解读:
-
整段包在
synchronized (this.contextCache)里。缓存操作是全局串行化的。这是有意为之:上下文加载很重,串行化避免两个线程并发为同一个 key 加载出两份容器,也保证LinkedHashMap的 LRU 访问顺序与使用计数不被并发破坏。代价是上下文加载期间其他测试线程会等——但这正是"只加载一次"想要的效果。 -
replaceIfNecessary(第 312-325 行):AOT 模式下(AotDetector.useGeneratedArtifacts()为 true),把普通MergedContextConfiguration替换成AotMergedContextConfiguration,走 AOT 运行时加载路径(用预生成的ApplicationContextInitializer而不是动态刷新)。普通 JVM 测试直接原样返回。 -
先
contextCache.get:命中就 trace 一下 identityHashCode 返回。trace 日志里打印System.identityHashCode(context)是排查"是否复用了同一个上下文"的利器——两个测试类打出相同的 identityHashCode,说明它们共享了容器。 -
失败阈值(failure threshold,6.1 引入):miss 后先查
getFailureCount。如果这个 key 之前已经失败过failureThreshold次(默认值由ContextCacheUtils.retrieveContextFailureThreshold()决定),直接抛IllegalStateException跳过重复加载。这避免了一个坏掉的上下文在庞大测试套件里被反复重试、拖慢整个构建——第一次失败后,后续所有引用同一配置的测试快速失败,而不是每次都等几十秒重新刷新容器。 -
put(key, loadFunction):miss 时把"怎么加载"封装成LoadFunction传给缓存。注意 7.0 的put新签名接受LoadFunction而不是直接传现成 context——这是为了让缓存在执行"LRU 淘汰"之后再真正加载新上下文,保证淘汰与加载的原子性(在synchronized块内)。lambda 里区分 AOT 与普通路径调loadContextInternal;加载异常时incrementFailureCount并交给ApplicationContextFailureProcessor(通过SpringFactoriesLoader加载的 SPI,可在失败时做诊断、打印 bean 定义等)。 -
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);
}
}
逐段解读:
-
先清理上一个残留:
TransactionContextHolder.removeCurrentTransactionContext()。TransactionContextHolder是一个ThreadLocal<TransactionContext>。如果不为 null 就直接报错"不能在未结束旧事务时开新事务"——这是防御性编程,正常情况下 afterTestMethod 已经清理干净了。 -
attributeSource.getTransactionAttribute(testMethod, testClass):这个attributeSource是AnnotationTransactionAttributeSource的匿名子类(第 160-179 行),它重写了findTransactionAttribute(Class),除了沿继承层次找@Transactional,还会沿外层类层次找(findTransactionAttributeInEnclosingClassHierarchy)。这是为了支持@Nested嵌套测试类在外层类上标@Transactional的场景。注意构造时传的false(new AnnotationTransactionAttributeSource(false)),注释说"不要求 @Transactional 测试方法是 public"——这与生产环境代理不同,测试里非 public 的@Test方法也能被识别。 -
传播行为特判:如果解析出
PROPAGATION_NOT_SUPPORTED或PROPAGATION_NEVER,直接return——不开事务,方法非事务执行。 -
拿事务管理器:
getTransactionManager(testContext, qualifier)。有 qualifier 时用BeanFactoryAnnotationUtils.qualifiedBeanOfType按限定符找;否则走TestContextTransactionUtils.retrieveTransactionManager,它先找@TransactionManagementConfigurer指定的,再找容器里唯一的PlatformTransactionManagerbean,多个时报错。 -
构造 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);
}
}
}
逐段解读:
- 从 ThreadLocal 取出 txContext 并移除;为 null 说明根本没开事务,直接返回。
endTransaction():内部根据"是否该回滚"决定tm.rollback(status)还是tm.commit(status)。回滚判定综合了:构造时传入的isRollback默认值、测试方法里调用TestTransaction.flagForRollback()手动标记、以及测试异常(testContext.getTestException()不为 null 时通常强制回滚)。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 |
|---|---|
beforeAll | beforeTestClass() |
postProcessTestInstance | prepareTestInstance(testInstance) |
beforeEach | beforeTestMethod(instance, method) |
beforeTestExecution | beforeTestExecution(instance, method) |
afterTestExecution | afterTestExecution(instance, method, exception) |
afterEach | afterTestMethod(instance, method, exception) |
afterAll | afterTestClass() |
注意它没有实现 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 重要差异:
- 类 Javadoc 第 101 行:"This class requires JUnit Jupiter 6.0 or higher。" 7.0.9 的 SpringExtension 基于 JUnit Jupiter 6.x API(如
TestInstantiationAwareExtension),在 JUnit 5.x 环境下无法运行。 - 默认 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;
}
};
}
逐段解读这个"无容器分发"的关键步骤:
- 合并默认请求:若构建时配了
defaultRequest,且当前 builder 实现Mergeable,就合并——全局默认的 header、参数会叠加到每个请求。 buildRequest(servletContext):MockHttpServletRequestBuilder(request/包下,即MockMvcRequestBuilders.get/post返回的东西)根据 HTTP 方法、URI、参数、header、body 构造一个MockHttpServletRequest——它是org.springframework.mock.web包下对HttpServletRequest的内存实现。- 创建
MockHttpServletResponse:一个HttpServletResponse的内存实现,记录状态码、header、body。 SmartRequestBuilder.postProcessRequest:给 builder 一个最后加工请求的机会(如补充分片)。DefaultMvcResult与MVC_RESULT_ATTRIBUTE:把 request/response 包成MvcResult,并塞进 request 的一个属性里(key 是第 73 行MVC_RESULT_ATTRIBUTE)。这样DispatcherServlet分发过程中产生的ModelAndView、异常、处理器信息,都能通过这个挂在 request 上的MvcResult事后被断言拿到——这是 MockMvc 能"事后断言"的关键。- 绑定
RequestContextHolder:把当前请求/响应设进ServletRequestAttributes,因为DispatcherServlet及其处理链里的代码(如RequestContextHolder.getRequestAttributes())依赖这个 ThreadLocal;perform 结束后恢复成previousAttributes,避免污染线程池。 new MockFilterChain(this.servlet, this.filters)并doFilter:这是真正的分发。MockFilterChain先依次跑你注册的Filter,最后把请求交给TestDispatcherServlet(一个为测试定制的DispatcherServlet子类,位于同包TestDispatcherServlet.java)的service→doService。于是HandlerMapping找处理器、HandlerAdapter执行 controller、ViewResolver解析视图、@ExceptionHandler处理异常——整个 MVC 流程在内存里完整跑一遍,只是没有真实 socket。- 异步处理:若是 ASYNC 分发且异步已结束,调
asyncContext.complete()。 applyDefaultResultActions:跑构建时配的alwaysDo/alwaysExpect(全局处理器/断言)。- 返回匿名
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)
这个时序里最值得记住的三点:
- 上下文是懒加载的:直到
prepareTestInstance第一次要 DI 时才触发getApplicationContext(),进而 miss→加载→入缓存。如果你的测试类全程不用上下文(纯 POJO 测试),容器根本不会启动。 - 事务包住 @BeforeEach 与测试方法:
beforeTestMethod(开事务)在 JUnit@BeforeEach之前,afterTestMethod(回滚)在@AfterEach之后,所以准备数据与断言数据都在同一回滚事务里。 - 缓存复用发生在 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=...) 到底怎么合并":
- 无注解 = 全默认:测试类上没有
@TestExecutionListeners,直接usingDefaults=true,把getDefaultTestExecutionListeners()(通过SpringFactoriesLoader从META-INF/spring.factories加载的那 12 个)全部加入。 - 有注解 = 沿类层次向上遍历:
while (descriptor != null)从当前类一直走到父类。每一层读它的inheritListeners(是否继承父层监听器)与mergeMode。 - MERGE_WITH_DEFAULTS 的触发条件:
(!inheritListeners || parentDescriptor == null) && mergeMode == MERGE_WITH_DEFAULTS。即在"最底层"(没有父描述符可继承,或本层声明了不继承)时,若 merge 模式是合并,才把默认监听器加进来。这就是为什么默认MERGE_WITH_DEFAULTS下你自定义的监听器和默认监听器都在;而REPLACE_DEFAULTS不满足这个条件,默认监听器一行都不加。 - 用户监听器插到最前面:
listeners.addAll(0, instantiateListeners(...))——注意是 add 到 index 0。用户显式声明的监听器排在列表最前。但这只是临时顺序,最后会被排序。 - 去重:
usingDefaults为真时(即加载了默认监听器),按监听器类去重——如果用户重复声明了同一个默认监听器类型,只留一个。去重是按getClass()比较,不是按实例。 - 排序:
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。
两个与顺序相关的事实:
- 它的 order 在
TransactionalTestExecutionListener之后(见 20.9.3),所以beforeTestMethod里跑脚本时,事务已经在 20.10.1 开好了——脚本的 INSERT/DDL 与测试方法在同一事务,默认一起回滚。 @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 与方法结构):
prepareContext(context, mergedConfig):即 20.7.2 所述——设 active profiles、加 test property sources、调用ApplicationContextInitializer。loadBeanDefinitions(context):抽象钩子,子类实现。AnnotationConfigContextLoader在其中把mergedConfig.getClasses()注册为注解 bean 定义;GenericXmlContextLoader用XmlBeanDefinitionReader读 locations。customizeContext(context, mergedConfig):应用ContextCustomizer(见 20.19.4)与后置定制。context.refresh():标准 Spring 容器刷新。- 非 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 与测试方法。
本章关键类清单
| 类名 | 模块与相对路径 | 职责一句话 |
|---|---|---|
TestContextManager | spring-test/src/main/java/org/springframework/test/context/TestContextManager.java | 框架主入口:管理 TestContext,按七回调遍历监听器并传播异常 |
TestContext / DefaultTestContext | spring-test/.../context/TestContext.java;.../context/support/DefaultTestContext.java | 封装测试状态;延迟加载上下文、存监听器属性、支持并行拷贝 |
TestExecutionListener | spring-test/.../context/TestExecutionListener.java | 测试事件监听器 SPI,七个空默认回调 |
DefaultTestContextBootstrapper | spring-test/.../context/support/DefaultTestContextBootstrapper.java | 默认引导器,指定 DelegatingSmartContextLoader 为默认加载器 |
DelegatingSmartContextLoader | spring-test/.../context/support/DelegatingSmartContextLoader.java | 默认加载器,在 XML/Groovy 与注解配置加载器间分派 |
AnnotationConfigContextLoader | spring-test/.../context/support/AnnotationConfigContextLoader.java | 加载 @ContextConfiguration(classes) 的 Java 配置容器 |
GenericXmlContextLoader | spring-test/.../context/support/GenericXmlContextLoader.java | 加载 XML 资源位置的容器 |
AbstractContextLoader | spring-test/.../context/support/AbstractContextLoader.java | 加载器模板基类:prepareContext 设 profile/属性源/初始化器 |
MergedContextConfiguration | spring-test/.../context/MergedContextConfiguration.java | 合并后的不可变上下文配置,既是加载输入也是缓存 key(hashCode 排除 testClass) |
DefaultCacheAwareContextLoaderDelegate | spring-test/.../context/cache/DefaultCacheAwareContextLoaderDelegate.java | loadContext 总控:同步、查缓存、失败阈值、委托加载器 |
DefaultContextCache | spring-test/.../context/cache/DefaultContextCache.java | LinkedHashMap access-order 的 LRU 缓存、7.0 pause 与使用计数 |
DependencyInjectionTestExecutionListener | spring-test/.../context/support/DependencyInjectionTestExecutionListener.java | prepareTestInstance 时对测试实例做依赖注入 |
TransactionalTestExecutionListener | spring-test/.../context/transaction/TransactionalTestExecutionListener.java | beforeTestMethod 开事务、afterTestMethod 按默认 true 回滚 |
SqlScriptsTestExecutionListener | spring-test/.../context/jdbc/SqlScriptsTestExecutionListener.java | 在事务内执行 @Sql 脚本 |
DirtiesContextTestExecutionListener / DirtiesContextBeforeModesTestExecutionListener | spring-test/.../context/support/ | 分别处理 @DirtiesContext 的 AFTER/BEFORE 模式并清缓存 |
SpringExtension | spring-test/.../context/junit/jupiter/SpringExtension.java | JUnit Jupiter 扩展,把引擎回调桥接到 TestContextManager(需 JUnit 6) |
SpringJUnitConfig / SpringJUnitWebConfig | spring-test/.../context/junit/jupiter/SpringJUnitConfig.java | 组合注解:@ExtendWith(SpringExtension)+@ContextConfiguration |
MockMvc | spring-test/.../web/servlet/MockMvc.java | 无容器 MockMvc 主入口,perform 驱动 MockFilterChain→TestDispatcherServlet |
MockMvcBuilders | spring-test/.../web/servlet/setup/MockMvcBuilders.java | webAppContextSetup/standaloneSetup/routerFunctions 构建入口 |
MockRestServiceServer | spring-test/.../web/client/MockRestServiceServer.java | mock RestTemplate/RestClient 的服务端响应 |
ReflectionTestUtils / AopTestUtils | spring-test/src/main/java/org/springframework/test/util/ | 反射设字段/调方法;剥 AOP 代理取真实对象 |