Spring Boot 4.1.1 深度源码解析-第 29 章 · 测试体系:`@SpringBootTest`、切片测试、Testcontainers

16 阅读51分钟

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. 从一行注解出发

@SpringBootTest
class MyApplicationTests {

    @Test
    void contextLoads() {
    }

}

这行注解背后发生的事情,比大多数人想象的多得多。它不是"启动一个 Spring 容器", 而是在 Spring TestContext Framework(以下简称 TCF)的六个扩展点上各挂了一个 Spring Boot 实现, 把"启动应用"这件事完整地搬进测试里。

拆开看,TCF 的标准流程是:

  1. TestContextBootstrapper 决定"用什么配置加载上下文"
  2. ContextLoader 真正去加载
  3. ContextCustomizerFactory 产生一批 ContextCustomizer,在上下文刷新前后做定制
  4. MergedContextConfiguration 作为缓存键,决定两个测试类能否共用同一个上下文
  5. TestExecutionListener 在测试方法前后做事情
  6. ApplicationListener / BeanFactoryPostProcessor 在容器内部做事情

@SpringBootTest 在这六层都留下了痕迹:

TCF 扩展点Spring Boot 的实现本章位置
@BootstrapWithSpringBootTestContextBootstrapper§3
ContextLoaderSpringBootContextLoader(同时实现 AotContextLoader)§4
ContextCustomizerSpringBootTestAnnotation、ServiceConnectionContextCustomizer 等§3.7、§6.4
缓存键MergedContextConfiguration 的 equals + differentiator 属性§3.6
TestExecutionListenerMockMvcPrintOnlyOnFailureTestExecutionListener、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 / OverrideAutoConfigurationContextCustomizerFactory
  • TestSliceTestContextBootstrapper
  • OnFailureConditionReportContextCustomizerFactory

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

一个类层次清晰地分层:

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:开发期服务编排

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 注解本身

先看注解声明(去掉注释):

@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 的内联属性。 源码注释写得很直白:

// 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 的来源

/**
 * 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 布尔

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 里有专门校验):

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

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

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 读取的属性名。

三条分支:

  1. MOCK + SERVLET → true:需要 mock servlet 环境(MockServletContext)。
  2. RANDOM_PORT / DEFINED_PORT → false:真服务器,不需要 mock。
  3. 其它(比如 NONE,或 MOCK 但应用类型是 REACTIVE)→ 不设属性,交给 TCF 默认行为。

注意第 2 个分支用的是 isEmbedded() 而不是枚举相等判断——这正是 §2.3 那个布尔存在的理由: 用一个布尔表达"是否真起服务器",比用两个枚举值更不容易遗漏未来的新枚举项。

3.2 getDefaultContextLoaderClass:一行切换

@Override
protected Class<? extends ContextLoader> getDefaultContextLoaderClass(Class<?> testClass) {
    return SpringBootContextLoader.class;
}

TCF 默认用 DelegatingSmartContextLoader(按 XML 或注解配置选 GenericXmlContextLoader / AnnotationConfigContextLoader)。换成 SpringBootContextLoader 之后, "加载上下文"就变成了"跑一次 SpringApplication"。

3.3 getOrFindConfigurationClasses:搜索 @SpringBootConfiguration

这是 @SpringBootTest 最著名的行为——自动向上找主配置类。

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 的实现值得看:

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 了主配置)。

搜索本身:

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

三个要点:

  1. AotTestAttributes 是一层缓存。AOT 处理阶段会把"测试类 → 找到的主配置类"这个映射持久化, 运行期(AOT 模式)直接读,不用再扫包。这与第 28 章讲的"把构建期的判定结论持久化"是同一条定律 (第七十四:省掉决策)。
  2. AnnotatedClassFinder 从测试类所在包逐级向上找,直到找到带 @SpringBootConfiguration 的类。 所以测试类放在与主应用不同的包下时要小心——常见错误是把测试放到 com.example.other 而主类在 com.example.app,搜索会一直向上到根包,最后找不到。
  3. 错误消息的措辞很讲究:不仅说"找不到",还列出了三条解决路径 (@ContextConfiguration、classes=...、其它机制),并明确提醒"@TestConfiguration 不算"。 错误消息要可执行,这是本仓库反复出现的纪律(第 16 章 FailureAnalyzer 那里系统讲过)。

merge 把找到的类放到第一位:

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 上下文配置

@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:

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 的写法值得注意:

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:

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 的实现

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:一个专门用来防缓存串味的属性

/**
 * 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

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:

/**
 * {@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 影响 ApplicationArguments
  • webEnvironment 影响 ServletTestExecutionListener 与上下文类型
  • useMainMethod 影响"是否跑 main 方法"

hashCode 的手写实现:

@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 注解"的情况:

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 有两个构造器:

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 三个入口,一个私有实现

@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 三态:

private enum Mode {
    STANDARD,        // 正常测试
    AOT_PROCESSING,  // AOT 处理(录制)
    AOT_RUNTIME      // AOT 运行(消费产物)
}

注意 AOT_PROCESSING 多带一个 runtimeHints 参数——因为录制阶段要往 RuntimeHints 里注册 "main 方法需要被反射调用"(见下文 registerMethod)。

4.2 useMainMethod 的三态如何落地

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:
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:三态的具体判定

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:

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:

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

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

逐条:

  1. setMainApplicationClass(mergedConfig.getTestClass())——注意是测试类不是主应用类。 这会影响 banner 输出、spring.main.* 的一些推断,以及 AOT 生成类的命名 (第 28 章讲过 <MainClass>__ApplicationContextInitializer,测试 AOT 时那个 "MainClass" 就是测试类)。
  2. addPrimarySources(classes) + getSources().addAll(locations): 主配置类是 primary source(会被 @ComponentScan 的 basePackage 推断用到), XML locations 是普通 source。
  3. 三个 instanceof 分支决定 WebApplicationType——与 §3.4 bootstrapper 的分支一一对应, 只不过那边产出配置类型,这边消费。
  4. isEmbeddedWebEnvironment 为 true 时不加 mock servlet 环境 (WebConfigurer 只在非 embedded 时调用)——真服务器不需要 mock。
  5. parent != null 时关 banner:层次上下文里子上下文启动时打印 banner 很吵。
  6. Environment 的两条路:子类覆写 getEnvironment() 返回非 null 就直接设; 否则注册一个 PrepareEnvironmentListener 在 ApplicationEnvironmentPreparedEvent 时再准备。 后者是默认路径——因为 Environment 必须由 SpringApplication 自己按 WebApplicationType 创建。

getApplicationContextFactory:

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

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 双写

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

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 值得单独说:

/**
 * 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 用在测试里

这是本节技术含量最高的一段:

/**
 * {@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_PROCESSINGMode.AOT_PROCESSING(同名,不同类)
是否捕获上下文由 SpringApplicationAotProcessor 捕获自己用 contexts 列表捕获

starting() 里的两个动作:

  1. configurer.accept(application)——这是"走 main 方法"路径配置 SpringApplication 的唯一时机。 因为 main 方法内部 new 出来的 SpringApplication 外部拿不到,只有在 SpringApplicationRunListener.starting() 时才能截获它。
  2. AOT_RUNTIME 时把 AOT 生成的 AotApplicationContextInitializer 加进去—— 注意用的是方法引用强制转型:(AotApplicationContextInitializer<?>) initializer::initialize。 因为 SpringApplication.addInitializers 要求 ApplicationContextInitializer<?>, 而 AOT 需要一个特定标记接口(第 28 章 §3.1 讲过 AotApplicationContextInitializer 的作用—— addAotGeneratedInitializerIfNecessary 会去找它)。

run() 方法收尾:

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

四个分支:

  1. 正常返回 ApplicationContext:直接返回。这发生在"不走 hook"的极少数情况 (比如 main 方法自己返回了上下文)。
  2. AbandonedRunException:忽略异常,走下面的列表逻辑。
  3. 其它异常:如果只有一个失败上下文,包成 ContextLoadException(携带上下文, 便于 FailureAnalyzer 分析;第 16 章讲过这套机制);否则原样抛。
  4. 正常跑完:从 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)要做三件独立的事:

  1. 关掉全量自动配置
  2. 限制组件扫描范围(只留切片相关的组件)
  3. 只导入切片需要的自动配置

这三件事分别由三个机制实现,彼此正交。

5.1 第一件:@OverrideAutoConfiguration(enabled = false)

看 @WebMvcTest 的注解声明:

@BootstrapWith(WebMvcTestContextBootstrapper.class)
@ExtendWith(SpringExtension.class)
@OverrideAutoConfiguration(enabled = false)
@TypeExcludeFilters(WebMvcTypeExcludeFilter.class)
@AutoConfigureWebMvc
@AutoConfigureMockMvc
@ImportAutoConfiguration
public @interface WebMvcTest {

@OverrideAutoConfiguration(enabled = false) 的实现:

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

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 的解法是:

  1. 在 @SpringBootApplication 的 @ComponentScan 里静态声明 excludeFilters = @Filter(type = CUSTOM, classes = TypeExcludeFilter.class) (这是 SpringBootApplication 注解的一部分,见第 13 章)
  2. TypeExcludeFilter 运行期从 bean 工厂里找真正的过滤器

于是过滤规则可以是一个 bean,可以被 @Bean 方法创建、可以被条件注解控制、 可以在测试里动态注册。

getClass() == TypeExcludeFilter.class 这个判断是防自引用的: 如果当前对象是子类(真正的过滤器实现),它不应该再去问一遍委托——否则无限递归。

equals/hashCode 直接抛异常是本段最反直觉的地方。Javadoc 解释了原因:

Implementations should provide a subclass registered with BeanFactory and override the match(...) method. They should also implement a valid hashCode() and equals(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:把注解属性翻译成过滤规则

@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 的两级:

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 同时试两种匹配:

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:约定优于反射

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 解析泛型参数:

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 合两张表:

@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 实例

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

}

四个设计点:

  1. OPTIONAL_INCLUDES 用字符串类名而不是 Class 字面量: Jackson 3 的 JacksonModule、Thymeleaf 的 IDialect、Jackson 2 的 Module 都是可选依赖。写 Class 字面量会导致这个 filter 类本身加载失败 (第 28 章第七十七定律的又一次出现)。用 ClassUtils.forName + catch 忽略。
  2. 两张静态表:KNOWN_INCLUDES 与 KNOWN_INCLUDES_AND_CONTROLLER(多一个 Controller)。 用哪张取决于是否显式指定了 controllers。
  3. getKnownIncludes() 的语义:指定 controllers 时,默认的 @Controller 不再被包含 ——因为你要的只是那几个 controller。这正是 @WebMvcTest(MyController.class) 比 @WebMvcTest 更严格的原因。
  4. getComponentIncludes() 把 controllers 作为具体类白名单返回, 于是 isTypeOrAnnotated 会用 AssignableTypeFilter 匹配它们及其子类。

注意 KMNOWN_INCLUDES 里既有注解(ControllerAdvice)又有接口(WebMvcConfigurer)—— 正好对应 §5.3 的 isTypeOrAnnotated 双匹配。

5.6 TypeIncludes:SPI 扩展点,让第三方往切片里加东西

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 上全部读取合并。

加载失败的处理:

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

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
@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 模块里有同名文件:

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 的基类

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。

@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 枚举定义了"什么算测试数据库":

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 + @ServiceConnection PostgreSQL 容器的测试, 断言 DataSource 的 URL 指向容器而非 H2。


6. Testcontainers:把容器变成"会自己启动和关闭的 bean"

Testcontainers 支持要解决三个问题: 容器怎么变成 bean、容器什么时候启动、容器的连接信息怎么传给自动配置。

6.1 声明方式:静态字段 + @ServiceConnection

@TestConfiguration(proxyBeanMethods = false)
class MyContainers {

    @Bean
    @ServiceConnection
    PostgreSQLContainer<?> postgres() {
        return new PostgreSQLContainer<>("postgres:16");
    }

}

或者更简单的字段形式:

@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 的推断:

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 三重匹配:

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

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:字段形式的收集

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 里两处值得注意:

Assert.state(Modifier.isStatic(field.getModifiers()),
        () -> "@ServiceConnection field '%s' must be static".formatted(field.getName()));

必须 static。因为 ContextCustomizerFactory 在测试实例创建之前就运行, 拿不到实例字段。

// 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

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 做一件反直觉的事:

/**
 * {@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 的核心:

@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 的失败处理:

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。

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 抛的可能是包装过的异常。

关闭逻辑:

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

两个不关闭的条件:

  1. 框架已经接管了销毁(destroyMethodName 不是空字符串——即 BFPP 没抹掉它, 说明用户显式配了 destroy 方法)。
  2. 可复用容器(Testcontainers 的 reuse 特性,容器在测试之间保留)。

第 1 条的判断方式很巧妙:BFPP 把 destroy 设成 "" 当作"我接管了"的标记。 用一个特殊值而不是单独的标记字段——第 27 章的 UNPACK 注释、NO_MANIFEST 哨兵都是同族。

6.6 TestcontainersStartup:顺序还是并行

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 切换。

解析用"规范化 + 忽略大小写":

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 的幂等处理:

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:把声明类导入上下文

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Import(ImportTestcontainersRegistrar.class)
@ImportAutoConfiguration(TestcontainersPropertySourceAutoConfiguration.class)
public @interface ImportTestcontainers {

    Class<?>[] value() default {};

}

ContainerFieldsImporter 把静态 Container 字段注册成 bean(要求字段 static 且非 null):

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。两个注解值得注意:

@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

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

}

三个要点:

  1. 时机是 ApplicationPreparedEvent——bean 定义已加载但未刷新。必须是 bean 定义加载之后 (否则无法替换 DataSource 的 ConnectionDetails),又必须是刷新之前(否则 bean 已创建完)。
  2. SpringApplication.getShutdownHandlers() 注册关闭钩子,不用 Runtime.getRuntime().addShutdownHook()——关闭顺序可控(第 4 章讲过它)。
  3. 把 event.getSpringApplication().getListeners() 传给 lifecycle manager,供 §7.4 手工广播用。

7.2 DockerComposeLifecycleManager.start():十步

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 的处理:

if (wait == Wait.ONLY_IF_STARTED) {
    wait = Wait.ALWAYS;
}

只有在我们自己启动了服务时才等待就绪。 如果服务本来就在跑(skip 分支), 就不做就绪检查——因为那可能是生产环境或别人手动起的。

Wait 三值:

public enum Wait {
    ALWAYS, NEVER, ONLY_IF_STARTED
}

7.3 DockerComposeSkipCheck:靠栈追踪判断"是不是在测试里"

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

/**
 * 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 连接的三重语义

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 的判定逻辑是本章最"接地气"的一段:

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:

// 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.*:

属性默认说明
enabledtrue总开关
arguments空传给 compose 命令的额外参数
file空(自动查找)compose 文件路径
lifecycle-managementSTART_AND_STOPNONE / START_ONLY / START_AND_STOP
host空Docker 主机
start.commandUP启动命令
start.log-levelINFO日志级别
start.skipIF_RUNNINGNEVER / IF_RUNNING
stop.commandSTOP停止命令
stop.timeout10s停止超时,0 表示强制
profiles.active空Docker Compose profiles
skip.in-teststrue测试中跳过
readiness.waitALWAYSALWAYS / NEVER / ONLY_IF_STARTED
readiness.timeout2m就绪超时
readiness.tcp.connect-timeout200msTCP 连接超时
readiness.tcp.read-timeout200msTCP 读超时

LifecycleManagement 用两个布尔表达三态:

NONE(false, false), START_ONLY(true, false), START_AND_STOP(true, true);

与 §2.3 WebEnvironment 的 embedded 布尔同构—— 三态枚举用两个正交布尔表达,比三个 if 分支更不容易漏。

7.7 DockerComposeFile 的查找

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:只加载配置,不启动应用

@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:测试"某类不存在"的场景

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 使用。

典型用法(本仓库自己的测试里大量出现):

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:

Type.MAP              // 加到 MapPropertySource
Type.SYSTEM_ENVIRONMENT
Type.VALUE            // 直接 setProperty

applyTo(environment, Type.MAP, "active-test-profiles") 里的第三个参数是 property source 名—— 命名属性源便于调试时定位,这是本仓库的一贯做法(第 6 章讲过每个内置属性源都有名字)。


9. 决策速查表

你想做什么用什么关键实现
启动完整应用做测试@SpringBootTestSpringBootContextLoader 跑 SpringApplication
让测试走 main 方法useMainMethod = ALWAYS/WHEN_AVAILABLEContextLoaderHook.starting() 里配置
只测 Web 层@WebMvcTest关自动配置 + WebMvcTypeExcludeFilter + AutoConfigureMockMvc.imports
只测持久层@DataJpaTest额外 @AutoConfigureTestDatabase 换数据源
让切片扫到我自己的组件类型META-INF/spring/<切片注解全名>.includesTypeIncludes.load
让第三方自动配置进入切片META-INF/spring/<@AutoConfigureXxx 全名>.imports@ImportAutoConfiguration
用真实数据库做测试@ServiceConnection 容器字段或 @BeanContainerConnectionSource → ConnectionDetails
测试里控制容器启停spring.testcontainers.beans.startupTestcontainersStartup
开发期起依赖服务compose.yaml + spring-boot-docker-composeDockerComposeLifecycleManager
让某个服务不做就绪检查compose label ...readiness-check.disableServiceReadinessChecks.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. 本章未逐行核实 / 待你实测的项

  1. AotTestAttributes 的持久化格式与位置(在 spring-test,不在本仓库)。
  2. @SpringBootApplication 是否无条件带 TypeExcludeFilter 排除器。
  3. TestDatabaseAutoConfiguration 如何按 ContainerImageMetadata 决定不替换 DataSource。
  4. DynamicPropertySourceMethodsImporter 的导入逻辑与 AOT hint 注册。
  5. 接收 DockerComposeServicesReadyEvent 的 listener(服务 → ConnectionDetails 转换)未定位。
  6. DockerComposeFile.find 的完整候选文件名与目录常量。
  7. OutputCaptureExtension 替换 System.out 的时机与恢复逻辑。
  8. TypeExcludeFiltersContextCustomizer(Factory) 如何把过滤器注册成 bean(本章只看了过滤器本身)。
  9. spring-boot-test 里 http/client、web/server 两个包的内容未展开。
  10. 除 JMX 外还有哪些"测试专用默认属性"。

12. 小结与下一章

本章把测试体系从注解走到容器:

  • @SpringBootTest 不是"启动容器",而是在 TCF 的六层扩展点上各挂一个 Spring Boot 实现(§0)。
  • Bootstrapper 负责"算出配置":配置类搜索、differentiator 防串味、 SpringBootTestAnnotation 进缓存键、WebEnvironment 翻译成上下文类型(§3)。
  • Loader 负责"把跑应用变成加载上下文":三个 Mode 共享一条路径, ContextLoaderHook 用 SpringApplicationHook 截获 main 方法创建的 SpringApplication(§4)。
  • 切片测试是三件正交的事:关自动配置(属性)、限制扫描(TypeExcludeFilter bean)、 导入特定 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 协作。