《Spring Boot 实战》4.2 Testcontainers 真实依赖

本节解释内嵌 H2 为何会掩盖 PostgreSQL 方言差异,给出 Testcontainers 2.0 在 Spring Boot 4.x 下的依赖坐标、@ServiceConnection 用法与共享容器模式,讲清容器复用、CI 里 Docker 的可用性前提,并附镜像拉取、端口、健康检查三类失败的排查清单。

本节目标:说清内嵌数据库会掩盖什么,给出 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 是一次破坏性升级,两点必须记住:

  1. 所有模块加 testcontainers- 前缀。1.x 的 org.testcontainers:postgresql、org.testcontainers:junit-jupiter 在 2.0 里是 testcontainers-postgresql、testcontainers-junit-jupiter,核心库是 testcontainers。
  2. 容器类搬到与模块同名的包下。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 {} 手动 startJVM 内一次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 契约测试与数据隔离 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Spring Boot 入门》18.3 打包与运行
  2. 《Spring Boot 入门》18.2 实现
  3. 《Spring Boot 入门》18.1 需求与设计