本节目标:说清内嵌数据库会掩盖什么,给出 Testcontainers 2.0 + Spring Boot 4.x 的依赖坐标、
@ServiceConnection用法、共享容器与复用策略,以及 CI 中 Docker 的可用性前提与失败排查清单。
适用版本:Spring Boot 4.1.x(Java 21)
4.2 Testcontainers 真实依赖
上一节留了一个尾巴:@DataJpaTest 默认把数据源换成内嵌数据库。这一节就来拆这颗雷。图书借阅服务的 loan 表要用到「同时最多借 5 本」的计数查询、due_at 的时间比较,还要处理并发借书;这些逻辑在 H2 上可能全绿,换到生产用的 PostgreSQL 上就翻车。
4.2.1 H2 到底掩盖了什么
内嵌库的问题不是「功能不全」,而是「行为不同」。差异一旦落在你依赖的那部分 SQL 上,测试的通过就变成了一种幻觉:
| 差异点 | H2 常见表现 | PostgreSQL 真实行为 | 后果 |
|---|---|---|---|
| 方言 | 默认兼容模式 | 独立方言 | 同一段 SQL 一边能跑一边报错 |
| 大小写折叠 | 标识符默认大写 | 未加引号折叠为小写 | 迁移后才暴露的表名冲突 |
| JSON 列 | 支持有限 | jsonb 索引与操作符丰富 | 用 jsonb 的查询在 H2 上无法验证 |
| 自增 / 序列 | identity 语义不同 | bigserial / nextval | 批量插入的 id 生成策略偏差 |
| 锁与隔离级别 | 实现简化 | 真实的 MVCC 行为 | 并发借书的竞态在 H2 上测不出来 |
| 时间类型 | 精度与默认时区不同 | timestamptz 语义严格 | 逾期判断在跨时区下出错 |
结论很直接:只要你的生产库不是 H2,就不要用 H2 去验证 SQL 层的行为。切片测试可以继续用内嵌库跑「映射是否配对」,但凡涉及方言、约束、并发的地方,必须换成真实数据库。
4.2.2 Testcontainers 2.0 的口径变化
Spring Boot 4.x 的依赖管理里是 Testcontainers 2.0(见 BRIEF 第五节的依赖大版本清单)。2.0 是一次破坏性升级,两点必须记住:
- 所有模块加
testcontainers-前缀。1.x 的org.testcontainers:postgresql、org.testcontainers:junit-jupiter在 2.0 里是testcontainers-postgresql、testcontainers-junit-jupiter,核心库是testcontainers。 - 容器类搬到与模块同名的包下。1.x 里
PostgreSQLContainer在org.testcontainers.containers,2.0 按模块名迁到org.testcontainers.postgresql(确切包名以官方 2.0 迁移指南为准,写代码时让 IDE 自动导入)。
Spring Boot 会统一管理 Testcontainers 的版本,需要时可覆盖:
testcontainers.version=2.0.5
测试依赖这样声明(版本由 Spring Boot 管理,不写 <version>):
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-postgresql</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
spring-boot-testcontainers 提供 @ServiceConnection;testcontainers-junit-jupiter 提供 @Testcontainers 与 @Container。
4.2.3 最小可用例子:一个类一个容器
最直接的写法是在测试类里声明一个静态容器,交给 JUnit 扩展管理生命周期:
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
@Testcontainers
@SpringBootTest
class LoanRepositoryIT {
@Container
@ServiceConnection
static final PostgreSQLContainer<?> POSTGRES =
new PostgreSQLContainer<>("postgres:17-alpine");
@Autowired
private LoanRepository loanRepository;
@Test
void countActiveLoansByMember() {
// 真实 PostgreSQL 上验证计数查询与约束
}
}
关键在 @ServiceConnection:它让 Spring Boot 直接从容器里抽取连接信息并装配 DataSource,取代了 3.x 时代那段 @DynamicPropertySource 样板代码。对比一下:
// 3.x 常见写法:手动把容器信息写进属性
@DynamicPropertySource
static void props(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", POSTGRES::getJdbcUrl);
registry.add("spring.datasource.username", POSTGRES::getUsername);
registry.add("spring.datasource.password", POSTGRES::getPassword);
}
用了 @ServiceConnection 之后,上面整段可以删掉,而且连接池等非连接属性仍然由配置文件控制,不会被容器覆盖。
4.2.4 共享容器:别让每个类都起一次
「一个类一个容器」在类多了以后代价很大:每个类启动一次数据库,十几秒就没了。生产上的做法是共享容器。最干净的方式是把容器声明成一个 @TestConfiguration 里的 bean:
@TestConfiguration(proxyBeanMethods = false)
public class ContainersConfig {
@Bean
@ServiceConnection
PostgreSQLContainer<?> postgres() {
return new PostgreSQLContainer<>("postgres:17-alpine");
}
}
@SpringBootTest
@Import(ContainersConfig.class)
class LoanRepositoryIT { /* ... */ }
这样容器在上下文启动时创建、在整个测试 JVM 内被所有引用了同一配置的测试类共享。要注意两点:
- 共享容器解决的是容器启动成本,不解决上下文缓存。如果每个测试类的
@MockitoBean组合或属性不同,Spring 仍会建多个上下文(见 4.1.8)。 - 容器共享后,数据也在共享,必须配合数据隔离策略(见 4.3),否则测试之间会互相污染。
本地开发时还可以开启容器复用,跳过重复启动:
# ~/.testcontainers.properties
testcontainers.reuse.enable=true
new PostgreSQLContainer<>("postgres:17-alpine").withReuse(true);
复用只适合本机。CI 上镜像与数据都可能变化,复用一个残留容器会让测试结果不可信,所以 CI 里一律关闭复用,让每次构建从干净容器开始。
三种生命周期方式的选择可以归纳成一张表:
| 方式 | 容器何时启停 | 启动次数 | 适合场景 |
|---|---|---|---|
@Container 静态字段 + @Testcontainers | 每个测试类前后 | 类数量 | 少量测试类、彼此需强隔离 |
@TestConfiguration 里的容器 bean | 每次上下文创建 | 上下文数量 | 大多数集成测试 |
静态单例 + static {} 手动 start | JVM 内一次 | 1 | 测试类多、镜像重、能接受共享数据 |
静态单例写法要自己承担生命周期,JVM 退出时容器才被回收:
public abstract class PostgresTestBase {
static final PostgreSQLContainer<?> POSTGRES =
new PostgreSQLContainer<>("postgres:17-alpine");
static {
POSTGRES.start();
}
@DynamicPropertySource
static void props(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", POSTGRES::getJdbcUrl);
registry.add("spring.datasource.username", POSTGRES::getUsername);
registry.add("spring.datasource.password", POSTGRES::getPassword);
}
}
这里没有再使用 @ServiceConnection——@ServiceConnection 是给字段与 bean 声明用的,走 @DynamicPropertySource 的共享基类用不了它。两种写法各有取舍:要 @ServiceConnection 的简洁就用容器 bean,要 JVM 级共享就退回手写属性。
@ServiceConnection 也不是万能的。它只对 Spring Boot 内置支持的容器类型(数据库、消息队列、Redis 之类)自动生效;如果你的容器是自研的、或者要连的是一个没有 ConnectionDetails 的中间件,就得自己写一个 ConnectionDetailsFactory,或者退回 @DynamicPropertySource。遇到「加了注解但 DataSource 没被替换」时,第一反应应该是「这个容器类型在不在支持列表里」。
4.2.5 CI 里 Docker 的可用性前提
Testcontainers 不是「引入依赖就能跑」,它需要一台能用的 Docker。上线到流水线前,逐项确认:
- 守护进程可达:容器内执行
docker info能成功。流水线若是容器里跑构建,需要挂载宿主机的/var/run/docker.sock,或使用 Docker-in-Docker。 - 连接方式:非默认 socket 时用
DOCKER_HOST指定;dind 场景下 Testcontainers 需要知道如何从容器外访问映射端口,必要时设置TESTCONTAINERS_HOST_OVERRIDE。 - 资源回收:Testcontainers 依赖 Ryuk 容器清理残留资源,它也要能访问守护进程。若环境限制导致 Ryuk 起不来,只能用
TESTCONTAINERS_RYUK_DISABLED=true关闭——但这会让失败构建留下悬挂容器,属于下策。 - 镜像获取:优先在构建前预拉取镜像,或配置镜像加速 / 私有 registry 认证(
DOCKER_AUTH_CONFIG或~/.docker/config.json)。匿名拉 Docker Hub 有速率限制,流水线一忙就会命中。 - 磁盘与内存:每个容器都是一份镜像层与运行实例,节点磁盘不足会以「镜像拉取失败」的形式表现出来,容易误判为网络问题。
4.2.6 失败排查清单
Testcontainers 的失败大多落在三类,按这个顺序查最快:
| 现象 | 常见原因 | 处理 |
|---|---|---|
| 镜像拉取失败 | 标签不存在、网络不通、registry 未认证、速率限制 | 固定到存在的 tag;预拉取;配置认证 |
| 端口相关报错 | 自定义了固定宿主端口导致冲突;容器网络不可达 | 用随机映射端口(默认行为);检查 DOCKER_HOST |
| 启动超时 | 健康检查未通过、初始化慢、内存不足 | 换 Wait 策略、调 withStartupTimeout、加资源 |
| 找不到 Docker 环境 | socket 未挂载、dind 配置缺失 | 挂 socket 或补齐 dind 环境变量 |
4.1 针对最后一类做了一个体验改进:当 Testcontainers 找不到可用的 Docker 环境时,新增的 failure analyzer 会给出更详细的说明,不用再去翻一长串堆栈找根因。
一个容易被忽略的点是健康检查。默认的等待策略是「端口可连」,对 PostgreSQL 来说端口可连不等于可以接受连接。生产上更稳的是等日志或等 healthcheck:
POSTGRES.withStartupTimeout(Duration.ofMinutes(2))
.waitingFor(Wait.forLogMessage(".*database system is ready to accept connections.*\\n", 2));
排查时先把「是不是 Testcontainers 的问题」摘出去——用 Docker CLI 手动做一遍同样的动作:
# 1. 守护进程是否可用
docker info
# 2. 目标镜像能不能拉
docker pull postgres:17-alpine
# 3. 能不能起来并接受连接
docker run --rm -e POSTGRES_PASSWORD=secret -p 5432:5432 postgres:17-alpine
如果 docker pull 本身就失败,那问题在镜像与网络,跟 Testcontainers 无关;如果手动能起来、测试里起不来,再看环境变量(DOCKER_HOST、TESTCONTAINERS_HOST_OVERRIDE)与 Ryuk 是否被挡。这条「先摘出去」的习惯能省掉大量在框架层面绕圈的排查时间。
4.2.7 示例输出
下面这段是示例输出,不是本机实测——本机环境只跑通了纯 Spring Boot 应用,没有启动 Docker。真实运行时 🐳 前缀是 Testcontainers 的日志标记,容器启动耗时取决于镜像是否已在本地缓存:
# 示例输出(非本机实测)
2026-09-23T14:05:11.220+08:00 INFO 51234 --- [ main] o.t.utility.ImageNameSubstitutor : Image name substitution will be performed by: DefaultImageNameSubstitutor
2026-09-23T14:05:13.884+08:00 INFO 51234 --- [ main] 🐳 [postgres:17-alpine] : Creating container for image: postgres:17-alpine
2026-09-23T14:05:16.412+08:00 INFO 51234 --- [ main] 🐳 [postgres:17-alpine] : Container postgres:17-alpine is starting: 8f2c1d9a0b7e
2026-09-23T14:05:19.905+08:00 INFO 51234 --- [ main] 🐳 [postgres:17-alpine] : Container postgres:17-alpine started in 6.02s
判断启动耗时是否可接受的方法很朴素:在 CI 日志里量「从创建容器到 started」这一段。首次拉镜像可能几十秒,镜像已缓存通常几秒。把这段耗时计入「集成测试预算」,再决定要不要共享容器或开启复用。
4.2.8 小结
- 内嵌 H2 验证不了方言、约束、并发与时间语义;生产库不是 H2 时,SQL 层必须用真实数据库。
- Testcontainers 2.0 的模块加了
testcontainers-前缀,容器类迁到与模块同名的包下,写代码让 IDE 自动导入。 @ServiceConnection取代@DynamicPropertySource,连接信息由容器直接喂给自动配置。- 用
@TestConfiguration里的容器 bean 共享容器,降低启动成本;容器复用只在本机开启,CI 一律关闭。 - CI 前提是守护进程可达、Ryuk 可用、镜像可获取;失败先按「镜像 / 端口 / 健康检查」三类定位。
- 涉及容器的日志请一律按「示例输出」对待,不要在文档里伪称实测数字。
真实依赖接上以后,下一个问题是:同一套共享容器里,测试之间怎么保证互不干扰?以及跨团队、跨服务的接口兼容性由谁来兜?这正是下一节的主题。
阅读导航:上一节:4.1 测试分层策略 · 下一节:4.3 契约测试与数据隔离 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。