Spring 集成测试与 Testcontainers:真实依赖怎样可重复验证
Controller 能否接收 JSON、Repository 能否执行目标数据库的 SQL、服务提交后另一条连接能否读到订单,是三个不同的测试任务。Spring 测试可以为它们分别准备 Web 切片、持久化切片和完整应用。选择的范围越明确,失败报告就越容易指向实际出错的协作。
选择需要启动的应用范围
从对象装配到真实 HTTP
普通 JUnit 测试可以直接 new 服务并传入协作者。需要验证注入、配置条件、代理、转换器或事务时,再让 Spring 创建对象。常用层次可以这样展开:
纯对象测试
└── 实际业务对象 + 显式替身
@WebMvcTest(OrderController.class)
├── MVC 路由、参数解析、转换器、异常处理及相关 Web 配置
├── MockMvc:在测试进程内分派请求,不监听真实网络端口
└── OrderService:通过 @MockitoBean 提供替身
@DataJpaTest
├── Entity、Repository、EntityManager、DataSource、事务管理器
├── 目标数据库与 schema
└── 默认测试事务及回滚
@SpringBootTest(webEnvironment = RANDOM_PORT)
├── 完整应用配置与实际协作对象
├── 启动内嵌服务器并分配端口
└── 测试 HTTP 客户端 → 服务器线程 → 应用事务 → 数据库@SpringBootTest 默认 MOCK 环境可以创建 Web ApplicationContext,但不自动启动监听端口;配合 MockMvc 的自动配置才能在这个上下文中进行模拟分派。RANDOM_PORT 启动真正的服务器,DEFINED_PORT 使用指定端口,NONE 不创建 Web 环境。选择与配置示例见 Spring Boot 应用测试。
切片通过限定组件扫描与自动配置缩小装配范围。@WebMvcTest 中 Controller 依赖的普通 Service 通常需要显式提供,@DataJpaTest 也不会顺带加载整套业务服务。测试缺 Bean 时,先判断该 Bean 是否应属于这次测试,而不是直接把所有测试换成完整启动。
Boot 4 将许多测试支持拆到对应技术模块。工程使用 spring-boot-starter-webmvc-test 和 spring-boot-starter-data-jpa-test;相应注解位于 org.springframework.boot.webmvc.test.autoconfigure 与 org.springframework.boot.data.jpa.test.autoconfigure。从旧版示例迁移时,要同时检查依赖模块和 import。各切片实际导入清单见 Boot 测试切片附录。
JDBC、JSON、客户端、消息等测试也有相应的窄范围配置。只有涉及多个组件共同工作的问题,才需要把这些部分合在一个测试里。例如订单路由的 JSON 字段可以在 Web 切片验证,唯一约束需要目标数据库,订单事务提交后发布事件则需要服务代理与事务管理器共同参与。
运行真实 PostgreSQL 示例
下载 Spring 集成测试工程。工程采用 Java 17 编译目标、Spring Boot 4.1.1、Testcontainers 2.0.5 和 PostgreSQL 18.6,可使用 Java 17 或 25 执行。数据库只保存实验订单,schema 由 Flyway 迁移创建,Hibernate 使用 ddl-auto=validate 检查映射。
工程内容与测试任务对应:
src/main/
├── java/example/testing/
│ ├── LabApplication、OrderController
│ ├── OrderService、CommitObserver
│ └── PurchaseOrder、OrderRepository
└── resources/db/migration/V1__orders.sql
src/test/java/example/testing/
├── PostgresConfig 容器 Bean 与连接信息
├── WebSliceTest 路由成功、坏 JSON 拒绝
├── JpaTransactionTest flush、clear、rollback、commit
├── HttpTransactionTest 真 HTTP 与两个事务
├── ContextCacheTest 实际上下文对象复用
└── WebSliceMissingBeanCase 单独运行的缺 Bean 反例Linux 原生 Docker、默认 Unix socket 的运行方式如下。宿主已安装 Docker CLI 与 unzip,当前普通用户有权访问开发用 daemon;不要在生产 Docker 主机上执行来历不明的测试工程。
unzip testing-spring-lab.zip
cd testing-spring
mkdir -p .m2
LAB_DIR="$(pwd -P)"
MAVEN_IMAGE='maven:3.9.12-eclipse-temurin-25'
docker version
test -S /var/run/docker.sock
SOCKET_GID="$(stat -c '%g' /var/run/docker.sock)"
docker pull "$MAVEN_IMAGE"
docker pull postgres:18.6
docker run --rm --user "$(id -u):$(id -g)" --group-add "$SOCKET_GID" \
-e HOME=/tmp -e MAVEN_CONFIG=/tmp/.m2 \
--mount "type=bind,src=$LAB_DIR,dst=/work" \
--mount "type=bind,src=$LAB_DIR/.m2,dst=/m2" \
--mount type=bind,src=/var/run/docker.sock,dst=/var/run/docker.sock \
--workdir /work "$MAVEN_IMAGE" \
mvn -B -ntp -Duser.home=/tmp -Dmaven.repo.local=/m2 clean verify--user 使 Maven 按宿主 UID/GID 写入源码目录和缓存。--group-add 另行提供 socket 所属组,测试 JVM 才能请求 daemon 创建 PostgreSQL 容器。能操作 Docker socket 通常意味着对 daemon 所在主机拥有很高的权限;非 root UID 并不会消除这项能力。官方权限说明见 Docker Linux 安装后配置。
Docker Desktop 中运行容器化 Maven 时,在上述 docker run 中增加 -e TESTCONTAINERS_HOST_OVERRIDE=host.docker.internal,使测试 JVM 能通过宿主入口连接兄弟容器的随机映射端口。原生 Linux、rootless Docker 和远程 daemon 的可达地址各不相同,应按实际拓扑选择;rootless socket 也未必位于 /var/run/docker.sock。容器内运行 Testcontainers
已有本机 JDK 和 Maven 时,可以在宿主直接执行 mvn -B -ntp clean verify,省去容器内客户端到宿主端口的一层路由。Testcontainers 支持的运行环境与发现配置见 Docker 环境说明。企业内网需要同时准备 Maven 依赖、PostgreSQL 镜像和 Testcontainers 辅助镜像;镜像拉取受限时先处理获准仓库和认证,不要将测试标成跳过。
正常运行共有 8 次测试执行,Surefire 汇总应为零失败、零错误、零跳过。日志会出现 PostgreSQL 启动、Flyway 应用 V1__orders.sql、Hibernate schema 校验和随机 HTTP 端口;这些步骤发生在测试方法开始之前。最终报告位于 target/surefire-reports。
理解上下文、连接信息与容器的生命周期
TestContext 怎样接上 JUnit
Spring 的 JUnit 扩展通过 TestContext 框架完成测试实例注入,并在方法前后通知相应监听器。TestContextManager 管理一次测试类的相关协作;配置合并后交给 ContextLoader 创建或取回 ApplicationContext。事务监听器、SQL 脚本监听器等在这一流程中各自执行职责。TestContext Framework
因此,测试失败可能出现在三个阶段:准备上下文时缺 Bean 或连接失败;测试方法中断言失败;方法结束后事务或资源收尾失败。阅读日志时从第一条真正的 cause 开始,而不是只看最外层 Failed to load ApplicationContext。
ApplicationContext 的缓存与复用条件
上下文创建成本包括配置解析、Bean 创建、连接池启动等。相同配置的测试可以共享上下文,缓存按合并后的配置标识查找,而不只看测试类名。配置类、active profiles、测试属性、动态属性方法和 Bean override 等差异都会影响匹配。Context Caching
工程中的 ContextCacheTest 让两个测试类使用同一个配置,再让第三个增加一条属性:
var first = new TestContextManager(First.class)
.getTestContext().getApplicationContext();
var second = new TestContextManager(Second.class)
.getTestContext().getApplicationContext();
var different = new TestContextManager(Different.class)
.getTestContext().getApplicationContext();
assertSame(first, second);
assertSame(first.getBean("marker"), second.getBean("marker"));
assertNotSame(first, different);前两个引用指向同一实际 Context,第三个因测试属性变化获得另一个 Context。对象复用也意味着可变 Singleton 被共享。给每个方法创建新的 JUnit 测试实例,只能隔离实例字段,不能重新初始化共享 Bean、数据库和静态集合。
诊断启动次数时,可设置 logging.level.org.springframework.test.context.cache=DEBUG 查看命中情况。把共享测试配置集中在明确的组合注解或配置类中,通常比给每类添加略有差异的 property 与 mock 更容易控制启动成本。缓存留在测试 JVM 中,不能跨 Maven 的独立 fork 复用。
@DirtiesContext 适用于测试确实改坏了容器配置或共享应用对象的情况。关闭后重新创建会再次承担初始化成本;清理一条订单通常应直接按测试 ID 删除,没必要销毁整个 Spring 容器。
容器 Bean 与 Spring 一起存活
示例将 PostgreSQL 注册为测试配置中的 Bean:
@TestConfiguration(proxyBeanMethods = false)
public class PostgresConfig {
@Bean
@ServiceConnection
PostgreSQLContainer postgres() {
return new PostgreSQLContainer("postgres:18.6")
.withDatabaseName("test16")
.withUsername("test16")
.withPassword("test16-local")
.withLabel("lab", "test16-spring");
}
}导入类型为 org.testcontainers.postgresql.PostgreSQLContainer;Testcontainers 2 的模块依赖使用 testcontainers-postgresql。旧版本教程中的模块名和包路径需要随升级检查。Testcontainers 2.0.5 发布记录
@ServiceConnection 将运行中的容器转换成 Boot 能识别的连接详情,数据源与 Flyway 取得动态地址、端口和凭据。此功能需要 spring-boot-testcontainers 测试依赖。容器 Bean 随 ApplicationContext 启停,Spring 先让需要容器的应用 Bean 完成销毁,再停止容器。Boot Testcontainers 集成
JPA 切片和完整应用使用不同上下文,所以各自有独立 PostgreSQL 实例。这个代价换来独立 schema 与清理范围。测试类很多时可以设计统一共享配置,但要保留数据命名和清理规则,不能仅靠复用容器来实现隔离。
另一种常见写法是 JUnit @Testcontainers 与 @Container:静态字段按类共享,实例字段通常为每次测试准备独立容器。它们的停止时机由 JUnit Extension 决定,而 Spring Context 可能仍留在缓存里。若类结束时容器停止、下一类却复用旧 Context,连接池就会持有失效地址。使用哪种方式,应先确定谁拥有最长生命周期。Testcontainers Jupiter 生命周期
动态属性、等待与数据库迁移
自定义服务没有合适的 ConnectionDetails 时,可通过静态 @DynamicPropertySource 方法向 Environment 注册属性供应者。需要把服务 URL、用户名等与容器实际值关联,不要把随机端口抄成固定端口。供应者会在属性解析时取值,注册动作本身不会让任意容器自动启动;启停仍要交给选定的 owner。
容器进入 running 状态后,应用协议可能还未就绪。使用对应容器模块的等待机制,或按服务特征配置 HTTP、日志和健康检查等待;固定睡眠容易同时导致快机器浪费时间、慢机器偶尔失败。等待策略与超时配置见 Waiting Strategies。
数据库启动成功后还要建立结构。示例让 Flyway 执行版本化 SQL,再让 Hibernate validate 映射,顺序与应用运行需要保持一致。ddl-auto=create-drop 适合某些隔离实验,但会避开真实 migration 的语法、索引和历史兼容问题。涉及升级时,还要从上一版本结构与代表性数据迁移,不能只测试空库建表。
分别观察 flush、回滚、提交与 HTTP 事务
测试事务绑定在执行线程上
@DataJpaTest 默认启动测试管理事务并在结束时回滚。调用默认 REQUIRED 传播的应用服务时,服务通常加入当前事务。业务方法返回后,测试仍可以继续在同一事务中查询;此时尚未发生最终提交。Spring 测试事务
Hibernate 的持久化上下文又增加了一层内存状态。save 后得到对象,并不能据此判断 SQL 已执行到数据库。flush 将待执行变更同步到数据库,约束错误可能在这里出现;clear 清除一级缓存,使下一次查询重新加载实体;commit 决定事务的持久提交。这三个动作各有用途。
唯一编码测试先插入并 flush,再插入相同编码:
String code = "JPA-" + UUID.randomUUID();
orders.saveAndFlush(new PurchaseOrder(code));
orders.save(new PurchaseOrder(code));
assertThrows(DataIntegrityViolationException.class, orders::flush);PostgreSQL 的唯一约束在执行 SQL 时拒绝第二次写入,Spring 将相关异常转换为数据访问异常。发生这种错误后不要在同一已失败事务里继续业务查询,交给回滚完成清理,再在新事务里运行后续场景。
另一个测试在 saveAndFlush 后执行 entityManager.clear(),按 ID 重新读取并断言对象身份不同、字段相同。这能检查读取映射,而只从持久化上下文取回原对象可能漏掉列名或转换问题。
用另一条连接观察提交结果
默认 READ COMMITTED 下,另一个连接读不到本事务尚未提交的新行。示例刻意用 DataSource.getConnection() 获取独立连接并执行查询,避免查询又加入测试事务:
测试线程 / 事务 A 观察连接 B
create + flush count(code) = 0
测试内查询可以读到新订单
rollback count(code) = 0
另一个测试 / 事务 C
create + flush count(code) = 0
commit count(code) = 1
AFTER_COMMIT 监听器记录 codeJpaTransactionTest 用 TestTransaction 显式结束测试事务:
service.create(code);
orders.flush();
TestTransaction.flagForCommit();
TestTransaction.end();
assertEquals(1, outsideCount(code));
assertTrue(observer.contains(code));outsideCount 的连接、语句和 ResultSet 都用 try-with-resources 关闭。提交测试在 finally 中按自己的 code 删除记录,并清理监听器的内存集合。已经提交的订单不能期待测试结束自动回滚。
默认阶段为 AFTER_COMMIT 的 @TransactionalEventListener 在事务成功提交后执行;回滚路径没有同样的回调。监听器里若还要进行新的持久化写入,需要结合事务传播设计明确的新事务。外部消息投递与数据库原子性则要另外考虑 Outbox 等方案。事务事件
真实 HTTP 进入了另一个线程
完整应用测试使用随机端口发送实际 POST:
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Import(PostgresConfig.class)
class HttpTransactionTest {
@LocalServerPort int port;
@Autowired JdbcTemplate jdbc;
@Autowired CommitObserver observer;
@Test
@Transactional
void serverCommitSurvivesClientTestRollback() throws Exception {
String code = "HTTP-" + UUID.randomUUID();
var request = HttpRequest.newBuilder(
URI.create("http://127.0.0.1:" + port + "/orders"))
.timeout(Duration.ofSeconds(5))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(
"{\"code\":\"" + code + "\"}"))
.build();
try {
var response = HttpClient.newHttpClient()
.send(request, HttpResponse.BodyHandlers.ofString());
assertEquals(201, response.statusCode());
assertTrue(response.body().contains(code));
TestTransaction.flagForRollback();
TestTransaction.end();
assertEquals(1, jdbc.queryForObject(
"SELECT count(*) FROM purchase_order WHERE code=?",
Integer.class, code));
assertTrue(observer.contains(code));
} finally {
if (TestTransaction.isActive()) {
TestTransaction.flagForRollback();
TestTransaction.end();
}
jdbc.update("DELETE FROM purchase_order WHERE code=?", code);
observer.remove(code);
}
}
}请求从 JDK HttpClient 经监听端口进入服务器线程。服务的事务代理在服务器线程上开启并提交事务,调用测试线程上的 @Transactional 无法包住这个过程。示例在收到 201 后主动回滚调用测试事务,随后仍查到一条订单,并在 finally 中删除它。
这个区别会改变清理方法。MockMvc 通常在测试调用线程内执行同步分派;真实 HTTP 则经服务器处理线程,异步 Controller 或后台任务还会增加其他执行上下文。涉及提交后的行为时,应选择能覆盖实际线程关系的测试方式。
REQUIRES_NEW、多个事务管理器、手工提交、异步线程以及抢占式超时也可能让业务写入脱离默认测试事务。测试设计应标出每次写入属于哪个连接与事务,再决定回滚、删除或重建 schema 的清理策略。
定位装配、数据库与 Docker 失败
切片缺少业务 Bean
WebSliceTest 使用 @MockitoBean OrderService service,为 Controller 提供明确协作者。它验证正常 JSON 响应,并用坏 JSON 验证请求在调用 Service 前被拒绝。@MockitoBean 修改的是测试 ApplicationContext 中的 Bean;普通 Mockito @Mock 只创建测试字段,不能自动替换 Spring 注入关系。override 的选择与限制见 Spring Mockito Bean Override。
缺 Bean 反例省去这项配置。在前面的 docker run 中,将 Maven 目标改为 -Dtest=WebSliceMissingBeanCase test,保存整个运行结果:
set +e
docker run --rm --user "$(id -u):$(id -g)" \
-e HOME=/tmp -e MAVEN_CONFIG=/tmp/.m2 \
--mount "type=bind,src=$LAB_DIR,dst=/work" \
--mount "type=bind,src=$LAB_DIR/.m2,dst=/m2" \
--workdir /work "$MAVEN_IMAGE" \
mvn -B -ntp -Duser.home=/tmp -Dmaven.repo.local=/m2 \
-Dtest=WebSliceMissingBeanCase test > missing-bean.log 2>&1
status=$?
set -e
test "$status" -ne 0
grep -F 'No qualifying bean of type' missing-bean.log
grep -F 'example.testing.OrderService' missing-bean.log这个反例只启动 Web 切片,因此不需要 Docker socket。预期失败发生在上下文创建阶段,具体缺失类型是 OrderService。恢复时运行正常 WebSliceTest,它提供的 @MockitoBean 会让两个 Web 用例成功;最后回到完整 clean verify 检查 8 次正常执行。
启动失败按依赖顺序阅读
| 最先出现的现象 | 优先检查 | 下一步 |
|---|---|---|
| 找不到可用 Docker 环境 | 当前 JVM 的 DOCKER_HOST、socket 路径、组权限与 daemon 状态 | 用相同身份验证 daemon 连接,再重跑已选测试 |
| Docker API 不支持 | 实际 Testcontainers/docker-java 与 daemon 的 API 兼容 | 查看 dependency tree,升级兼容组合;不要随意固定过旧 API |
| 镜像拉取失败 | 仓库认证、代理、平台架构、镜像 tag、辅助镜像来源 | 拉取失败镜像并确认本机存在后重试 |
| 容器已启动但等待超时 | 容器日志、服务配置、内存、启动等待条件 | 修复首个服务错误,保留有上限的等待 |
| JDBC connection refused | 客户端实际连接的 host/映射端口,容器是否已停止 | 从同一测试网络检查连通性,再查生命周期 |
| migration 失败 | 首个失败版本、SQLState、目标数据库版本与历史校验 | 在新的实验数据库修复迁移路径后重跑 |
| Context failure threshold | 同一配置先前的首次启动失败 | 回看首次完整异常,修复后重新运行 JVM |
| 单跑通过、整套失败 | 共享 Bean、停止过早的容器、数据 ID 与清理范围 | 保留失败顺序,逐项缩小共享资源范围 |
日志不要只保留 Surefire 最后一段。数据库容器退出前的日志、测试 XML、首个 Context 异常和实际 JDBC 地址共同帮助还原启动过程;对外分享时删去凭据、宿主路径和真实业务数据。
Ryuk 与容器 owner 会清理所属临时容器。若异常退出后仍有实验资源,先用 docker ps -a --filter label=lab=test16-spring 确认对象,再按精确容器 ID 处理。不要在共享主机上执行全局 prune 来结束一次测试。
将相同实验带入 CI
CI 中保留同一 POM、数据库镜像和迁移文件,给测试进程提供可达 daemon、镜像仓库与足够资源。一次正常执行应同时留下测试数量、结果与报告;基础设施失败单独归类,避免重试后只保存最后一次绿色。
容器随机端口解决宿主端口冲突,测试数据唯一 code 解决同库对象冲突,上下文复用减少 Spring 初始化开销。这三项调整作用不同。继续增加并发前,先确认测试的共享 Bean、事务清理和容器 owner 已经明确;数据竞争与消息重投的可控实验见 数据库、消息与并发测试。
权威资料与规范地址
Spring 测试与事务
- Boot 应用测试:https://docs.spring.io/spring-boot/reference/testing/spring-boot-applications.html
- Boot 测试切片清单:https://docs.spring.io/spring-boot/appendix/test-auto-configuration/slices.html
- TestContext Framework:https://docs.spring.io/spring-framework/reference/testing/testcontext-framework.html
- Context Caching:https://docs.spring.io/spring-framework/reference/testing/testcontext-framework/ctx-management/caching.html
- Boot Testcontainers:https://docs.spring.io/spring-boot/reference/testing/testcontainers.html
- 测试事务:https://docs.spring.io/spring-framework/reference/testing/testcontext-framework/tx.html
- 事务事件:https://docs.spring.io/spring-framework/reference/data-access/transaction/event.html
- Mockito Bean Override:https://docs.spring.io/spring-framework/reference/testing/annotations/integration-spring/annotation-mockitobean.html
容器环境
- Testcontainers 2.0.5:https://github.com/testcontainers/testcontainers-java/releases/tag/2.0.5
- Jupiter 生命周期:https://java.testcontainers.org/test_framework_integration/junit_5/
- 容器内测试:https://java.testcontainers.org/supported_docker_environment/continuous_integration/dind_patterns/
- 支持的 Docker 环境:https://java.testcontainers.org/supported_docker_environment/
- 等待策略:https://java.testcontainers.org/features/startup_and_waits/
- Docker Linux 权限:https://docs.docker.com/engine/install/linux-postinstall/
