Spring Boot 4.1.1 深度源码解析-第 29 章 · 测试体系:@SpringBootTest、切片测试、Testcontainers、Docker Compose
本章的阅读对象:
core/spring-boot-test、core/spring-boot-test-autoconfigure、module/spring-boot-*-test(26 个)、core/spring-boot-testcontainers、core/spring-boot-docker-compose。第 28 章结尾说过"测试 AOT 是最容易触发日志配置不一致错误的场景"------ 本章要把这句话的上下文补齐:
SpringBootTestAotProcessor为什么会为每个测试类各建一个上下文, 以及@SpringBootTest到底在 Spring TestContext Framework 之上加了什么。
0. 从一行注解出发
java
@SpringBootTest
class MyApplicationTests {
@Test
void contextLoads() {
}
}
这行注解背后发生的事情,比大多数人想象的多得多。它不是"启动一个 Spring 容器", 而是在 Spring TestContext Framework(以下简称 TCF)的六个扩展点上各挂了一个 Spring Boot 实现, 把"启动应用"这件事完整地搬进测试里。
拆开看,TCF 的标准流程是:
TestContextBootstrapper决定"用什么配置加载上下文"ContextLoader真正去加载ContextCustomizerFactory产生一批ContextCustomizer,在上下文刷新前后做定制MergedContextConfiguration作为缓存键,决定两个测试类能否共用同一个上下文TestExecutionListener在测试方法前后做事情ApplicationListener/BeanFactoryPostProcessor在容器内部做事情
@SpringBootTest 在这六层都留下了痕迹:
| TCF 扩展点 | Spring Boot 的实现 | 本章位置 |
|---|---|---|
@BootstrapWith |
SpringBootTestContextBootstrapper |
§3 |
ContextLoader |
SpringBootContextLoader(同时实现 AotContextLoader) |
§4 |
ContextCustomizer |
SpringBootTestAnnotation、ServiceConnectionContextCustomizer 等 |
§3.7、§6.4 |
| 缓存键 | MergedContextConfiguration 的 equals + differentiator 属性 |
§3.6 |
TestExecutionListener |
MockMvcPrintOnlyOnFailureTestExecutionListener、WebDriverTestExecutionListener |
§5.6 |
| 容器内部 | TestcontainersLifecycleBeanFactoryPostProcessor、Docker Compose 的 ApplicationListener |
§6、§7 |
本章要做的,就是把这条链从头走到尾,并且回答一个核心问题: "测试用的应用"和"生产运行的应用",到底差在哪里?
1. 模块拓扑:五类测试模块,两条"外部服务"路线
Spring Boot 4.1 的测试相关代码分散在五个地方,各自职责不同。
1.1 core/spring-boot-test:基础设施
核心包结构:context/(@SpringBootTest、SpringBootContextLoader、 SpringBootTestContextBootstrapper、ConfigDataApplicationContextInitializer、 FilteredClassLoader、PropertyMapping、SpringBootTestAotProcessor )、 system/(输出捕获)、web/(htmlunit、server)、http/、json/、util/(TestPropertyValues)。
注意 context/ 下有 SpringBootTestAotProcessor ------它就是第 28 章 §9 讲的那个类。 测试 AOT 与 @SpringBootTest 是同一套代码,AOT 版本只是换了加载模式。
1.2 core/spring-boot-test-autoconfigure:切片的公共机制
只有 17 个源文件,但全是"切片"这套机制的地基:
OverrideAutoConfiguration/OverrideAutoConfigurationContextCustomizerFactoryTestSliceTestContextBootstrapperOnFailureConditionReportContextCustomizerFactory
1.3 module/spring-boot-*-test:26 个测试切片模块
这是 Spring Boot 4 相对 3.x 的一个结构性变化 :切片测试注解从 spring-boot-test-autoconfigure 一个模块里拆了出去,每个技术栈有自己的 *-test 模块。ls module/ | grep -- "-test$" 会列出 26 个: cache、data-{cassandra,couchbase,elasticsearch,jdbc,jpa,ldap,mongodb,neo4j,r2dbc,redis}、 graphql、grpc、jdbc、jooq、jpa、micrometer-{metrics,tracing}、restclient、 rsocket、security、web-server、webclient、webflux、webmvc、webservices。
拆分带来的直接好处:你只引入 @WebMvcTest,就只依赖 webmvc-test 模块 , 不会把 Cassandra、Neo4j 的测试自动配置拉进 classpath。在 Boot 3 时代, spring-boot-test-autoconfigure 是一个大而全的 jar,切片测试的类扫描范围天然比现在大。
也带来一个包名变化 :org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest → org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest。 这是 Boot 4 模块化改造的一部分(第 3 章讲过同一批改造)。
统计一下切片过滤器:全仓库有 19 个 TypeExcludeFilter 实现挂在切片注解上:
DataCassandraTypeExcludeFilter JdbcTypeExcludeFilter
DataCouchbaseTypeExcludeFilter JooqTypeExcludeFilter
DataElasticsearchTypeExcludeFilter RestClientTypeExcludeFilter
DataJdbcTypeExcludeFilter WebClientTypeExcludeFilter
DataJpaTypeExcludeFilter WebFluxTypeExcludeFilter
DataLdapTypeExcludeFilter WebMvcTypeExcludeFilter
DataMongoTypeExcludeFilter WebServiceClientExcludeFilter
DataNeo4jTypeExcludeFilter WebServiceServerTypeExcludeFilter
DataR2dbcTypeExcludeFilter GraphQlTypeExcludeFilter
DataRedisTypeExcludeFilter
(数量 19 是按 @TypeExcludeFilters(...) 的引用统计得来的,含 grpc / security 等未使用过滤器注解的模块不在其中。)
1.4 core/spring-boot-testcontainers:容器即 Bean
一个类层次清晰地分层:
bash
testcontainers/
├── beans/ TestcontainerBeanDefinition
├── context/ ImportTestcontainers、ContainerFieldsImporter、
│ DynamicPropertySourceMethodsImporter、TestcontainerFieldBeanDefinition
├── lifecycle/ TestcontainersLifecycleApplicationContextInitializer、
│ TestcontainersLifecycleBeanFactoryPostProcessor、
│ TestcontainersLifecycleBeanPostProcessor、TestcontainersStartup、
│ DockerEnvironmentNotFoundFailureAnalyzer
├── properties/ TestcontainersPropertySourceAutoConfiguration
└── service/connection/ @ServiceConnection、ContainerConnectionSource、
ContainerConnectionDetailsFactory、ServiceConnectionContextCustomizer(F)、
Ssl / SslBundleSource / PemKeyStore / JksKeyStore ...
1.5 core/spring-boot-docker-compose:开发期服务编排
bash
docker/compose/
├── core/ DockerCompose、DockerCli、DockerComposeFile、RunningService、
│ ConnectionPorts、ImageName、DockerJson(26 个类)
├── lifecycle/ DockerComposeListener、DockerComposeLifecycleManager、
│ DockerComposeProperties、ServiceReadinessChecks、
│ TcpConnectServiceReadinessCheck、LifecycleManagement、
│ StartCommand / StopCommand、DockerComposeSkipCheck
└── service/connection/ ------ 与 Testcontainers 共用 ConnectionDetails 抽象
注意这两条路线(Testcontainers 与 Docker Compose)共用同一个下游抽象 ------ org.springframework.boot.autoconfigure.service.connection.ConnectionDetails。 这正是第 26 章提到过的那个接口:自动配置不再自己拼 URL,而是从 ConnectionDetails 拿。 所以无论服务是 Testcontainers 起的还是 docker compose up 起的, 下游自动配置看到的东西完全一样。
这一节把模块拓扑讲清楚,是为了给后面所有的"为什么在这里"提供定位。 后面出现任何类名,你都能立刻判断它属于哪一层。
2. @SpringBootTest 注解本身
先看注解声明(去掉注释):
java
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@BootstrapWith(SpringBootTestContextBootstrapper.class)
@ExtendWith(SpringExtension.class)
public @interface SpringBootTest {
@AliasFor("properties")
String[] value() default {};
@AliasFor("value")
String[] properties() default {};
String[] args() default {};
Class<?>[] classes() default {};
WebEnvironment webEnvironment() default WebEnvironment.MOCK;
UseMainMethod useMainMethod() default UseMainMethod.NEVER;
}
六个属性,各自都有讲究。
2.1 value 与 properties 的 @AliasFor 双向别名
@SpringBootTest("a=b") 与 @SpringBootTest(properties = "a=b") 等价。 这是一对互为别名 的声明------两个属性上都写了 @AliasFor 指向对方。 Spring 的 AnnotationUtils / MergedAnnotations 会做别名一致性校验: 如果两个都写了不同值,会抛 AnnotationConfigurationException。
properties 的作用在 Bootstrapper 里(§3.5), 但有一个细节常被误解:它们的优先级低于 @TestPropertySource 的内联属性。 源码注释写得很直白:
java
// Added first so that inlined properties from @TestPropertySource take precedence
propertySourceProperties.addAll(0, Arrays.asList(properties));
addAll(0, ...) 把 properties 插到列表最前面 ,而 TestPropertyValues 应用属性时 越靠后的优先级越高。所以插到最前 = 优先级最低。 这个"越靠后越优先"的约定在第 6 章 PropertySource 次序那里见过同款。
2.2 args:ApplicationArguments 的来源
java
/**
* Application arguments that should be passed to the application under test.
* @see ApplicationArguments
* @see SpringApplication#run(String...)
* @since 2.2.0
*/
String[] args() default {};
它最终会传给 application.run(args),因此能被 ApplicationArguments bean 注入到业务代码里。 这是测试"命令行参数驱动的行为"的唯一正规入口。
它还有一个隐藏作用:参与上下文缓存键(§3.7)。两个测试 args 不同 → 上下文不共享。
2.3 WebEnvironment:四个值,一个 embedded 布尔
java
enum WebEnvironment {
MOCK(false), RANDOM_PORT(true), DEFINED_PORT(true), NONE(false);
private final boolean embedded;
WebEnvironment(boolean embedded) {
this.embedded = embedded;
}
public boolean isEmbedded() {
return this.embedded;
}
}
四个值的语义(Javadoc 原文要点):
- MOCK :有 servlet API 就建
WebApplicationContext(带 mock servlet 环境); 有 WebFlux 就建ReactiveWebApplicationContext;否则建普通ApplicationContext。 不启端口。 - RANDOM_PORT :真起一个服务器,并把
server.port=0塞进 Environment。 - DEFINED_PORT :真起服务器,端口用配置的(不塞
server.port=0)。 - NONE :
SpringApplication.setWebApplicationType(WebApplicationType.NONE)。
embedded 这个布尔是唯一的区分维度:MOCK 与 NONE 不启服务器 , RANDOM_PORT 与 DEFINED_PORT 启。它在 Bootstrapper 里被用来决定是否激活 ServletTestExecutionListener(§3.1)。
一个容易踩的坑(Bootstrapper 里有专门校验):
java
protected void verifyConfiguration(Class<?> testClass) {
SpringBootTest springBootTest = getAnnotation(testClass);
if (springBootTest != null && isListeningOnPort(springBootTest.webEnvironment())
&& MergedAnnotations.from(testClass, SearchStrategy.INHERITED_ANNOTATIONS)
.isPresent(WebAppConfiguration.class)) {
throw new IllegalStateException("@WebAppConfiguration should only be used "
+ "with @SpringBootTest when @SpringBootTest is configured with a "
+ "mock web environment. Please remove @WebAppConfiguration or reconfigure @SpringBootTest.");
}
}
@WebAppConfiguration 与真实端口模式互斥。 因为 @WebAppConfiguration 是 TCF 里 "我要一个 mock servlet 环境"的声明,与"我要真起服务器"语义冲突。 这个校验把"两个注解打架"从"运行时行为诡异"提前成"启动时明确报错"。
2.4 UseMainMethod:三态,且默认 NEVER
java
enum UseMainMethod {
ALWAYS, NEVER, WHEN_AVAILABLE
}
默认值是 NEVER------默认情况下测试不会走 main 方法 。这一点非常反直觉, 很多人以为 @SpringBootTest 是"调了应用的 main"。
三态语义(Javadoc):
ALWAYS:必须用 main。没有@SpringBootConfiguration类或该类没有 main 方法 → 失败。NEVER:不用 main,自己造一个测试专用的SpringApplication。WHEN_AVAILABLE:有就用,没有就降级到测试专用的SpringApplication。
为什么要区分?因为 main 方法里可能有测试也想跑的逻辑 ------ 比如某些自定义初始化、SpringApplicationBuilder 的链式配置、setAdditionalProfiles 等。 NEVER 模式下这些都不会生效。
代码路径在 SpringBootContextLoader(§4.2),这里先记住差异。
2.5 六个属性里唯一"不可省略"的其实是零个
@SpringBootTest 的所有属性都有默认值,所以 @SpringBootTest 等价于"用默认配置启动整个应用"。 真正决定"启动什么"的是上下文配置类的搜索(§3.3)。
3. SpringBootTestContextBootstrapper:配置是怎么被"算"出来的
TestContextBootstrapper 的职责是:给定测试类,算出一份 MergedContextConfiguration。 Spring Boot 的实现覆盖了父类的六个方法,每一个都有明确意图。
3.1 buildTestContext:激活(或关闭)ServletTestExecutionListener
java
private static final String ACTIVATE_SERVLET_LISTENER = "org.springframework.test."
+ "context.web.ServletTestExecutionListener.activateListener";
@Override
public TestContext buildTestContext() {
TestContext context = super.buildTestContext();
verifyConfiguration(context.getTestClass());
WebEnvironment webEnvironment = getWebEnvironment(context.getTestClass());
if (webEnvironment == WebEnvironment.MOCK && WebApplicationType.deduce() == WebApplicationType.SERVLET) {
context.setAttribute(ACTIVATE_SERVLET_LISTENER, true);
}
else if (webEnvironment != null && webEnvironment.isEmbedded()) {
context.setAttribute(ACTIVATE_SERVLET_LISTENER, false);
}
return context;
}
ACTIVATE_SERVLET_LISTENER 这个 key 被拆成两段字符串拼接 , 是为了避免某些静态分析工具把它当成一个完整的类名去解析。 它的值是 TCF 里 ServletTestExecutionListener 读取的属性名。
三条分支:
- MOCK + SERVLET →
true:需要 mock servlet 环境(MockServletContext)。 - RANDOM_PORT / DEFINED_PORT →
false:真服务器,不需要 mock。 - 其它(比如 NONE,或 MOCK 但应用类型是 REACTIVE)→ 不设属性,交给 TCF 默认行为。
注意第 2 个分支用的是 isEmbedded() 而不是枚举相等判断------这正是 §2.3 那个布尔存在的理由: 用一个布尔表达"是否真起服务器",比用两个枚举值更不容易遗漏未来的新枚举项。
3.2 getDefaultContextLoaderClass:一行切换
java
@Override
protected Class<? extends ContextLoader> getDefaultContextLoaderClass(Class<?> testClass) {
return SpringBootContextLoader.class;
}
TCF 默认用 DelegatingSmartContextLoader(按 XML 或注解配置选 GenericXmlContextLoader / AnnotationConfigContextLoader)。换成 SpringBootContextLoader 之后, "加载上下文"就变成了"跑一次 SpringApplication"。
3.3 getOrFindConfigurationClasses:搜索 @SpringBootConfiguration
这是 @SpringBootTest 最著名的行为------自动向上找主配置类。
java
protected Class<?>[] getOrFindConfigurationClasses(MergedContextConfiguration mergedConfig) {
Class<?>[] classes = mergedConfig.getClasses();
if (containsNonTestComponent(classes) || mergedConfig.hasLocations()) {
return classes;
}
Class<?> found = findConfigurationClass(mergedConfig.getTestClass());
logger.info("Found @SpringBootConfiguration " + found.getName() + " for test " + mergedConfig.getTestClass());
return merge(found, classes);
}
短路条件(两个,任一成立就不搜索):
containsNonTestComponent(classes):已经显式给了配置类,且其中至少有一个不是@TestConfiguration。mergedConfig.hasLocations():用了 XML 位置。
containsNonTestComponent 的实现值得看:
java
private boolean containsNonTestComponent(Class<?>[] classes) {
for (Class<?> candidate : classes) {
if (!MergedAnnotations.from(candidate, SearchStrategy.INHERITED_ANNOTATIONS)
.isPresent(TestConfiguration.class)) {
return true;
}
}
return false;
}
意思是:如果所有给出的类都是 @TestConfiguration,那还是要去搜主配置类 。 因为 @TestConfiguration 的语义是"补充配置"而不是"替代配置"------ 它不会触发 @EnableAutoConfiguration,也不带 @ComponentScan, 单独用它做配置类是跑不起来的(除非它 @Import 了主配置)。
搜索本身:
java
private Class<?> findConfigurationClass(Class<?> testClass) {
String propertyName = "%s.SpringBootConfiguration.%s"
.formatted(SpringBootTestContextBootstrapper.class.getName(), testClass.getName());
String foundClassName = this.aotTestAttributes.getString(propertyName);
if (foundClassName != null) {
return ClassUtils.resolveClassName(foundClassName, testClass.getClassLoader());
}
Class<?> found = new AnnotatedClassFinder(SpringBootConfiguration.class).findFromClass(testClass);
Assert.state(found != null,
"Unable to find a @SpringBootConfiguration by searching packages upwards from the test. "
+ "You can use @ContextConfiguration, @SpringBootTest(classes=...) or other Spring Test "
+ "supported mechanisms to explicitly declare the configuration classes to load. Classes "
+ "annotated with @TestConfiguration are not considered.");
this.aotTestAttributes.setAttribute(propertyName, found.getName());
return found;
}
三个要点:
AotTestAttributes是一层缓存。AOT 处理阶段会把"测试类 → 找到的主配置类"这个映射持久化, 运行期(AOT 模式)直接读,不用再扫包。这与第 28 章讲的"把构建期的判定结论持久化"是同一条定律 (第七十四:省掉决策)。AnnotatedClassFinder从测试类所在包逐级向上 找,直到找到带@SpringBootConfiguration的类。 所以测试类放在与主应用不同的包下时要小心------常见错误是把测试放到com.example.other而主类在com.example.app,搜索会一直向上到根包,最后找不到。- 错误消息的措辞很讲究:不仅说"找不到",还列出了三条解决路径 (
@ContextConfiguration、classes=...、其它机制),并明确提醒"@TestConfiguration不算"。 错误消息要可执行 ,这是本仓库反复出现的纪律(第 16 章FailureAnalyzer那里系统讲过)。
merge 把找到的类放到第一位:
java
private Class<?>[] merge(Class<?> head, Class<?>[] existing) {
Class<?>[] result = new Class<?>[existing.length + 1];
result[0] = head;
System.arraycopy(existing, 0, result, 1, existing.length);
return result;
}
主配置在前,@TestConfiguration 在后------保证后者的 bean 能覆盖前者(后注册的覆盖先注册的)。
3.4 processMergedContextConfiguration:把 MOCK 翻译成 Web 上下文配置
java
@Override
protected MergedContextConfiguration processMergedContextConfiguration(MergedContextConfiguration mergedConfig) {
Class<?>[] classes = getOrFindConfigurationClasses(mergedConfig);
List<String> propertySourceProperties = getAndProcessPropertySourceProperties(mergedConfig);
mergedConfig = createModifiedConfig(mergedConfig, classes, StringUtils.toStringArray(propertySourceProperties));
WebEnvironment webEnvironment = getWebEnvironment(mergedConfig.getTestClass());
if (webEnvironment != null && isWebEnvironmentSupported(mergedConfig)) {
WebApplicationType webApplicationType = getWebApplicationType(mergedConfig);
if (webApplicationType == WebApplicationType.SERVLET
&& (webEnvironment.isEmbedded() || webEnvironment == WebEnvironment.MOCK)) {
mergedConfig = new WebMergedContextConfiguration(mergedConfig, determineResourceBasePath(mergedConfig));
}
else if (webApplicationType == WebApplicationType.REACTIVE
&& (webEnvironment.isEmbedded() || webEnvironment == WebEnvironment.MOCK)) {
return new ReactiveWebMergedContextConfiguration(mergedConfig);
}
}
return mergedConfig;
}
三个分支产三种 MergedContextConfiguration:
| 条件 | 产物 |
|---|---|
| SERVLET + (embedded 或 MOCK) | WebMergedContextConfiguration(带 resource base path) |
| REACTIVE + (embedded 或 MOCK) | ReactiveWebMergedContextConfiguration |
| NONE / 非 web | 原样返回 |
WebMergedContextConfiguration 是 TCF 的类型,它携带 resourceBasePath------ 即 @WebAppConfiguration 的 value,默认是 src/main/webapp:
java
protected String determineResourceBasePath(MergedContextConfiguration configuration) {
return MergedAnnotations.from(configuration.getTestClass(), SearchStrategy.TYPE_HIERARCHY)
.get(WebAppConfiguration.class)
.getValue(MergedAnnotation.VALUE, String.class)
.orElse("src/main/webapp");
}
ReactiveWebMergedContextConfiguration 是 Spring Boot 自己写的 (在 org.springframework.boot.test.context 下),因为 TCF 只为 servlet 提供了 WebMergedContextConfiguration。 这是"框架没提供,就自己造一个"的典型。
getWebApplicationType 的写法值得注意:
java
private WebApplicationType getWebApplicationType(MergedContextConfiguration configuration) {
ConfigurationPropertySource source = new MapConfigurationPropertySource(
TestPropertySourceUtils.convertInlinedPropertiesToMap(configuration.getPropertySourceProperties()));
Binder binder = new Binder(source);
return binder.bind("spring.main.web-application-type", Bindable.of(WebApplicationType.class))
.orElseGet(WebApplicationType::deduce);
}
它不是 直接看注解,而是先用 Binder 看 spring.main.web-application-type 属性------ 因为 WebEnvironment.NONE 的实现方式就是往属性里塞 spring.main.web-application-type=none(§3.5)。 所以这里读属性才能看到"最终生效的类型"。 优先读最终状态而不是读输入参数,避免了两处逻辑不同步。
isWebEnvironmentSupported 处理 @ContextHierarchy:
java
private boolean isWebEnvironmentSupported(MergedContextConfiguration mergedConfig) {
Class<?> testClass = mergedConfig.getTestClass();
ContextHierarchy hierarchy = AnnotationUtils.getAnnotation(testClass, ContextHierarchy.class);
if (hierarchy == null || hierarchy.value().length == 0) {
return true;
}
ContextConfiguration[] configurations = hierarchy.value();
return isFromConfiguration(mergedConfig, configurations[configurations.length - 1]);
}
层次上下文里,只有最后一层 才被认为是"web 层"。 这是一个约定:@ContextHierarchy 里最后一个 @ContextConfiguration 是 web 子上下文。
3.5 processPropertySourceProperties:RANDOM_PORT 与 NONE 的实现
java
protected void processPropertySourceProperties(MergedContextConfiguration mergedConfig,
List<String> propertySourceProperties) {
Class<?> testClass = mergedConfig.getTestClass();
String[] properties = getProperties(testClass);
if (!ObjectUtils.isEmpty(properties)) {
// Added first so that inlined properties from @TestPropertySource take precedence
propertySourceProperties.addAll(0, Arrays.asList(properties));
}
WebEnvironment webEnvironment = getWebEnvironment(testClass);
if (webEnvironment == WebEnvironment.RANDOM_PORT) {
propertySourceProperties.add("server.port=0");
}
else if (webEnvironment == WebEnvironment.NONE) {
propertySourceProperties.add("spring.main.web-application-type=none");
}
}
RANDOM_PORT 的实现就是加一个 server.port=0 ,没有任何魔法。 同理 NONE 就是加 spring.main.web-application-type=none。
这也解释了为什么 @SpringBootTest(webEnvironment = RANDOM_PORT) 与 @SpringBootTest(properties = "server.port=0") 不完全等价 : 后者不会被 processMergedContextConfiguration 识别为"web 环境"吗? 实际上会------因为 §3.4 的 getWebApplicationType 读的是属性,而不是枚举。 但 isEmbedded() 仍然为 false,所以 ServletTestExecutionListener 不会被关闭(§3.1)。 用枚举而不是属性,语义才完整。
3.6 differentiator:一个专门用来防缓存串味的属性
java
/**
* Return a "differentiator" property to ensure that there is something to
* differentiate regular tests and bootstrapped tests. Without this property a cached
* context could be returned that wasn't created by this bootstrapper. By default uses
* the bootstrapper class as a property.
* @return the differentiator or {@code null}
*/
protected @Nullable String getDifferentiatorPropertySourceProperty() {
return getClass().getName() + "=true";
}
这段注释解释了一个非常微妙的 bug 场景。
TCF 的上下文缓存键是 MergedContextConfiguration。如果两个测试类的配置 恰好完全一样 (同样的类、同样的属性、同样的 profile),它们会共享同一个上下文。 问题在于:一个用 SpringBootTestContextBootstrapper 启动的上下文, 与一个用 TCF 默认 DefaultTestContextBootstrapper 启动的上下文, MergedContextConfiguration 可能完全一致 ------但前者跑过 SpringApplication,后者没有。 如果共享了,后者会拿到一个"被 Spring Boot 处理过"的上下文。
对策:往属性列表里塞一个只由 bootstrapper 类名决定 的属性。 SpringBootTestContextBootstrapper 加 SpringBootTestContextBootstrapper=true, WebMvcTestContextBootstrapper 加 WebMvcTestContextBootstrapper=true------ 于是两类测试的缓存键天然不同,不会串味。
而且由于 getClass().getName() 是动态取的,子类自动获得不同的 differentiator。 一行代码覆盖了"所有 bootstrapper 子类之间的关系",这是很划算的设计。
顺带解释了为什么切片测试(§5)继承
SpringBootTestContextBootstrapper而不是另起炉灶: 它们共享绝大部分逻辑,同时自动获得各自的 differentiator。
3.7 createModifiedConfig:塞入 SpringBootTestAnnotation
java
protected final MergedContextConfiguration createModifiedConfig(MergedContextConfiguration mergedConfig,
Class<?>[] classes, String[] propertySourceProperties) {
Set<ContextCustomizer> contextCustomizers = new LinkedHashSet<>(mergedConfig.getContextCustomizers());
contextCustomizers.add(new SpringBootTestAnnotation(mergedConfig.getTestClass()));
return new MergedContextConfiguration(mergedConfig.getTestClass(), mergedConfig.getLocations(), classes,
mergedConfig.getContextInitializerClasses(), mergedConfig.getActiveProfiles(),
mergedConfig.getPropertySourceDescriptors(), propertySourceProperties, contextCustomizers,
mergedConfig.getContextLoader(), getCacheAwareContextLoaderDelegate(), mergedConfig.getParent());
}
用 LinkedHashSet 包一层再 add,是为了去重 (ContextCustomizer 要求实现 equals), 同时保持顺序。
SpringBootTestAnnotation 是一个特殊用途的 ContextCustomizer:
java
/**
* {@link ContextCustomizer} to track attributes of {@link SpringBootTest @SpringBootTest}
* that are taken into account when evaluating a {@link MergedContextConfiguration} to
* determine if a context can be shared between tests.
*/
class SpringBootTestAnnotation implements ContextCustomizer {
...
@Override
public void customizeContext(ConfigurableApplicationContext context, MergedContextConfiguration mergedConfig) {
}
}
它的 customizeContext 是空实现。 它存在的唯一目的是参与 MergedContextConfiguration 的 equals/hashCode------即作为缓存键的一部分。
它记录了三个东西:args、webEnvironment、useMainMethod。 为什么这三个必须进缓存键?因为它们不改变属性列表,却改变上下文的行为:
args影响ApplicationArgumentswebEnvironment影响ServletTestExecutionListener与上下文类型useMainMethod影响"是否跑 main 方法"
hashCode 的手写实现:
java
@Override
public int hashCode() {
final int prime = 31;
int result = 1;
result = prime * result + Arrays.hashCode(this.args);
result = prime * result + Objects.hash(this.useMainMethod, this.webEnvironment);
return result;
}
还有一个静态工厂处理"没有 @SpringBootTest 注解"的情况:
java
static SpringBootTestAnnotation get(MergedContextConfiguration mergedConfig) {
for (ContextCustomizer customizer : mergedConfig.getContextCustomizers()) {
if (customizer instanceof SpringBootTestAnnotation annotation) {
return annotation;
}
}
return DEFAULT;
}
DEFAULT 的三元组是 (空 args, WebEnvironment.NONE, UseMainMethod.NEVER)------ 零值语义 ,让 SpringBootContextLoader 不用判空。
3.8 一个 AOT 相关的细节
SpringBootTestContextBootstrapper 有两个构造器:
java
public SpringBootTestContextBootstrapper() {
this(AotTestAttributes.getInstance());
}
SpringBootTestContextBootstrapper(AotTestAttributes aotTestAttributes) {
this.aotTestAttributes = aotTestAttributes;
}
包级可见的构造器给测试用(可传 mock)。AotTestAttributes.getInstance() 是 Spring Framework 的 全局单例,被 TestAotProcessor(AOT 处理)与运行期共同读取------ 第 28 章 §9 讲测试 AOT 时提到的"属性持久化"就落在这里。
待核实 :
AotTestAttributes的持久化格式(写到哪个文件、何时写)本轮未读取 (该类在spring-test中,不在本仓库)。可自测:跑一次processTestAot, 在target/spring-aot/test/下找含SpringBootConfiguration字样的文件。
4. SpringBootContextLoader:把"跑应用"变成"加载上下文"
SpringBootContextLoader 是本章代码量最大的类(624 行),也是最能体现 "测试与生产共用同一条启动路径"的地方。它同时实现 AbstractContextLoader 与 AotContextLoader------后者是 AOT 支持的入口。
4.1 三个入口,一个私有实现
java
@Override
public ApplicationContext loadContext(MergedContextConfiguration mergedConfig) throws Exception {
return loadContext(mergedConfig, Mode.STANDARD, null, null);
}
@Override
public ApplicationContext loadContextForAotProcessing(MergedContextConfiguration mergedConfig,
RuntimeHints runtimeHints) throws Exception {
return loadContext(mergedConfig, Mode.AOT_PROCESSING, null, runtimeHints);
}
@Override
public ApplicationContext loadContextForAotRuntime(MergedContextConfiguration mergedConfig,
ApplicationContextInitializer<ConfigurableApplicationContext> initializer) throws Exception {
return loadContext(mergedConfig, Mode.AOT_RUNTIME, initializer, null);
}
Mode 三态:
java
private enum Mode {
STANDARD, // 正常测试
AOT_PROCESSING, // AOT 处理(录制)
AOT_RUNTIME // AOT 运行(消费产物)
}
注意 AOT_PROCESSING 多带一个 runtimeHints 参数------因为录制阶段要往 RuntimeHints 里注册 "main 方法需要被反射调用"(见下文 registerMethod)。
4.2 useMainMethod 的三态如何落地
java
private ApplicationContext loadContext(MergedContextConfiguration mergedConfig, Mode mode,
@Nullable ApplicationContextInitializer<ConfigurableApplicationContext> initializer,
@Nullable RuntimeHints runtimeHints) throws Exception {
assertHasClassesOrLocations(mergedConfig);
SpringBootTestAnnotation annotation = SpringBootTestAnnotation.get(mergedConfig);
String[] args = annotation.getArgs();
UseMainMethod useMainMethod = annotation.getUseMainMethod();
Method mainMethod = getMainMethod(mergedConfig, useMainMethod);
if (mainMethod != null) {
if (runtimeHints != null) {
runtimeHints.reflection().registerMethod(mainMethod, ExecutableMode.INVOKE);
}
ContextLoaderHook hook = new ContextLoaderHook(mode, initializer,
(application) -> configure(mergedConfig, application));
return hook.runMain(() -> {
if (mainMethod.getParameterCount() == 0) {
ReflectionUtils.invokeMethod(mainMethod, null);
}
else {
ReflectionUtils.invokeMethod(mainMethod, null, new Object[] { args });
}
});
}
SpringApplication application = getSpringApplication();
configure(mergedConfig, application);
ContextLoaderHook hook = new ContextLoaderHook(mode, initializer, ALREADY_CONFIGURED);
return hook.run(() -> application.run(args));
}
两条路径的差别只在"谁来配置 SpringApplication":
- 走 main :配置动作交给 lambda
(application) -> configure(mergedConfig, application), 由ContextLoaderHook在starting()回调里执行(那时SpringApplication才存在)。 - 不走 main :自己
new SpringApplication()并configure,于是 hook 的 configurer 是ALREADY_CONFIGURED------一个空 lambda:
java
private static final Consumer<SpringApplication> ALREADY_CONFIGURED = (springApplication) -> {
};
用空 lambda 而不是 null,避免了 ContextLoaderHook 里判空。 "什么都不做"用空实现表达,不用 null ------这与 §3.7 的 DEFAULT 零值是同一条纪律。
runtimeHints.reflection().registerMethod(mainMethod, ExecutableMode.INVOKE) 这一行是关键: 走 main 方法意味着 AOT 生成的 native image 必须能反射调它。 这正是第 28 章第七十六定律("生成代码之后必须自己注册可达性 hint")的变体------ 这里没有生成代码,而是"用了反射就得注册"。
mainMethod.getParameterCount() == 0 的分支对应 JEP 445 的无参 main (第 27 章讲 Launcher 时也见过同款双形态支持)。有意思的是:无参 main 时 args 就丢了, 因为没法传进去。所以用无参 main 的测试无法使用 @SpringBootTest(args = ...)。
4.3 getMainMethod:三态的具体判定
java
private @Nullable Method getMainMethod(MergedContextConfiguration mergedConfig, UseMainMethod useMainMethod) {
if (useMainMethod == UseMainMethod.NEVER) {
return null;
}
Assert.state(mergedConfig.getParent() == null,
() -> "UseMainMethod.%s cannot be used with @ContextHierarchy tests".formatted(useMainMethod));
Class<?> springBootConfiguration = Arrays.stream(mergedConfig.getClasses())
.filter(this::isSpringBootConfiguration)
.findFirst()
.orElse(null);
Assert.state(springBootConfiguration != null || useMainMethod == UseMainMethod.WHEN_AVAILABLE,
"Cannot use main method as no @SpringBootConfiguration-annotated class is available");
Method mainMethod = findMainMethod(springBootConfiguration);
Assert.state(mainMethod != null || useMainMethod == UseMainMethod.WHEN_AVAILABLE,
() -> "Main method not found on '%s'"
.formatted((springBootConfiguration != null) ? springBootConfiguration.getName() : null));
return mainMethod;
}
NEVER直接返回null(默认路径)。ALWAYS/WHEN_AVAILABLE都不允许@ContextHierarchy------因为层次上下文需要多次加载, 而 main 方法只会跑一次且无法传 parent。这是一个能力边界的显式拒绝。- 两个
Assert.state都带|| useMainMethod == WHEN_AVAILABLE:ALWAYS时条件不满足就抛,WHEN_AVAILABLE时降级返回null。一个表达式覆盖两态。
findMainMethod 还处理 Kotlin:
java
private static @Nullable Method findMainMethod(@Nullable Class<?> type) {
Method mainMethod = (type != null) ? findMainJavaMethod(type) : null;
if (mainMethod == null && KotlinDetector.isKotlinPresent()) {
try {
Assert.state(type != null, "'type' must not be null");
Class<?> kotlinClass = ClassUtils.forName(type.getName() + "Kt", type.getClassLoader());
mainMethod = ReflectionUtils.findMethod(kotlinClass, "main", String[].class);
}
catch (ClassNotFoundException ex) {
// Ignore
}
}
return mainMethod;
}
Kotlin 的顶层 main 函数编译后落在 <类名>Kt 这个类上,所以要额外找一次。 KotlinDetector.isKotlinPresent() 先判断 Kotlin 是否在 classpath------ 可选依赖的三段式:检测 → 尝试 → 忽略失败(第 28 章第七十七定律的变体)。
findMainJavaMethod 与 getMainMethod:
java
private static @Nullable Method findMainJavaMethod(Class<?> type) {
try {
Method method = getMainMethod(type);
if (Modifier.isStatic(method.getModifiers())) {
method.setAccessible(true);
return method;
}
}
catch (Exception ex) {
// Ignore
}
return null;
}
private static Method getMainMethod(Class<?> type) throws NoSuchMethodException {
try {
return type.getDeclaredMethod("main", String[].class);
}
catch (NoSuchMethodException ex) {
return type.getDeclaredMethod("main");
}
}
getDeclaredMethod(不是getMethod):main 可以是private。- 先看
main(String[]),再看main()------顺序与第 27 章Launcher一致。 - 必须是
static,否则返回null(静默降级)。 setAccessible(true)打开私有访问。
4.4 configure:把 MergedContextConfiguration 翻译成 SpringApplication
java
private void configure(MergedContextConfiguration mergedConfig, SpringApplication application) {
application.setMainApplicationClass(mergedConfig.getTestClass());
application.addPrimarySources(Arrays.asList(mergedConfig.getClasses()));
application.getSources().addAll(Arrays.asList(mergedConfig.getLocations()));
List<ApplicationContextInitializer<?>> initializers = getInitializers(mergedConfig, application);
if (mergedConfig instanceof WebMergedContextConfiguration) {
application.setWebApplicationType(WebApplicationType.SERVLET);
if (!isEmbeddedWebEnvironment(mergedConfig)) {
new WebConfigurer().configure(mergedConfig, initializers);
}
}
else if (mergedConfig instanceof ReactiveWebMergedContextConfiguration) {
application.setWebApplicationType(WebApplicationType.REACTIVE);
}
else {
application.setWebApplicationType(WebApplicationType.NONE);
}
application.setApplicationContextFactory(getApplicationContextFactory(mergedConfig));
if (mergedConfig.getParent() != null) {
application.setBannerMode(Banner.Mode.OFF);
}
application.setInitializers(initializers);
ConfigurableEnvironment environment = getEnvironment();
if (environment != null) {
prepareEnvironment(mergedConfig, application, environment, false);
application.setEnvironment(environment);
}
else {
application.addListeners(new PrepareEnvironmentListener(mergedConfig));
}
}
逐条:
setMainApplicationClass(mergedConfig.getTestClass())------注意是测试类 不是主应用类。 这会影响 banner 输出、spring.main.*的一些推断,以及 AOT 生成类的命名 (第 28 章讲过<MainClass>__ApplicationContextInitializer,测试 AOT 时那个 "MainClass" 就是测试类)。addPrimarySources(classes)+getSources().addAll(locations): 主配置类是 primary source (会被@ComponentScan的 basePackage 推断用到), XML locations 是普通 source。- 三个
instanceof分支决定WebApplicationType------与 §3.4 bootstrapper 的分支一一对应, 只不过那边产出配置类型,这边消费。 isEmbeddedWebEnvironment为 true 时不加 mock servlet 环境 (WebConfigurer只在非 embedded 时调用)------真服务器不需要 mock。parent != null时关 banner:层次上下文里子上下文启动时打印 banner 很吵。- Environment 的两条路:子类覆写
getEnvironment()返回非 null 就直接设; 否则注册一个PrepareEnvironmentListener在ApplicationEnvironmentPreparedEvent时再准备。 后者是默认路径------因为 Environment 必须由SpringApplication自己按WebApplicationType创建。
getApplicationContextFactory:
java
protected ApplicationContextFactory getApplicationContextFactory(MergedContextConfiguration mergedConfig) {
return (webApplicationType) -> {
if (webApplicationType != WebApplicationType.NONE && !isEmbeddedWebEnvironment(mergedConfig)) {
if (webApplicationType == WebApplicationType.REACTIVE) {
return new GenericReactiveWebApplicationContext();
}
if (webApplicationType == WebApplicationType.SERVLET) {
return new GenericWebApplicationContext();
}
}
return ApplicationContextFactory.DEFAULT.create(webApplicationType);
};
}
非 embedded 的 web 测试用 GenericWebApplicationContext ,而不是默认的 AnnotationConfigServletWebApplicationContext。区别:后者自带注解处理能力与 ServletContext 初始化;前者更"裸",配置全部来自 SpringApplication 的设置。 这与第 28 章 AOT 模式用 GenericApplicationContext 是同一思路------ 测试里不需要默认那套"便利设施",越裸越可预测。
embedded(RANDOM_PORT / DEFINED_PORT)时走 ApplicationContextFactory.DEFAULT, 即生产同款------因为真起服务器需要完整的 web 上下文能力。
4.5 getInlinedProperties:spring.jmx.enabled=false
java
protected String[] getInlinedProperties(MergedContextConfiguration mergedConfig) {
ArrayList<String> properties = new ArrayList<>();
// JMX bean names will clash if the same bean is used in multiple contexts
properties.add("spring.jmx.enabled=false");
properties.addAll(Arrays.asList(mergedConfig.getPropertySourceProperties()));
return StringUtils.toStringArray(properties);
}
注释说得很清楚:多个上下文同时存在时 JMX bean 名会冲突。 测试里同时跑多个上下文是常态(上下文缓存),所以默认关掉 JMX。
这是"测试默认值与生产默认值不同"的少数几处之一。其它类似的还有 (推测,未逐行核实)DevTools 相关属性、shutdown hook 等。
4.6 setActiveProfiles:属性与 API 双写
java
private void setActiveProfiles(ConfigurableEnvironment environment, String[] profiles,
boolean applicationEnvironment) {
if (ObjectUtils.isEmpty(profiles)) {
return;
}
if (!applicationEnvironment) {
environment.setActiveProfiles(profiles);
}
String[] pairs = new String[profiles.length];
for (int i = 0; i < profiles.length; i++) {
pairs[i] = "spring.profiles.active[" + i + "]=" + profiles[i];
}
TestPropertyValues.of(pairs).applyTo(environment, Type.MAP, "active-test-profiles");
}
为什么要做两遍?
environment.setActiveProfiles(profiles):直接设置,但会被后续的 ConfigData 加载覆盖 (第 10 章讲过spring.profiles.active可以从配置文件里来)。- 写
spring.profiles.active[i]=xxx属性:以属性形式生效,优先级更高且能参与 ConfigData 求解。
applicationEnvironment 参数为 true 时(即 PrepareEnvironmentListener 里)只写属性、 不调 setActiveProfiles------因为那时 SpringApplication 已经用属性准备好了 Environment, 再调 API 会打架。
spring.profiles.active[0]= 这种带索引的属性语法 是 Spring Boot 绑定 List/数组的标准写法 (第 8 章 Binder 讲过)。
4.7 WebConfigurer 与 DefensiveWebApplicationContextInitializer
java
private void addMockServletContext(List<ApplicationContextInitializer<?>> initializers,
WebMergedContextConfiguration webMergedConfig) {
SpringBootMockServletContext servletContext = new SpringBootMockServletContext(
webMergedConfig.getResourceBasePath());
initializers.add(0, new DefensiveWebApplicationContextInitializer(
new ServletContextApplicationContextInitializer(servletContext, true)));
}
add(0, ...)------放最前面,因为 ServletContext 必须在任何 web 相关 bean 创建前就位。
DefensiveWebApplicationContextInitializer 值得单独说:
java
/**
* Decorator for {@link ServletContextApplicationContextInitializer} that prevents
* a failure when the context type is not as was predicted when the initializer
* was registered. This can occur when spring.main.web-application-type is set to
* something other than servlet.
*/
private static final class DefensiveWebApplicationContextInitializer
implements ApplicationContextInitializer<ConfigurableApplicationContext> {
@Override
public void initialize(ConfigurableApplicationContext applicationContext) {
if (applicationContext instanceof ConfigurableWebApplicationContext webApplicationContext) {
this.delegate.initialize(webApplicationContext);
}
}
}
它解决的场景是:注册时以为是 servlet 上下文,实际运行时不是 。 为什么会这样?因为 spring.main.web-application-type 可以在测试属性里被覆盖成 none。 注册 initializer 发生在 configure()(那时读的是 MergedContextConfiguration 类型), 而真实上下文类型要到 ApplicationContextFactory 决定时才定型------两者可能不一致。
对策是"防御性检查":类型不匹配就什么都不做。 这是一种"静默降级",在这里是安全的------不是 web 上下文就不需要 ServletContext。
这个模式在第 28 章 §7.7 也出现过(
inferTypeFromParent推断失败返回null)。 两条定律会在 §10 汇总。
4.8 ContextLoaderHook:把 SpringApplicationHook 用在测试里
这是本节技术含量最高的一段:
java
/**
* {@link SpringApplicationHook} used to capture {@link ApplicationContext} instances
* and to trigger early exit for the {@link Mode#AOT_PROCESSING} mode.
*/
private static class ContextLoaderHook implements SpringApplicationHook {
private final List<ApplicationContext> contexts = Collections.synchronizedList(new ArrayList<>());
private final List<ApplicationContext> failedContexts = Collections.synchronizedList(new ArrayList<>());
@Override
public SpringApplicationRunListener getRunListener(SpringApplication application) {
return new SpringApplicationRunListener() {
@Override
public void starting(ConfigurableBootstrapContext bootstrapContext) {
ContextLoaderHook.this.configurer.accept(application);
if (ContextLoaderHook.this.mode == Mode.AOT_RUNTIME) {
Assert.state(ContextLoaderHook.this.initializer != null, "'initializer' must not be null");
application.addInitializers(
(AotApplicationContextInitializer<?>) ContextLoaderHook.this.initializer::initialize);
}
}
@Override
public void contextLoaded(ConfigurableApplicationContext context) {
ContextLoaderHook.this.contexts.add(context);
if (ContextLoaderHook.this.mode == Mode.AOT_PROCESSING) {
throw new AbandonedRunException(context);
}
}
@Override
public void failed(@Nullable ConfigurableApplicationContext context, Throwable exception) {
if (context != null) {
ContextLoaderHook.this.failedContexts.add(context);
}
}
};
}
}
这就是第 28 章 §2 讲的 AotProcessorHook 的"测试版" 。两个 hook 用的是同一个技巧: 在 contextLoaded 时抛 AbandonedRunException,把上下文从启动流程里"带出来"。
区别在于:
第 28 章 AotProcessorHook |
本章 ContextLoaderHook |
|
|---|---|---|
| 目的 | 拿上下文去生成 AOT 代码 | 拿上下文交给 TCF |
| 触发时机 | Mode.AOT_PROCESSING |
Mode.AOT_PROCESSING(同名,不同类) |
| 是否捕获上下文 | 由 SpringApplicationAotProcessor 捕获 |
自己用 contexts 列表捕获 |
starting() 里的两个动作:
configurer.accept(application)------这是"走 main 方法"路径配置SpringApplication的唯一时机 。 因为 main 方法内部new出来的SpringApplication外部拿不到,只有在SpringApplicationRunListener.starting()时才能截获它。AOT_RUNTIME时把 AOT 生成的AotApplicationContextInitializer加进去------ 注意用的是方法引用强制转型 :(AotApplicationContextInitializer<?>) initializer::initialize。 因为SpringApplication.addInitializers要求ApplicationContextInitializer<?>, 而 AOT 需要一个特定标记接口(第 28 章 §3.1 讲过AotApplicationContextInitializer的作用------addAotGeneratedInitializerIfNecessary会去找它)。
run() 方法收尾:
java
private ApplicationContext run(ThrowingSupplier<?> action) throws Exception {
try {
Object result = SpringApplication.withHook(this, action);
if (result instanceof ApplicationContext context) {
return context;
}
}
catch (AbandonedRunException ex) {
// Ignore
}
catch (Exception ex) {
if (this.failedContexts.size() == 1) {
throw new ContextLoadException(this.failedContexts.get(0), ex);
}
throw ex;
}
List<ApplicationContext> rootContexts = this.contexts.stream()
.filter((context) -> context.getParent() == null)
.toList();
Assert.state(!rootContexts.isEmpty(), "No root application context located");
Assert.state(rootContexts.size() == 1, "No unique root application context located");
return rootContexts.get(0);
}
四个分支:
- 正常返回
ApplicationContext:直接返回。这发生在"不走 hook"的极少数情况 (比如 main 方法自己返回了上下文)。 AbandonedRunException:忽略异常,走下面的列表逻辑。- 其它异常 :如果只有一个失败上下文,包成
ContextLoadException(携带上下文, 便于FailureAnalyzer分析;第 16 章讲过这套机制);否则原样抛。 - 正常跑完 :从
contexts里筛parent == null的(根上下文), 要求恰好一个。多条断言防止"多个根上下文"的歧义。
SpringApplication.withHook(this, action) 是第 28 章 §2.7 见过的 ThreadLocal 挂载。 测试与 AOT 处理共用这同一个基础设施 ------这解释了为什么 SpringBootContextLoader 能同时承担三种 Mode。
4.9 一个容易忽略的事实
SpringBootContextLoader 是 AotContextLoader 。这意味着: @SpringBootTest 从设计之初就同时考虑了 AOT ,不是后来补的。 第 28 章 §9 的 SpringBootTestAotProcessor 之所以能用 classpath roots 而不是 main 类, 正是因为 loadContextForAotProcessing 接受的是 MergedContextConfiguration------ 它由 TCF 从测试类算出,与"有没有 main 类"无关。
5. 切片测试:三件事构成的机制
一个切片注解(比如 @WebMvcTest)要做三件独立的事:
- 关掉全量自动配置
- 限制组件扫描范围(只留切片相关的组件)
- 只导入切片需要的自动配置
这三件事分别由三个机制实现,彼此正交。
5.1 第一件:@OverrideAutoConfiguration(enabled = false)
看 @WebMvcTest 的注解声明:
java
@BootstrapWith(WebMvcTestContextBootstrapper.class)
@ExtendWith(SpringExtension.class)
@OverrideAutoConfiguration(enabled = false)
@TypeExcludeFilters(WebMvcTypeExcludeFilter.class)
@AutoConfigureWebMvc
@AutoConfigureMockMvc
@ImportAutoConfiguration
public @interface WebMvcTest {
@OverrideAutoConfiguration(enabled = false) 的实现:
java
class OverrideAutoConfigurationContextCustomizerFactory implements ContextCustomizerFactory {
@Override
public @Nullable ContextCustomizer createContextCustomizer(Class<?> testClass,
List<ContextConfigurationAttributes> configurationAttributes) {
if (AotDetector.useGeneratedArtifacts()) {
return null;
}
OverrideAutoConfiguration overrideAutoConfiguration = TestContextAnnotationUtils.findMergedAnnotation(testClass,
OverrideAutoConfiguration.class);
boolean enabled = (overrideAutoConfiguration == null) || overrideAutoConfiguration.enabled();
return !enabled ? new DisableAutoConfigurationContextCustomizer() : null;
}
private static final class DisableAutoConfigurationContextCustomizer implements ContextCustomizer {
@Override
public void customizeContext(ConfigurableApplicationContext context, MergedContextConfiguration mergedConfig) {
TestPropertyValues.of(EnableAutoConfiguration.ENABLED_OVERRIDE_PROPERTY + "=false").applyTo(context);
}
@Override
public boolean equals(@Nullable Object obj) {
return (obj != null) && (obj.getClass() == getClass());
}
@Override
public int hashCode() {
return getClass().hashCode();
}
}
}
关掉自动配置的手段是:设一个属性 spring.boot.enableautoconfiguration=false (EnableAutoConfiguration.ENABLED_OVERRIDE_PROPERTY)。 第 14 章讲过这个属性------它是 AutoConfigurationImportSelector 的短路开关。
两个细节:
AotDetector.useGeneratedArtifacts()时返回null:AOT 模式不生成这个 customizer。 因为 AOT 已经在构建期把自动配置结果固化了,运行期再关一次没有意义(而且会破坏 AOT 产物)。equals用obj.getClass() == getClass()------因为这个类无状态,同类型即相等 。hashCode用getClass().hashCode()。这是ContextCustomizer的标准写法 (§3.7 的SpringBootTestAnnotation则是"有状态就逐字段比"的另一极)。
5.2 第二件:TypeExcludeFilter ------ 用一个 bean 影响组件扫描
先看 core 里的基类(org.springframework.boot.context.TypeExcludeFilter):
java
public class TypeExcludeFilter implements TypeFilter, BeanFactoryAware {
@SuppressWarnings("NullAway.Init")
private BeanFactory beanFactory;
private @Nullable Collection<TypeExcludeFilter> delegates;
@Override
public void setBeanFactory(BeanFactory beanFactory) throws BeansException {
this.beanFactory = beanFactory;
}
@Override
public boolean match(MetadataReader metadataReader, MetadataReaderFactory metadataReaderFactory)
throws IOException {
if (this.beanFactory instanceof ListableBeanFactory && getClass() == TypeExcludeFilter.class) {
for (TypeExcludeFilter delegate : getDelegates()) {
if (delegate.match(metadataReader, metadataReaderFactory)) {
return true;
}
}
}
return false;
}
private Collection<TypeExcludeFilter> getDelegates() {
Collection<TypeExcludeFilter> delegates = this.delegates;
if (delegates == null) {
delegates = ((ListableBeanFactory) this.beanFactory).getBeansOfType(TypeExcludeFilter.class).values();
this.delegates = delegates;
}
return delegates;
}
@Override
public boolean equals(@Nullable Object obj) {
throw new IllegalStateException("TypeExcludeFilter " + getClass() + " has not implemented equals");
}
@Override
public int hashCode() {
throw new IllegalStateException("TypeExcludeFilter " + getClass() + " has not implemented hashCode");
}
}
这是本章最精妙的一段设计,值得逐句拆。
机制 :TypeExcludeFilter 自己是一个 TypeFilter(组件扫描的过滤器接口), 但它不直接判断任何东西 ,而是从 BeanFactory 里取出所有其它 TypeExcludeFilter bean 逐个问一遍。任何一个说"排除"就排除。
为什么这样绕? 因为 @ComponentScan 的 excludeFilters 是在注解上静态声明的, 无法在运行期改变。而切片测试需要"动态的、由测试决定的过滤规则"。 Spring Boot 的解法是:
- 在
@SpringBootApplication的@ComponentScan里静态声明excludeFilters = @Filter(type = CUSTOM, classes = TypeExcludeFilter.class)(这是SpringBootApplication注解的一部分,见第 13 章) TypeExcludeFilter运行期从 bean 工厂里找真正的过滤器
于是过滤规则可以是一个 bean ,可以被 @Bean 方法创建、可以被条件注解控制、 可以在测试里动态注册。
getClass() == TypeExcludeFilter.class 这个判断是防自引用的: 如果当前对象是子类(真正的过滤器实现),它不应该再去问一遍委托------否则无限递归。
equals/hashCode 直接抛异常是本段最反直觉的地方。Javadoc 解释了原因:
Implementations should provide a subclass registered with
BeanFactoryand override thematch(...)method. They should also implement a validhashCode()andequals(Object)methods so that they can be used as part of Spring test's application context caches.
即:基类无法给出有意义的相等语义,所以强制子类实现 。 用抛异常而不是抽象方法,是为了让"忘了实现"在运行期立刻暴露(而不是静默产生错误的缓存命中)。 这是一个"用失败代替错误行为"的强硬选择------ 对比第 27 章 definePackageIfNecessary 里 tolerateRaceConditionDueToBeingParallelCapable 那种"判断后再决定要不要抛"的柔和做法,这里是"无条件抛"。
待核实 :
@SpringBootApplication是否无条件带TypeExcludeFilter排除器, 本轮未回到第 13 章确认。可自测:看SpringBootApplication.java的@ComponentScan(excludeFilters = ...)部分。
5.3 AnnotationCustomizableTypeExcludeFilter:把注解属性翻译成过滤规则
java
@Override
public boolean match(MetadataReader metadataReader, MetadataReaderFactory metadataReaderFactory)
throws IOException {
if (hasAnnotation()) {
return !(include(metadataReader, metadataReaderFactory) && !exclude(metadataReader, metadataReaderFactory));
}
return false;
}
语义:没被包含 → 排除;被包含但也在排除列表 → 排除;否则保留 。 TypeFilter.match 返回 true 表示"排除"(这个反向语义容易读错)。
include 的两级:
java
protected boolean include(MetadataReader metadataReader, MetadataReaderFactory metadataReaderFactory)
throws IOException {
if (new FilterAnnotations(this.classLoader, getFilters(FilterType.INCLUDE)).anyMatches(metadataReader,
metadataReaderFactory)) {
return true;
}
return isUseDefaultFilters() && defaultInclude(metadataReader, metadataReaderFactory);
}
protected boolean defaultInclude(MetadataReader metadataReader, MetadataReaderFactory metadataReaderFactory)
throws IOException {
for (Class<?> include : getDefaultIncludes()) {
if (isTypeOrAnnotated(metadataReader, metadataReaderFactory, include)) {
return true;
}
}
for (Class<?> component : getComponentIncludes()) {
if (isTypeOrAnnotated(metadataReader, metadataReaderFactory, component)) {
return true;
}
}
return false;
}
- 显式
includeFilters优先,命中就包含。 - 否则看
useDefaultFilters(),为 true 才走"默认包含"逻辑。 - 默认包含查两张表:
getDefaultIncludes()(类型白名单,如@Controller)与getComponentIncludes()(具体类白名单,如@WebMvcTest(MyController.class)指定的类)。
isTypeOrAnnotated 同时试两种匹配:
java
protected final boolean isTypeOrAnnotated(MetadataReader metadataReader,
MetadataReaderFactory metadataReaderFactory, Class<?> type) throws IOException {
AnnotationTypeFilter annotationFilter = new AnnotationTypeFilter((Class<? extends Annotation>) type);
AssignableTypeFilter typeFilter = new AssignableTypeFilter(type);
return annotationFilter.match(metadataReader, metadataReaderFactory)
|| typeFilter.match(metadataReader, metadataReaderFactory);
}
所以白名单里的条目既可以是注解也可以是类型 : 写 Controller.class → 匹配 @Controller 标注的类; 写 WebMvcConfigurer.class → 匹配实现该接口的类。 一张表两种语义,用 Class 无法区分,靠运行时两个 filter 各试一次。
equals/hashCode 必须把所有抽象方法的结果纳入比较------hasAnnotation()、 两种 getFilters(...)、isUseDefaultFilters()、getDefaultIncludes()、getComponentIncludes() 逐项 nullSafeEquals,hashCode 用 31 * result + ... 累加。 这满足了 §5.2 基类强加的"必须实现相等语义"的要求: 两个过滤规则不同的测试不能共享上下文 ,否则 @WebMvcTest(A.class) 与 @WebMvcTest(B.class) 会拿到同一个上下文。
5.4 StandardAnnotationCustomizableTypeExcludeFilter:约定优于反射
java
public abstract class StandardAnnotationCustomizableTypeExcludeFilter<A extends Annotation>
extends AnnotationCustomizableTypeExcludeFilter {
private static final Filter[] NO_FILTERS = {};
private static final String[] FILTER_TYPE_ATTRIBUTES;
static {
FilterType[] filterValues = FilterType.values();
FILTER_TYPE_ATTRIBUTES = new String[filterValues.length];
for (int i = 0; i < filterValues.length; i++) {
FILTER_TYPE_ATTRIBUTES[i] = filterValues[i].name().toLowerCase(Locale.ROOT) + "Filters";
}
}
private final MergedAnnotation<A> annotation;
protected StandardAnnotationCustomizableTypeExcludeFilter(Class<?> testClass) {
this.annotation = MergedAnnotations.from(testClass, SearchStrategy.INHERITED_ANNOTATIONS)
.get(getAnnotationType());
}
@Override
protected Filter[] getFilters(FilterType type) {
return this.annotation.getValue(FILTER_TYPE_ATTRIBUTES[type.ordinal()], Filter[].class).orElse(NO_FILTERS);
}
@Override
protected boolean isUseDefaultFilters() {
return this.annotation.getValue("useDefaultFilters", Boolean.class).orElse(false);
}
}
FILTER_TYPE_ATTRIBUTES 的构造是约定驱动 的: FilterType.INCLUDE.name().toLowerCase() + "Filters" = "includeFilters", EXCLUDE → "excludeFilters"。 于是 getFilters(type) 直接用枚举序号去查注解的同名属性。
这建立了一条约定:切片注解必须有 includeFilters / excludeFilters / useDefaultFilters 三个属性, 且名字完全对应。 好处是所有切片注解共享一套实现;代价是改名会静默失效------ getValue 找不到属性时返回 Optional.empty() → orElse(NO_FILTERS), 结果是"没有过滤器"而不是报错。
这是一个"约定失败时静默降级"的例子,与 §4.7 的
DefensiveWebApplicationContextInitializer同类。 值得争论:这里是否应该抛异常?考虑到切片注解是框架自己写的、有测试覆盖, 静默降级的风险可控。但第三方自定义切片注解时踩坑不会有任何提示。
getAnnotationType() 用 ResolvableType 解析泛型参数:
java
protected Class<A> getAnnotationType() {
ResolvableType type = ResolvableType.forClass(StandardAnnotationCustomizableTypeExcludeFilter.class,
getClass());
Class<A> generic = (Class<A>) type.resolveGeneric();
Assert.state(generic != null, "'generic' must not be null");
return generic;
}
WebMvcTypeExcludeFilter extends StandardAnnotationCustomizableTypeExcludeFilter<WebMvcTest> → 解析出 WebMvcTest.class。用泛型参数传递"我服务于哪个注解", 比构造器传参更不容易写错。
getDefaultIncludes 合两张表:
java
@Override
protected final Set<Class<?>> getDefaultIncludes() {
Set<Class<?>> defaultIncludes = new HashSet<>();
defaultIncludes.addAll(getKnownIncludes());
defaultIncludes.addAll(TypeIncludes.load(this.annotation.getType(), getClass().getClassLoader()).getIncludes());
return defaultIncludes;
}
第二张表来自 SPI(§5.6)。
5.5 WebMvcTypeExcludeFilter 实例
java
class WebMvcTypeExcludeFilter extends StandardAnnotationCustomizableTypeExcludeFilter<WebMvcTest> {
private static final Class<?>[] NO_CONTROLLERS = {};
private static final String[] OPTIONAL_INCLUDES = { "tools.jackson.databind.JacksonModule",
"org.springframework.boot.jackson.JacksonComponent", "org.thymeleaf.dialect.IDialect",
"com.fasterxml.jackson.databind.Module", "org.springframework.boot.jackson2.JsonComponent" };
private static final Set<Class<?>> KNOWN_INCLUDES;
static {
Set<Class<?>> includes = new LinkedHashSet<>();
includes.add(ControllerAdvice.class);
includes.add(WebMvcConfigurer.class);
includes.add(WebMvcRegistrations.class);
includes.add(jakarta.servlet.Filter.class);
includes.add(FilterRegistrationBean.class);
includes.add(DelegatingFilterProxyRegistrationBean.class);
includes.add(HandlerMethodArgumentResolver.class);
includes.add(HttpMessageConverter.class);
includes.add(ErrorAttributes.class);
includes.add(Converter.class);
includes.add(GenericConverter.class);
includes.add(HandlerInterceptor.class);
for (String optionalInclude : OPTIONAL_INCLUDES) {
try {
includes.add(ClassUtils.forName(optionalInclude, null));
}
catch (Exception ex) {
// Ignore
}
}
KNOWN_INCLUDES = Collections.unmodifiableSet(includes);
}
private static final Set<Class<?>> KNOWN_INCLUDES_AND_CONTROLLER;
static {
Set<Class<?>> includes = new LinkedHashSet<>(KNOWN_INCLUDES);
includes.add(Controller.class);
KNOWN_INCLUDES_AND_CONTROLLER = Collections.unmodifiableSet(includes);
}
private final Class<?>[] controllers;
WebMvcTypeExcludeFilter(Class<?> testClass) {
super(testClass);
this.controllers = getAnnotation().getValue("controllers", Class[].class).orElse(NO_CONTROLLERS);
}
@Override
protected Set<Class<?>> getKnownIncludes() {
if (ObjectUtils.isEmpty(this.controllers)) {
return KNOWN_INCLUDES_AND_CONTROLLER;
}
return KNOWN_INCLUDES;
}
@Override
protected Set<Class<?>> getComponentIncludes() {
return new LinkedHashSet<>(Arrays.asList(this.controllers));
}
}
四个设计点:
OPTIONAL_INCLUDES用字符串类名而不是Class字面量 : Jackson 3 的JacksonModule、Thymeleaf 的IDialect、Jackson 2 的Module都是可选依赖 。写Class字面量会导致这个 filter 类本身加载失败 (第 28 章第七十七定律的又一次出现)。用ClassUtils.forName+catch忽略。- 两张静态表 :
KNOWN_INCLUDES与KNOWN_INCLUDES_AND_CONTROLLER(多一个Controller)。 用哪张取决于是否显式指定了controllers。 getKnownIncludes()的语义 :指定controllers时,默认的@Controller不再被包含 ------因为你要的只是那几个 controller。这正是@WebMvcTest(MyController.class)比@WebMvcTest更严格的原因。getComponentIncludes()把controllers作为具体类白名单 返回, 于是isTypeOrAnnotated会用AssignableTypeFilter匹配它们及其子类。
注意 KMNOWN_INCLUDES 里既有注解 (ControllerAdvice)又有接口 (WebMvcConfigurer)------ 正好对应 §5.3 的 isTypeOrAnnotated 双匹配。
5.6 TypeIncludes:SPI 扩展点,让第三方往切片里加东西
java
final class TypeIncludes implements Iterable<Class<?>> {
private static final String LOCATION = "META-INF/spring/%s.includes";
private static final String COMMENT_START = "#";
static TypeIncludes load(Class<?> annotation, @Nullable ClassLoader classLoader) {
Assert.notNull(annotation, "'annotation' must not be null");
ClassLoader classLoaderToUse = decideClassloader(classLoader);
String location = String.format(LOCATION, annotation.getName());
Enumeration<URL> urls = findUrlsInClasspath(classLoaderToUse, location);
Set<Class<?>> includes = new HashSet<>();
while (urls.hasMoreElements()) {
URL url = urls.nextElement();
includes.addAll(loadIncludes(url, classLoader));
}
return new TypeIncludes(includes);
}
}
文件格式:META-INF/spring/<注解全限定名>.includes,每行一个类名,支持 # 注释。
用途是什么?让第三方库往标准切片里加自己的组件类型。 比如你写了一个框架,提供了自定义的 MVC 基础设施类,希望 @WebMvcTest 也能扫到它------ 就在自己的 jar 里放一个 META-INF/spring/org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest.includes。
这与第 3 章讲的 AutoConfiguration.imports 是同一个套路: 按全限定名命名资源文件,多个 jar 贡献同名文件,classpath 上全部读取合并。
加载失败的处理:
java
catch (Exception ex) {
logger.debug(LogMessage.format("Include '%s' declared in '%s' could not be loaded and has been ignored",
includeName, url), ex);
}
加载不到就忽略,只记 debug 日志。 因为 includes 文件里的类必然是可选的 (否则就该写进 KNOWN_INCLUDES)。
5.7 第三件:@ImportAutoConfiguration 与按注解命名的 imports 文件
切片注解上有 @ImportAutoConfiguration,它让 Spring Boot 去读 META-INF/spring/<注解全限定名>.imports,把里面列的自动配置类导入。
看实际文件(spring-boot-webmvc-test):
bash
META-INF/spring/org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc.imports
org.springframework.boot.webmvc.test.autoconfigure.MockMvcAutoConfiguration
org.springframework.boot.webmvc.test.autoconfigure.MockMvcWebClientAutoConfiguration
org.springframework.boot.webmvc.test.autoconfigure.MockMvcWebDriverAutoConfiguration
java
@Target({ ElementType.TYPE, ElementType.METHOD })
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@ImportAutoConfiguration
@PropertyMapping("spring.test.mockmvc")
public @interface AutoConfigureMockMvc {
boolean addFilters() default true;
@PropertyMapping(skip = Skip.ON_DEFAULT_VALUE)
MockMvcPrint print() default MockMvcPrint.DEFAULT;
boolean printOnlyOnFailure() default true;
@PropertyMapping("htmlunit")
HtmlUnit htmlUnit() default @HtmlUnit;
@Target({ ElementType.TYPE, ElementType.METHOD })
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@interface HtmlUnit {
String url() default "http://localhost";
@PropertyMapping("webclient.enabled")
boolean webClient() default true;
@PropertyMapping("webdriver.enabled")
boolean webDriver() default true;
}
}
关键机制 :spring-boot-security-test 模块里有同名文件:
bash
META-INF/spring/org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc.imports
org.springframework.boot.security.autoconfigure.SecurityAutoConfiguration
org.springframework.boot.security.autoconfigure.UserDetailsServiceAutoConfiguration
org.springframework.boot.security.autoconfigure.web.servlet.SecurityFilterAutoConfiguration
org.springframework.boot.security.autoconfigure.web.servlet.ServletWebSecurityAutoConfiguration
org.springframework.boot.security.test.autoconfigure.webmvc.SecurityMockMvcAutoConfiguration
这就是"只要加了 spring-boot-security-test 依赖,@WebMvcTest 就自动带上 Spring Security"的实现 。 不需要任何条件注解,纯粹靠 classpath 上有这个 jar 就有这份 imports 文件。 同理 spring-boot-security-oauth2-client 贡献 WebMvcTest.imports(注意是注解名不是 AutoConfigureMockMvc------切片注解自己也可以有 imports 文件)。
这是一个非常干净的可扩展设计:切片 = 一组 imports 文件的集合,谁都能贡献。
@PropertyMapping 是配套的另一个机制:它把注解属性映射成属性名 (spring.test.mockmvc.print 等),这样属性也能被 application.properties 覆盖。 Skip.ON_DEFAULT_VALUE 表示"注解属性是默认值时不写属性"------ 避免默认值覆盖掉用户显式配置的属性(第 15 章条件注解那里见过同款取舍)。
5.8 TestSliceTestContextBootstrapper:切片 bootstraper 的基类
java
public abstract class TestSliceTestContextBootstrapper<T extends Annotation> extends SpringBootTestContextBootstrapper {
private final Class<T> annotationType;
@SuppressWarnings("unchecked")
protected TestSliceTestContextBootstrapper() {
Class<T> annotationType = (Class<T>) ResolvableType.forClass(getClass())
.as(TestSliceTestContextBootstrapper.class)
.getGeneric(0)
.resolve();
Assert.notNull(annotationType, "'%s' doesn't contain type parameter of '%s'".formatted(getClass().getName(),
TestSliceTestContextBootstrapper.class.getName()));
this.annotationType = annotationType;
}
@Override
protected String @Nullable [] getProperties(Class<?> testClass) {
MergedAnnotation<T> annotation = MergedAnnotations.search(SearchStrategy.TYPE_HIERARCHY)
.withEnclosingClasses(TestContextAnnotationUtils::searchEnclosingClass)
.from(testClass)
.get(this.annotationType);
return annotation.isPresent() ? annotation.getStringArray("properties") : null;
}
}
它只覆写了 getProperties 一个方法------因为切片注解也有 properties 属性 (@WebMvcTest(properties = "...")),需要被提取进属性列表。
其余全部继承 SpringBootTestContextBootstrapper: 配置类搜索、differentiator、SpringBootTestAnnotation customizer 一应俱全。 切片测试本质上就是"关了自动配置 + 限制扫描 + 导特定 imports"的 @SpringBootTest。
withEnclosingClasses(TestContextAnnotationUtils::searchEnclosingClass) 处理 @Nested 内部类测试------注解可能在外层类上。
5.9 @DataJpaTest 与 @AutoConfigureTestDatabase
数据类切片多一件事:替换 DataSource。
java
@BootstrapWith(DataJpaTestContextBootstrapper.class)
@ExtendWith(SpringExtension.class)
@OverrideAutoConfiguration(enabled = false)
@TypeExcludeFilters(DataJpaTypeExcludeFilter.class)
@Transactional
@AutoConfigureDataJpa
@AutoConfigureJdbc
@AutoConfigureTestDatabase
@AutoConfigureTestEntityManager
@ImportAutoConfiguration
public @interface DataJpaTest {
String[] properties() default {};
@PropertyMapping("spring.jpa.show-sql")
boolean showSql() default true;
@PropertyMapping("spring.data.jpa.repositories.bootstrap-mode")
BootstrapMode bootstrapMode() default BootstrapMode.DEFAULT;
}
注意 @Transactional 直接在注解上------@DataJpaTest 自带事务与回滚。
AutoConfigureTestDatabase 的 Replace 枚举定义了"什么算测试数据库":
java
enum Replace {
/**
* Replace the DataSource bean unless it is auto-configured and connecting to a
* test database. The following types of connections are considered test
* databases:
* <ul>
* <li>Any bean definition that includes {@link ContainerImageMetadata} (including
* {@code @ServiceConnection} annotated Testcontainers databases, and connections
* created using Docker Compose)</li>
* <li>Any connection configured using a {@code spring.datasource.url} backed by a
* {@link DynamicPropertySource @DynamicPropertySource}</li>
* <li>Any connection configured using a {@code spring.datasource.url} with the
* Testcontainers JDBC syntax</li>
* </ul>
*/
NON_TEST,
...
}
这段 Javadoc 非常重要,它解释了 Testcontainers 与 Docker Compose 如何与切片测试协作 : 它们创建的 DataSource bean 带有 ContainerImageMetadata(第 6 章讲过这个元数据类, ContainerFieldsImporter.registerBeanDefinition 里就有 containerMetadata.addTo(beanDefinition)), 于是 @AutoConfigureTestDatabase(replace = NON_TEST) 不会把它换成嵌入式数据库。
一个 bean definition 的 attribute,就成了"是不是测试数据库"的判定依据。 这与第 28 章 §5.4 讲的"BindMethod 存进 bean definition attribute 供 AOT 复用"是同一手法。
待核实 :
TestDatabaseAutoConfiguration如何读取ContainerImageMetadata并决定不替换, 本轮未读取。可自测:写一个@DataJpaTest+@ServiceConnectionPostgreSQL 容器的测试, 断言DataSource的 URL 指向容器而非 H2。
6. Testcontainers:把容器变成"会自己启动和关闭的 bean"
Testcontainers 支持要解决三个问题: 容器怎么变成 bean 、容器什么时候启动 、容器的连接信息怎么传给自动配置。
6.1 声明方式:静态字段 + @ServiceConnection
java
@TestConfiguration(proxyBeanMethods = false)
class MyContainers {
@Bean
@ServiceConnection
PostgreSQLContainer<?> postgres() {
return new PostgreSQLContainer<>("postgres:16");
}
}
或者更简单的字段形式:
java
@SpringBootTest
class MyTests {
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16");
}
两条路的差别在于"容器何时可用"(§6.4 会解释为什么 @Bean 方法拿不到 image name)。
6.2 ContainerConnectionSource:连接信息的统一载体
无论字段还是 @Bean 方法,最终都变成一个 ContainerConnectionSource: 持有 origin、containerType、containerImageName、connectionName、 connectionDetailsTypes、containerSupplier、sslBundleSource、annotations (并像第 28 章那样实现 OriginProvider,让错误信息能指回声明处)。
connectionName 的推断:
java
private static @Nullable String getOrDeduceConnectionName(@Nullable String connectionName,
@Nullable String containerImageName) {
if (StringUtils.hasText(connectionName)) {
return connectionName;
}
if (StringUtils.hasText(containerImageName)) {
DockerImageName imageName = DockerImageName.parse(containerImageName);
imageName.assertValid();
return imageName.getRepository();
}
return null;
}
没写 name 就用镜像名的 repository 部分 (postgres:16 → postgres)。 这就是"为什么 @ServiceConnection 通常不用写参数"。
accepts 三重匹配:
java
public boolean accepts(@Nullable String requiredConnectionName, Class<?> requiredContainerType,
Class<?> requiredConnectionDetailsType) {
if (StringUtils.hasText(requiredConnectionName)
&& !requiredConnectionName.equalsIgnoreCase(this.connectionName)) {
return false;
}
if (!requiredContainerType.isAssignableFrom(this.containerType)) {
return false;
}
if (!this.connectionDetailsTypes.isEmpty() && this.connectionDetailsTypes.stream()
.noneMatch((candidate) -> candidate.isAssignableFrom(requiredConnectionDetailsType))) {
return false;
}
return true;
}
三个维度:连接名 (大小写不敏感)、容器类型 、ConnectionDetails 类型 。 type() 属性为空表示"不限制"。
每一次不匹配的失败都用 logger.trace 记录原因------ 调试"为什么我的容器没被识别"时打开 trace 日志就能看到三个维度各自的结果。 在匹配逻辑里记录拒绝原因,是这类"多路选择"代码的良好实践。
6.3 ContainerConnectionDetailsFactory:从容器到 ConnectionDetails
java
public abstract class ContainerConnectionDetailsFactory<C extends Container<?>, D extends ConnectionDetails>
implements ConnectionDetailsFactory<ContainerConnectionSource<C>, D> {
protected static final @Nullable String ANY_CONNECTION_NAME = null;
private final List<String> connectionNames;
private final String[] requiredClassNames;
protected ContainerConnectionDetailsFactory() {
this(ANY_CONNECTION_NAME);
}
protected ContainerConnectionDetailsFactory(@Nullable String connectionName, String... requiredClassNames) {
this(Arrays.asList(connectionName), requiredClassNames);
}
protected ContainerConnectionDetailsFactory(List<String> connectionNames, String... requiredClassNames) {
Assert.notEmpty(connectionNames, "'connectionNames' must not be empty");
this.connectionNames = connectionNames;
this.requiredClassNames = requiredClassNames;
}
@Override
public final @Nullable D getConnectionDetails(ContainerConnectionSource<C> source) {
if (!hasRequiredClasses()) {
return null;
}
...
}
}
ANY_CONNECTION_NAME = null的价值在可读性 :super(null)看不出意图。requiredClassNames是可选依赖守卫 :驱动类不在 classpath 时返回null而非抛异常 (PostgreSQL 工厂要求org.postgresql.Driver存在)。getConnectionDetails是final------模板方法模式:匹配逻辑固定,转换逻辑可变。connectionNames是List(3.4.0 起):一个工厂可支持多种连接名,比如同时接受mariadb与mysql。
6.4 ServiceConnectionContextCustomizerFactory:字段形式的收集
java
class ServiceConnectionContextCustomizerFactory implements ContextCustomizerFactory {
@Override
public ContextCustomizer createContextCustomizer(Class<?> testClass,
List<ContextConfigurationAttributes> configAttributes) {
List<ContainerConnectionSource<?>> sources = new ArrayList<>();
collectSources(testClass, sources);
return new ServiceConnectionContextCustomizer(sources);
}
private void collectSources(@Nullable Class<?> candidate, List<ContainerConnectionSource<?>> sources) {
if (candidate == Object.class || candidate == null) {
return;
}
ReflectionUtils.doWithLocalFields(candidate, (field) -> {
MergedAnnotations annotations = MergedAnnotations.from(field);
annotations.stream(ServiceConnection.class)
.forEach((serviceConnection) -> sources.add(createSource(field, annotations, serviceConnection)));
});
if (TestContextAnnotationUtils.searchEnclosingClass(candidate)) {
collectSources(candidate.getEnclosingClass(), sources);
}
for (Class<?> implementedInterface : candidate.getInterfaces()) {
collectSources(implementedInterface, sources);
}
collectSources(candidate.getSuperclass(), sources);
}
}
递归收集四类位置:本类字段 → 外层类(@Nested)→ 实现的接口 → 父类。用 doWithLocalFields (只处理本类声明的字段)靠递归覆盖继承链------与第 28 章 §7.6 遍历 Model 字段是同一手法。
createSource 里两处值得注意:
java
Assert.state(Modifier.isStatic(field.getModifiers()),
() -> "@ServiceConnection field '%s' must be static".formatted(field.getName()));
必须 static 。因为 ContextCustomizerFactory 在测试实例创建之前就运行, 拿不到实例字段。
java
// container.getDockerImageName() fails if there is no running docker environment
// When running tests that doesn't matter, but running AOT processing should be
// possible without a Docker environment
String dockerImageName = isAotProcessingInProgress() ? null : container.getDockerImageName();
AOT 处理时不取 image name ------因为那时没有 Docker 环境,调用会失败。 代价是 AOT 阶段生成的 connectionName 为 null(无法按名字匹配)。 注释明确说明了取舍:AOT 处理必须能在没有 Docker 的机器上跑。
这解释了 §6.1 提到的"@Bean 方法拿不到 image name": @Bean 方法形式的容器在 bean 定义阶段还没有实例, ServiceConnectionContextCustomizerFactory 只能处理字段。 所以 @Bean 方法形式的 @ServiceConnection 必须显式写 name ------ServiceConnection 注解的 Javadoc 里也强调了这一点。
6.5 生命周期:一个 initializer + 一个 BFPP + 一个 BPP
java
public class TestcontainersLifecycleApplicationContextInitializer
implements ApplicationContextInitializer<ConfigurableApplicationContext> {
private static final Set<ConfigurableApplicationContext> applied = Collections.newSetFromMap(new WeakHashMap<>());
@Override
public void initialize(ConfigurableApplicationContext applicationContext) {
synchronized (applied) {
if (!applied.add(applicationContext)) {
return;
}
}
ConfigurableListableBeanFactory beanFactory = applicationContext.getBeanFactory();
applicationContext.addBeanFactoryPostProcessor(new TestcontainersLifecycleBeanFactoryPostProcessor());
TestcontainersStartup startup = TestcontainersStartup.get(applicationContext.getEnvironment());
TestcontainersLifecycleBeanPostProcessor beanPostProcessor = new TestcontainersLifecycleBeanPostProcessor(
beanFactory, startup);
beanFactory.addBeanPostProcessor(beanPostProcessor);
}
}
applied 是 WeakHashMap 支撑的 Set + 锁 ------防止同一上下文被初始化两次,同时不阻止 GC。 用弱引用集合记录"已处理"避免内存泄漏------与第 27 章 Cleaner 的 tracker 同一思路。
TestcontainersLifecycleBeanFactoryPostProcessor 做一件反直觉的事:
java
/**
* {@link BeanFactoryPostProcessor} to prevent {@link AutoCloseable} destruction calls so
* that {@link TestcontainersLifecycleBeanPostProcessor} can be smarter about which
* containers to close.
*/
@Order(Ordered.LOWEST_PRECEDENCE)
class TestcontainersLifecycleBeanFactoryPostProcessor implements BeanFactoryPostProcessor {
@Override
public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) throws BeansException {
for (String beanName : beanFactory.getBeanNamesForType(Startable.class, false, false)) {
try {
BeanDefinition beanDefinition = beanFactory.getBeanDefinition(beanName);
String destroyMethodName = beanDefinition.getDestroyMethodName();
if (destroyMethodName == null || AbstractBeanDefinition.INFER_METHOD.equals(destroyMethodName)) {
beanDefinition.setDestroyMethodName("");
}
}
catch (NoSuchBeanDefinitionException ex) {
// Ignore
}
}
}
}
先把所有 Startable bean 的 destroy 方法抹掉 (设成空字符串)。 因为容器实现了 AutoCloseable,Spring 默认会推断 close 作为 destroy 方法------ 那样每个容器都会被"各自"关闭,无法做统一决策。
抹掉之后,关闭动作统一由 TestcontainersLifecycleBeanPostProcessor 负责, 它就能"更聪明"(Javadoc 原话)------比如跳过 reused 容器。
这是"先剥夺默认行为,再统一接管"的经典套路。 @Order(LOWEST_PRECEDENCE) 保证它在其它 BFPP 之后运行,自己的修改不被覆盖。
TestcontainersLifecycleBeanPostProcessor 的核心:
java
@Override
public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
if (this.beanFactory.isConfigurationFrozen() && !isAotProcessingInProgress()) {
initializeContainers();
}
if (bean instanceof Startable startableBean) {
if (this.startables.compareAndExchange(Startables.UNSTARTED, Startables.STARTING) == Startables.UNSTARTED) {
initializeStartables(startableBean, beanName);
}
else if (this.startables.get() == Startables.STARTED) {
logger.trace(LogMessage.format("Starting container %s", beanName));
TestcontainersStartup.start(startableBean);
}
}
return bean;
}
策略是:第一个 Startable bean 出现时,把所有 Startable 一起启动。 三态机 AtomicReference<Startables>(UNSTARTED → STARTING → STARTED)用 compareAndExchange 保证只做一次。 不等到所有 bean 创建完,是因为启动容器很慢(几秒到几十秒),越早越好; 而"第一个 Startable"时 bean 定义已全部登记,可按类型查到所有容器。
initializeStartables 的失败处理:
java
List<Object> beans = getBeans(beanNames);
if (beans == null) {
logger.trace(LogMessage.format("Failed to obtain startables %s", beanNames));
this.startables.set(Startables.UNSTARTED);
return;
}
getBeans 遇到 BeanCurrentlyInCreationException 返回 null(循环依赖的信号), 此时把状态重置回 UNSTARTED ,等下一次机会。 状态机允许失败并重试,而不是卡在 STARTING。
java
private @Nullable List<Object> getBeans(List<String> beanNames) {
List<Object> beans = new ArrayList<>(beanNames.size());
for (String beanName : beanNames) {
try {
beans.add(this.beanFactory.getBean(beanName));
}
catch (BeanCreationException ex) {
if (ex.contains(BeanCurrentlyInCreationException.class)) {
return null;
}
throw ex;
}
}
return beans;
}
ex.contains(BeanCurrentlyInCreationException.class) 检查异常链 里有没有循环依赖异常------ 因为 getBean 抛的可能是包装过的异常。
关闭逻辑:
java
@Override
public boolean requiresDestruction(Object bean) {
return bean instanceof Startable;
}
@Override
public void postProcessBeforeDestruction(Object bean, String beanName) throws BeansException {
if (bean instanceof Startable startable && !isDestroyedByFramework(beanName) && !isReusedContainer(bean)) {
startable.close();
}
}
private boolean isDestroyedByFramework(String beanName) {
try {
BeanDefinition beanDefinition = this.beanFactory.getBeanDefinition(beanName);
String destroyMethodName = beanDefinition.getDestroyMethodName();
return !"".equals(destroyMethodName);
}
catch (NoSuchBeanDefinitionException ex) {
return false;
}
}
private boolean isReusedContainer(Object bean) {
return (bean instanceof GenericContainer<?> container) && container.isShouldBeReused()
&& TestcontainersConfiguration.getInstance().environmentSupportsReuse();
}
两个不关闭的条件:
- 框架已经接管了销毁 (
destroyMethodName不是空字符串------即 BFPP 没抹掉它, 说明用户显式配了 destroy 方法)。 - 可复用容器(Testcontainers 的 reuse 特性,容器在测试之间保留)。
第 1 条的判断方式很巧妙:BFPP 把 destroy 设成 "" 当作"我接管了"的标记 。 用一个特殊值而不是单独的标记字段------第 27 章的 UNPACK 注释、NO_MANIFEST 哨兵都是同族。
6.6 TestcontainersStartup:顺序还是并行
java
public enum TestcontainersStartup {
SEQUENTIAL {
@Override
void start(Collection<? extends Startable> startables) {
startables.forEach(TestcontainersStartup::start);
}
},
PARALLEL {
@Override
void start(Collection<? extends Startable> startables) {
SingleStartables singleStartables = new SingleStartables();
Startables.deepStart(startables.stream().map(singleStartables::getOrCreate)).join();
}
};
public static final String PROPERTY = "spring.testcontainers.beans.startup";
}
默认 SEQUENTIAL,用 spring.testcontainers.beans.startup=parallel 切换。
解析用"规范化 + 忽略大小写":
java
private static String getCanonicalName(String name) {
StringBuilder canonicalName = new StringBuilder(name.length());
name.chars()
.filter(Character::isLetterOrDigit)
.map(Character::toLowerCase)
.forEach((c) -> canonicalName.append((char) c));
return canonicalName.toString();
}
过滤掉所有非字母数字字符------于是 parallel、PARALLEL、par-allel 都能识别。 宽松解析 :这是配置属性解析的常见做法(第 8 章 Binder 的 RelaxedNames 是系统化的版本)。
start 的幂等处理:
java
public static void start(Startable startable) {
if (!isRunning(startable)) {
startable.start();
}
}
private static boolean isRunning(Startable startable) {
try {
return (startable instanceof Container<?> container) && container.isRunning();
}
catch (Throwable ex) {
return false;
}
}
catch (Throwable) 而非 Exception------无 Docker 环境时 isRunning() 可能抛 Error 系异常。 返回 false(当作没运行)让 start() 被调用并失败,其错误信息更有诊断价值。
6.7 @ImportTestcontainers:把声明类导入上下文
java
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Import(ImportTestcontainersRegistrar.class)
@ImportAutoConfiguration(TestcontainersPropertySourceAutoConfiguration.class)
public @interface ImportTestcontainers {
Class<?>[] value() default {};
}
ContainerFieldsImporter 把静态 Container 字段注册成 bean(要求字段 static 且非 null):
java
Set<Startable> registerBeanDefinitions(BeanDefinitionRegistry registry, Class<?> definitionClass) {
Set<Startable> importedContainers = new HashSet<>();
for (Field field : getContainerFields(definitionClass)) {
assertValid(field);
Container<?> container = getContainer(field);
if (container instanceof Startable startable) {
importedContainers.add(startable);
}
registerBeanDefinition(registry, field, container);
}
return importedContainers;
}
private void registerBeanDefinition(BeanDefinitionRegistry registry, Field field, Container<?> container) {
ContainerImageMetadata containerMetadata = new ContainerImageMetadata(container.getDockerImageName());
TestcontainerFieldBeanDefinition beanDefinition = new TestcontainerFieldBeanDefinition(field, container);
containerMetadata.addTo(beanDefinition);
String beanName = generateBeanName(field);
registry.registerBeanDefinition(beanName, beanDefinition);
}
private String generateBeanName(Field field) {
return "importTestContainer.%s.%s".formatted(field.getDeclaringClass().getName(), field.getName());
}
containerMetadata.addTo(beanDefinition) 就是 §5.9 提到的 "给 bean definition 打上 ContainerImageMetadata"------ 于是 @AutoConfigureTestDatabase(replace = NON_TEST) 能认出它是测试数据库。 两个模块通过 bean definition attribute 协作,没有直接依赖。
bean 名用 "importTestContainer." + 类名 + "." + 字段名------ 可读、稳定、不会与用户 bean 冲突。
6.8 @DynamicPropertySource 支持
TestcontainersPropertySourceAutoConfiguration 是一个 20 行的自动配置, 只注册一个 DynamicPropertyRegistrarBeanInitializer。两个注解值得注意:
java
@AutoConfiguration
@Order(Ordered.HIGHEST_PRECEDENCE)
@ConditionalOnClass(DynamicPropertyRegistry.class)
public final class TestcontainersPropertySourceAutoConfiguration {
@Bean
@ConditionalOnMissingBean
@Role(BeanDefinition.ROLE_INFRASTRUCTURE)
static DynamicPropertyRegistrarBeanInitializer dynamicPropertyRegistrarBeanInitializer() {
return new DynamicPropertyRegistrarBeanInitializer();
}
}
@Order(HIGHEST_PRECEDENCE)------必须最先,因为 @DynamicPropertySource 提供的属性 (通常是数据库 URL)要在其它自动配置之前就位。@Role(ROLE_INFRASTRUCTURE) 标记基础设施 bean, 在 Actuator 的 beans 端点等处会被区分对待(第 25 章讲过 @Role)。
DynamicPropertySourceMethodsImporter 负责扫描 @DynamicPropertySource 方法。
待核实 :该 importer 的具体导入逻辑与 AOT hint 注册本轮未读取。 可自测:看
spring-boot-testcontainers/src/main/resources/META-INF/spring/aot.factories。
7. Docker Compose:让 docker compose up 变成启动流程的一步
Testcontainers 是测试专用 的;Docker Compose 支持则是开发期 的------ mvn spring-boot:run 时也能用。
7.1 挂载点:ApplicationPreparedEvent
java
class DockerComposeListener implements ApplicationListener<ApplicationPreparedEvent> {
private final SpringApplicationShutdownHandlers shutdownHandlers;
DockerComposeListener() {
this(SpringApplication.getShutdownHandlers());
}
@Override
public void onApplicationEvent(ApplicationPreparedEvent event) {
ConfigurableApplicationContext applicationContext = event.getApplicationContext();
Binder binder = Binder.get(applicationContext.getEnvironment());
DockerComposeProperties properties = DockerComposeProperties.get(binder);
Set<ApplicationListener<?>> eventListeners = event.getSpringApplication().getListeners();
createDockerComposeLifecycleManager(applicationContext, binder, properties, eventListeners).start();
}
}
三个要点:
- 时机是
ApplicationPreparedEvent------bean 定义已加载但未刷新。必须是 bean 定义加载之后 (否则无法替换DataSource的ConnectionDetails),又必须是刷新之前(否则 bean 已创建完)。 SpringApplication.getShutdownHandlers()注册关闭钩子,不用Runtime.getRuntime().addShutdownHook()------关闭顺序可控(第 4 章讲过它)。- 把
event.getSpringApplication().getListeners()传给 lifecycle manager,供 §7.4 手工广播用。
7.2 DockerComposeLifecycleManager.start():十步
java
void start() {
if (Boolean.getBoolean(AbstractAotProcessor.AOT_PROCESSING) || AotDetector.useGeneratedArtifacts()) {
logger.trace("Docker Compose support disabled with AOT and native images");
return;
}
if (!this.properties.isEnabled()) {
logger.trace("Docker Compose support not enabled");
return;
}
if (this.skipCheck.shouldSkip(this.classLoader, this.properties.getSkip())) {
logger.trace("Docker Compose support skipped");
return;
}
DockerComposeFile composeFile = getComposeFile();
Set<String> activeProfiles = this.properties.getProfiles().getActive();
List<String> arguments = this.properties.getArguments();
DockerCompose dockerCompose = getDockerCompose(composeFile, activeProfiles, arguments);
if (!dockerCompose.hasDefinedServices()) {
logger.warn(...);
return;
}
LifecycleManagement lifecycleManagement = this.properties.getLifecycleManagement();
Start start = this.properties.getStart();
Stop stop = this.properties.getStop();
Wait wait = this.properties.getReadiness().getWait();
List<RunningService> runningServices = dockerCompose.getRunningServices();
if (lifecycleManagement.shouldStart()) {
Skip skip = this.properties.getStart().getSkip();
if (skip.shouldSkip(runningServices)) {
logger.info(skip.getLogMessage());
}
else {
try {
start.getCommand().applyTo(dockerCompose, start.getLogLevel(), start.getArguments());
}
catch (RuntimeException ex) {
logDockerComposeLogs(dockerCompose, start);
throw ex;
}
runningServices = dockerCompose.getRunningServices();
if (wait == Wait.ONLY_IF_STARTED) {
wait = Wait.ALWAYS;
}
if (lifecycleManagement.shouldStop()) {
this.shutdownHandlers
.add(() -> stop.getCommand().applyTo(dockerCompose, stop.getTimeout(), stop.getArguments()));
}
}
}
List<RunningService> relevantServices = new ArrayList<>(runningServices);
relevantServices.removeIf(this::isIgnored);
if (wait == Wait.ALWAYS || wait == null) {
this.serviceReadinessChecks.waitUntilReady(relevantServices);
}
publishEvent(new DockerComposeServicesReadyEvent(this.applicationContext, relevantServices));
}
三个提前返回(按成本从低到高):①AOT 处理或 AOT 运行------原生镜像里没有 docker CLI, 也不该在构建机上起容器(与第 28 章 §1 的三开关表同源);②spring.docker.compose.enabled=false; ③skipCheck(§7.3)。
启动命令失败时的处理很用心:
失败时先把容器日志拉出来再抛异常 :logDockerComposeLogs 调 dockerCompose.logs(), 打印 "docker compose %s failed with the following logs:%n%n%s"; 取不到日志(retrieveLogsIfPossible 自身 catch (Exception) 返回 null)则只说"日志不可用"。 docker compose up 的失败原因(端口冲突、镜像拉取失败、语法错误)几乎只体现在日志里。 日志级别用 start.getLogLevel()(默认 INFO),用户可降级到 DEBUG。
Wait.ONLY_IF_STARTED 的处理:
java
if (wait == Wait.ONLY_IF_STARTED) {
wait = Wait.ALWAYS;
}
只有在我们自己启动了服务时才等待就绪。 如果服务本来就在跑(skip 分支), 就不做就绪检查------因为那可能是生产环境或别人手动起的。
Wait 三值:
java
public enum Wait {
ALWAYS, NEVER, ONLY_IF_STARTED
}
7.3 DockerComposeSkipCheck:靠栈追踪判断"是不是在测试里"
java
class DockerComposeSkipCheck {
private static final Set<String> REQUIRED_CLASSES = Set.of("org.junit.jupiter.api.Test", "org.junit.Test");
private static final Set<String> SKIPPED_STACK_ELEMENTS;
static {
Set<String> skipped = new LinkedHashSet<>();
skipped.add("org.junit.runners.");
skipped.add("org.junit.platform.");
skipped.add("org.springframework.boot.test.");
skipped.add(SpringApplicationAotProcessor.class.getName());
skipped.add("cucumber.runtime.");
SKIPPED_STACK_ELEMENTS = Collections.unmodifiableSet(skipped);
}
boolean shouldSkip(@Nullable ClassLoader classLoader, DockerComposeProperties.Skip properties) {
if (properties.isInTests() && hasAtLeastOneRequiredClass(classLoader)) {
Thread thread = Thread.currentThread();
for (StackTraceElement element : thread.getStackTrace()) {
if (isSkippedStackElement(element)) {
return true;
}
}
}
return false;
}
}
默认在测试中跳过 Docker Compose (Skip.inTests 默认 true)。 理由是:Docker Compose 是开发期工具,测试应该用 Testcontainers。
判定方式很有意思:先确认测试框架在 classpath 上,再扫描当前线程的栈 看有没有 JUnit / Spring Boot 测试 / Cucumber 的帧。 用 getStackTrace() 而不是 Thread.currentThread().getStackTrace()? 其实是一样的------只是这里把 thread 提出来复用。
栈扫描是一种"环境探测的最后手段" :当没有更好的信号时,调用栈是最可靠的上下文。 第 28 章 NestedFileSystemProvider.isCreatingNewFileSystem() 也用了同样的技巧。 代价是性能(栈深度通常几十到几百帧)与脆弱性(框架改包名就失效)。
SpringApplicationAotProcessor.class.getName() 也在跳过列表里------ AOT 处理时也不起 Docker ,即使它不是"测试"。 (虽然 §7.2 第一步已经用 AOT_PROCESSING 拦了,这里是双保险。)
7.4 publishEvent:为什么不能用 ApplicationContext.publishEvent
java
/**
* Publish a {@link DockerComposeServicesReadyEvent} directly to the event listeners
* since we cannot call {@link ApplicationContext#publishEvent} this early.
* @param event the event to publish
*/
private void publishEvent(DockerComposeServicesReadyEvent event) {
SimpleApplicationEventMulticaster multicaster = new SimpleApplicationEventMulticaster();
this.eventListeners.forEach(multicaster::addApplicationListener);
multicaster.multicastEvent(event);
}
新建一个临时 multicaster,把 SpringApplication 的 listeners 塞进去,直接广播。
为什么不走 applicationContext.publishEvent?因为此时是 ApplicationPreparedEvent------ 上下文还没有 multicaster (ApplicationEventMulticaster 是在刷新时初始化的), 也没有注册任何 listener bean。
所以只能"手工广播"给那些注册在 SpringApplication 上而非上下文里的 listener 。 这些 listener 里就有负责把 RunningService 转成 ConnectionDetails 的那个。
待核实 :接收
DockerComposeServicesReadyEvent的具体 listener(如何把服务转成ConnectionDetails并注册成 bean)本轮未读取。可自测:搜索DockerComposeServicesReadyEvent的ApplicationListener实现。
7.5 就绪检查:TCP 连接的三重语义
java
class ServiceReadinessChecks {
private static final String DISABLE_LABEL = "org.springframework.boot.readiness-check.disable";
private static final Duration SLEEP_BETWEEN_READINESS_TRIES = Duration.ofSeconds(1);
void waitUntilReady(List<RunningService> runningServices) {
Duration timeout = this.properties.getTimeout();
Instant start = this.clock.instant();
while (true) {
List<ServiceNotReadyException> exceptions = check(runningServices);
if (exceptions.isEmpty()) {
return;
}
Duration elapsed = Duration.between(start, this.clock.instant());
if (elapsed.compareTo(timeout) > 0) {
throw new ReadinessTimeoutException(timeout, exceptions);
}
this.sleep.accept(SLEEP_BETWEEN_READINESS_TRIES);
}
}
}
Clock 与 sleep 都是构造器注入 的------为了测试(可注入假时钟与空 sleep), 本仓库反复出现的可测试性设计(第 25 章 HealthIndicator 见过)。超时默认 2 分钟、重试间隔 1 秒; 超时抛 ReadinessTimeoutException 携带所有未就绪服务的异常------一次报告全部问题。
TcpConnectServiceReadinessCheck 的判定逻辑是本章最"接地气"的一段:
java
private void check(RunningService service, int port, Socket socket) throws IOException {
try {
// -1 indicates the socket has been closed immediately
// Other responses or a timeout are considered as success
if (socket.getInputStream().read() == -1) {
throw new ServiceNotReadyException(service,
"Immediate disconnect while connecting to port %s".formatted(port));
}
}
catch (SocketTimeoutException ex) {
// Ignore
}
}
判定规则:
- 读到 -1(立即断开) → 未就绪。
- 读到任何数据 → 就绪。
- 读超时 → 就绪(因为能连上说明端口在监听,只是不发数据)。
这个规则的实用性:大多数服务的"未就绪"表现是 TCP 能连但立即断开 (比如数据库还在初始化)。而"连上但不说话"是正常的(比如 Redis)。
catch (SocketTimeoutException) 后不抛------SocketTimeoutException 是 "就绪"的正常表现。用异常表达正常路径,是这里唯一可用的方式(因为 read() 的超时就是靠异常表达的)。
两个"关闭检查"的 label:
java
// ServiceReadinessChecks
private static final String DISABLE_LABEL = "org.springframework.boot.readiness-check.disable";
// TcpConnectServiceReadinessCheck
private static final String DISABLE_LABEL = "org.springframework.boot.readiness-check.tcp.disable";
粒度不同:前者关掉整个就绪检查,后者只关 TCP 检查。用 Docker Compose 的 label 作配置载体 ------ 用户不改 Spring 配置,直接在 compose.yaml 的服务下写 labels: { org.springframework.boot.readiness-check.tcp.disable: "" }。 label 跟着服务定义走,比放在 Spring 配置里更内聚。
7.6 属性全景
spring.docker.compose.*:
| 属性 | 默认 | 说明 |
|---|---|---|
enabled |
true |
总开关 |
arguments |
空 | 传给 compose 命令的额外参数 |
file |
空(自动查找) | compose 文件路径 |
lifecycle-management |
START_AND_STOP |
NONE / START_ONLY / START_AND_STOP |
host |
空 | Docker 主机 |
start.command |
UP |
启动命令 |
start.log-level |
INFO |
日志级别 |
start.skip |
IF_RUNNING |
NEVER / IF_RUNNING |
stop.command |
STOP |
停止命令 |
stop.timeout |
10s |
停止超时,0 表示强制 |
profiles.active |
空 | Docker Compose profiles |
skip.in-tests |
true |
测试中跳过 |
readiness.wait |
ALWAYS |
ALWAYS / NEVER / ONLY_IF_STARTED |
readiness.timeout |
2m |
就绪超时 |
readiness.tcp.connect-timeout |
200ms |
TCP 连接超时 |
readiness.tcp.read-timeout |
200ms |
TCP 读超时 |
LifecycleManagement 用两个布尔表达三态:
java
NONE(false, false), START_ONLY(true, false), START_AND_STOP(true, true);
与 §2.3 WebEnvironment 的 embedded 布尔同构------ 三态枚举用两个正交布尔表达,比三个 if 分支更不容易漏。
7.7 DockerComposeFile 的查找
java
protected DockerComposeFile getComposeFile() {
DockerComposeFile composeFile = (CollectionUtils.isEmpty(this.properties.getFile()))
? DockerComposeFile.find(this.workingDirectory) : DockerComposeFile.of(this.properties.getFile());
Assert.state(composeFile != null, () -> "No Docker Compose file found in directory '%s'".formatted(
((this.workingDirectory != null) ? this.workingDirectory : new File(".")).toPath().toAbsolutePath()));
...
}
DockerComposeFile.find(...) 按约定查找(compose.yaml / compose.yml / docker-compose.* 等, 含 .devcontainer/ 下的变体),找不到就 Assert.state 失败并打印绝对路径。
待核实 :候选文件名与目录列表的完整常量本轮未读取。可自测:读
DockerComposeFile.java。
8. 其它值得知道的测试基础设施
8.1 ConfigDataApplicationContextInitializer:只加载配置,不启动应用
java
@Override
public void initialize(ConfigurableApplicationContext applicationContext) {
ConfigurableEnvironment environment = applicationContext.getEnvironment();
RandomValuePropertySource.addToEnvironment(environment);
DefaultBootstrapContext bootstrapContext = new DefaultBootstrapContext();
ConfigDataEnvironmentPostProcessor.applyTo(environment, applicationContext, bootstrapContext);
bootstrapContext.close(applicationContext);
DefaultPropertiesPropertySource.moveToEnd(environment);
}
四步:加 random 属性源 → 跑 ConfigData → 关 bootstrap 上下文 → 把 defaultProperties 移到最后。
用途:配合 @ContextConfiguration(classes = MyConfig.class, initializers = ConfigDataApplicationContextInitializer.class) 在不启动整个 Spring Boot 应用的情况下测试配置绑定。 第 7 章讲的 ConfigData 全流程在这里被单独复用。
DefaultPropertiesPropertySource.moveToEnd(environment) 这一步容易被忽略: defaultProperties 应该是优先级最低的,但 ConfigData 处理可能改变顺序,所以要显式归位。
8.2 FilteredClassLoader:测试"某类不存在"的场景
java
public class FilteredClassLoader extends URLClassLoader implements SmartClassLoader {
private final Collection<Predicate<String>> classesFilters;
private final Collection<Predicate<String>> resourcesFilters;
public FilteredClassLoader(Class<?>... hiddenClasses) { ... }
public FilteredClassLoader(String... hiddenPackages) { ... }
}
它实现 SmartClassLoader------Spring 的这个接口允许"类可见性"被动态改变, 配合 ClassUtils.isPresent / ClassPathScanningCandidateComponentProvider 使用。
典型用法(本仓库自己的测试里大量出现):
java
new ApplicationContextRunner()
.withClassLoader(new FilteredClassLoader(SomeOptionalClass.class))
.withUserConfiguration(MyConfig.class)
.run((context) -> assertThat(context).doesNotHaveBean(MyBean.class));
这是测试条件注解的负分支的正规手段(第 15 章讲条件注解时反复用到)。 没有它,"类不存在时自动配置不生效"这条逻辑没法测。
8.3 输出捕获
org.springframework.boot.test.system 包:
OutputCapture(JUnit 5 扩展,通过@ExtendWith(OutputCaptureExtension.class)或@RegisterExtension)CapturedOutput(注入到测试方法参数的接口)OutputCaptureRule(JUnit 4)
它替换 System.out / System.err 并把输出同时送到日志, 因此 Logback 控制台 appender 的输出也能被捕获(与第 24 章的日志体系相关)。
待核实 :替换的具体时机与恢复逻辑未读取,可自测其
beforeEach/afterEach。
8.4 TestPropertyValues:测试里改属性的统一入口
org.springframework.boot.test.util.TestPropertyValues 在本章出现了四次:
- §5.1 关自动配置
- §4.6 写
spring.profiles.active[i] - §8.1(间接)
它支持三种 Type:
java
Type.MAP // 加到 MapPropertySource
Type.SYSTEM_ENVIRONMENT
Type.VALUE // 直接 setProperty
applyTo(environment, Type.MAP, "active-test-profiles") 里的第三个参数是 property source 名 ------ 命名属性源便于调试时定位,这是本仓库的一贯做法(第 6 章讲过每个内置属性源都有名字)。
9. 决策速查表
| 你想做什么 | 用什么 | 关键实现 |
|---|---|---|
| 启动完整应用做测试 | @SpringBootTest |
SpringBootContextLoader 跑 SpringApplication |
让测试走 main 方法 |
useMainMethod = ALWAYS/WHEN_AVAILABLE |
ContextLoaderHook.starting() 里配置 |
| 只测 Web 层 | @WebMvcTest |
关自动配置 + WebMvcTypeExcludeFilter + AutoConfigureMockMvc.imports |
| 只测持久层 | @DataJpaTest |
额外 @AutoConfigureTestDatabase 换数据源 |
| 让切片扫到我自己的组件类型 | META-INF/spring/<切片注解全名>.includes |
TypeIncludes.load |
| 让第三方自动配置进入切片 | META-INF/spring/<@AutoConfigureXxx 全名>.imports |
@ImportAutoConfiguration |
| 用真实数据库做测试 | @ServiceConnection 容器字段或 @Bean |
ContainerConnectionSource → ConnectionDetails |
| 测试里控制容器启停 | spring.testcontainers.beans.startup |
TestcontainersStartup |
| 开发期起依赖服务 | compose.yaml + spring-boot-docker-compose |
DockerComposeLifecycleManager |
| 让某个服务不做就绪检查 | compose label ...readiness-check.disable |
ServiceReadinessChecks.isDisabled |
| 测试"类不存在"的分支 | FilteredClassLoader |
实现 SmartClassLoader |
| 只测配置绑定 | ConfigDataApplicationContextInitializer |
复用 ConfigDataEnvironmentPostProcessor |
10. 从本章提炼的定律
第八十一定律:无法静态配置的规则,做成 bean 去查。 @ComponentScan 的 excludeFilters 是静态的,于是 TypeExcludeFilter 把自己变成一个 "从 BeanFactory 里找所有同类 bean 并逐个问"的委托(§5.2)。 框架留的静态扩展点不够用时,用一个间接层把它变成动态的。
第八十二定律:基类无法给出相等语义时,用抛异常强制子类实现。 TypeExcludeFilter.equals/hashCode 直接 throw new IllegalStateException(§5.2)------ 因为过滤规则不同的两个测试不能共享上下文 ,写错会导致极难排查的缓存串味。 宁可启动即失败,不要静默错误。
第八十三定律:要统一接管某个行为,先剥夺默认行为。 TestcontainersLifecycleBeanFactoryPostProcessor 先把所有 Startable 的 destroy 方法抹成 "", 再由 BPP 统一决定关不关(§6.5)。抹成空字符串同时充当"我已接管"的标记。
第八十四定律:"什么都不做"用空实现表达,不用 null。 ALREADY_CONFIGURED 是空 lambda(§4.2);SpringBootTestAnnotation.DEFAULT 是零值三元组(§3.7); DisableAutoConfigurationContextCustomizer 无状态所以同类型即相等(§5.1)。 消除判空分支,代价是多几个零值对象。
第八十五定律:三态枚举用两个正交布尔表达。 WebEnvironment 的 embedded(§2.3)与 LifecycleManagement 的 start/stop(§7.6)。 消费方只问布尔,于是未来的新枚举值不会漏判。
第八十六定律:环境探测的最后手段是栈扫描,但必须配 classpath 前置检查。 DockerComposeSkipCheck 先确认 JUnit 在 classpath 上,再扫 getStackTrace()(§7.3)。 两道条件缺一不可:只看 classpath 会误伤(生产里可能也有 junit jar),只扫栈则开销大。
第八十七定律:能力边界要显式拒绝,不要静默降级。 UseMainMethod 遇到 @ContextHierarchy 直接 Assert.state 失败(§4.3); @WebAppConfiguration 与真实端口模式冲突直接抛 IllegalStateException(§2.3)。 "做不到"和"做不到但假装做到了",后者才是灾难。
第八十八定律:白名单里的可选条目用字符串类名 + try/catch 加载。 OPTIONAL_INCLUDES(§5.5)用 ClassUtils.forName + catch (Exception) { }, 因为 Jackson 3 / Jackson 2 / Thymeleaf 都是可选依赖。第 28 章第七十七定律的重申。
第八十九定律:失败信息要自包含------先把上下文捞出来再抛。 docker compose up 失败时先 dockerCompose.logs() 再抛(§7.2); ContextLoadException 携带失败的上下文(§4.8)。 错误发生的瞬间是唯一能拿到完整信息的时刻。
第九十定律:约定驱动的属性名要写清代价。 FILTER_TYPE_ATTRIBUTES 由 FilterType.name().toLowerCase() + "Filters" 派生(§5.4), 一行代码服务所有切片注解;代价是属性改名会静默降级成"没有过滤器"。 约定越隐式,越要在注释里写明失效模式。
11. 本章未逐行核实 / 待你实测的项
AotTestAttributes的持久化格式与位置(在spring-test,不在本仓库)。@SpringBootApplication是否无条件带TypeExcludeFilter排除器。TestDatabaseAutoConfiguration如何按ContainerImageMetadata决定不替换DataSource。DynamicPropertySourceMethodsImporter的导入逻辑与 AOT hint 注册。- 接收
DockerComposeServicesReadyEvent的 listener(服务 →ConnectionDetails转换)未定位。 DockerComposeFile.find的完整候选文件名与目录常量。OutputCaptureExtension替换System.out的时机与恢复逻辑。TypeExcludeFiltersContextCustomizer(Factory)如何把过滤器注册成 bean(本章只看了过滤器本身)。spring-boot-test里http/client、web/server两个包的内容未展开。- 除 JMX 外还有哪些"测试专用默认属性"。
12. 小结与下一章
本章把测试体系从注解走到容器:
@SpringBootTest不是"启动容器",而是在 TCF 的六层扩展点上各挂一个 Spring Boot 实现(§0)。- Bootstrapper 负责"算出配置" :配置类搜索、differentiator 防串味、
SpringBootTestAnnotation进缓存键、WebEnvironment翻译成上下文类型(§3)。 - Loader 负责"把跑应用变成加载上下文" :三个 Mode 共享一条路径,
ContextLoaderHook用SpringApplicationHook截获 main 方法创建的SpringApplication(§4)。 - 切片测试是三件正交的事 :关自动配置(属性)、限制扫描(
TypeExcludeFilterbean)、 导入特定 imports(按注解全名,谁都能贡献)(§5)。 - Testcontainers 把容器变成 bean ,并用"第一个
Startable出现时全部启动"+ "抹掉 destroy 再统一关闭"管理生命周期(§6)。 - Docker Compose 是开发期对应物 :挂在
ApplicationPreparedEvent, 默认在测试中跳过,就绪检查用"能连上但不立即断开"(§7)。
三条贯穿全章的开关(与第 28 章 §1 的表对应): AOT_PROCESSING(构建期录制)、AotDetector.useGeneratedArtifacts()(运行期消费)、 NativeDetector.inNativeImage()(能力判断)。 本章每一处"要不要做"的分支,都能归到这三者之一。
下一章(第 30 章)进入 基础设施能力:SSL Bundle、Task 与虚拟线程、Retry 。 本章 §6.2 已经见过 SslBundle 的身影------ContainerConnectionSource 携带 SslBundleSource,Testcontainers 可以为容器连接配置 SSL。 第 30 章会从 SslBundle 的抽象讲起:PEM / JKS 两种格式、SslBundleRegistry、 ssl.* 属性体系、证书热重载,以及 spring.threads.virtual.enabled 与 @Retryable 的自动配置如何与第 25 章讲过的 @ConditionalOnThreading 协作。