《Java 编程实战》15.2 Testcontainers:真实 PostgreSQL 集成测试

手工 docker run 的数据库会随开发机和 CI 漂移,H2 又撑不起 libraryd 的部分索引和 pg_trgm。本节用 Testcontainers 1.21.4 启动 postgres:17-alpine,讲清静态容器、连接注入、Flyway 迁移、容器复用与清理的真实行为。

本节目标:让 libraryd 的持久化测试自己拉起一个真实 PostgreSQL,跑完自动清理,并且在开发机和 CI 上行为一致。
适用版本:JDK 25(Temurin 25.0.5)、Testcontainers 1.21.4、PostgreSQL 17(postgres:17-alpine)、Flyway 13.11.1、Docker 29.x。

承接的问题

15.1 节确定了持久化测试必须用真实 PostgreSQL。接下来最直接的做法是写一份 README:

docker run -d --name libraryd-pg \
  -e POSTGRES_DB=libraryd -e POSTGRES_USER=libraryd -e POSTGRES_PASSWORD=libraryd \
  -p 5432:5432 postgres:17-alpine
mvn -pl libraryd-persistence test

这个流程在作者机器上能跑,在别人机器上很快会出现四类问题:

  • 端口 5432 被本地数据库占用,测试连到了另一个实例。
  • 容器里还留着上一次测试的数据,isbn-1 已经存在,测试报 DUPLICATE_ISBN。
  • CI 上没有手动启动的容器,Connection refused。
  • 某台机器拉的是 postgres:16-alpine,pg_trgm 行为或查询计划不同,测试结果不可比。

这些问题的共同点是:数据库的生命周期没有跟测试代码绑在一起。Testcontainers 解决的就是这件事——测试启动时创建容器,测试结束时销毁,镜像版本写在代码里。

核心机制一:一个测试类,一个静态容器

libraryd 的持久化测试用 JUnit 5 扩展管理容器:

@Testcontainers
class PostgresRepositoriesTest {

    @Container
    static final PostgreSQLContainer<?> POSTGRES =
            new PostgreSQLContainer<>("postgres:17-alpine")
                    .withDatabaseName("libraryd")
                    .withUsername("libraryd")
                    .withPassword("libraryd");
}

三个细节决定了它的行为:

  • static 字段让容器属于测试类,而不是每个测试方法。JUnit 在每个测试方法前创建新的测试实例,非静态字段会导致每个方法都启动一次容器。
  • @Testcontainers 是 JUnit 5 扩展。它扫描 @Container 字段,在 @BeforeAll 之前启动,在 @AfterAll 之后停止。
  • PostgreSQLContainer 自带等待策略。容器启动后,它会等待 PostgreSQL 端口可用和初始化完成,再返回 getJdbcUrl()。

如果测试类没有 @Testcontainers,@Container 不会生效,POSTGRES.getJdbcUrl() 会在容器尚未启动时抛异常。另一种写法是手动 POSTGRES.start() / POSTGRES.stop(),但那样要自己处理异常和清理,不如扩展稳。

容器的镜像版本必须写死为 postgres:17-alpine,不要用 postgres:latest。latest 会在某次拉取后变成 18,测试可能因为默认排序规则、pg_trgm 版本或 JDBC 驱动兼容性而变化。版本写死,测试才有可比性。

核心机制二:连接信息怎么进入被测代码

容器启动后,测试需要把 JDBC URL、用户名、密码传给 DataSourceFactory。libraryd 没有引入 Spring,所以不用 @DynamicPropertySource,而是直接构造 DatabaseConfig:

@BeforeAll
static void startDatabase() {
    DatabaseConfig config = new DatabaseConfig(
            POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword(), 4);
    var pool = DataSourceFactory.create(config);
    DatabaseMigrator.migrate(pool);
    dataSource = pool;
    transactions = new ThreadLocalTransactionManager(pool);
    books = new JdbcBookRepository(pool, transactions);
    members = new JdbcMemberRepository(pool, transactions);
    loans = new JdbcLoanRepository(pool, transactions);
}

DatabaseConfig 的四个参数是 URL、用户、密码、连接池大小。测试里池大小设为 4,因为测试是串行执行的,4 个连接足够,也不会在 CI 上占用太多 PostgreSQL 连接。

测试结束后要关闭连接池,否则 JVM 可能因为 Hikari 的非守护线程而不退出:

@AfterAll
static void stopDatabase() throws Exception {
    if (dataSource instanceof AutoCloseable closeable) {
        closeable.close();
    }
}

DataSource 接口本身没有 close(),但 HikariDataSource 实现了 AutoCloseable。用 instanceof 判断比直接强转安全,将来换成其他连接池也不会崩。

Testcontainers 的依赖只需要放在测试作用域:

<dependency>
  <groupId>org.testcontainers</groupId>
  <artifactId>postgresql</artifactId>
  <scope>test</scope>
</dependency>
<dependency>
  <groupId>org.testcontainers</groupId>
  <artifactId>junit-jupiter</artifactId>
  <scope>test</scope>
</dependency>

版本由父 POM 的 dependencyManagement 统一管理,子模块不写版本号。postgresql 提供 PostgreSQLContainer,junit-jupiter 提供 @Testcontainers 和 @Container。只加前者也能手动 start(),但用注解更省事。

@DynamicPropertySource 是 Spring Test 的注解,作用是往 Spring 的 Environment 里动态注册属性。libraryd 没有 Spring 容器,用这个注解只会多一个 spring-test 依赖,还要写一个不会被调用的静态方法。直接构造 DatabaseConfig 更符合当前架构:

// 不适用于 libraryd:没有 Spring Environment
@DynamicPropertySource
static void database(DynamicPropertyRegistry registry) {
    registry.add("spring.datasource.url", POSTGRES::getJdbcUrl);
}

如果将来把 libraryd 迁到 Spring Boot,这段代码才有意义。当前阶段不要为了“看起来标准”而引入不存在的容器。

迁移也在同一个容器上执行:

DatabaseMigrator.migrate(pool);

DatabaseMigrator 用 classpath:db/migration 下的 V1__init.sql 和 V2__book_search_index.sql 建表、建索引、安装 pg_trgm。这一步很关键:测试验证的不是“手写 schema 能不能用”,而是“应用启动时 Flyway 实际执行的 schema 能不能用”。如果迁移文件和实体代码不一致,测试会在容器里暴露,而不是等到部署。

核心机制三:复用、等待与清理

Testcontainers 默认每次测试类启动一个新容器,结束后删除。容器启动通常要 1 到 3 秒,测试类多的时候,这部分开销会累积。withReuse(true) 可以让同一个镜像、同一组配置的容器在多次运行之间复用:

PostgreSQLContainer<?> pg = new PostgreSQLContainer<>("postgres:17-alpine")
        .withDatabaseName("libraryd")
        .withUsername("libraryd")
        .withPassword("libraryd")
        .withReuse(true);
pg.start();

复用不是默认开启的,需要在 ~/.testcontainers.properties 里写:

testcontainers.reuse.enable=true

或者设置环境变量 TESTCONTAINERS_REUSE_ENABLE=true。本机用后者跑了一个最小探针,连续启动三次,真实输出如下:

containerId=96b22b7a9b876a1e9c828533ca93d3ebe0be332383050381910d1527d5a5aa70
startMillis=2646
containerId=96b22b7a9b876a1e9c828533ca93d3ebe0be332383050381910d1527d5a5aa70
startMillis=778
containerId=96b22b7a9b876a1e9c828533ca93d3ebe0be332383050381910d1527d5a5aa70
startMillis=770

第一次启动 2646 毫秒,后两次复用同一个容器,降到 770 毫秒左右。容器 ID 完全相同,说明没有重新创建。

复用要付出代价:

  • 容器里的数据不会自动清空。上一次测试留下的行会影响下一次,必须在 @BeforeEach 里 truncate,或者让每个测试用独立的 schema。
  • 如果两次运行之间修改了迁移文件,Flyway 会检测到 checksum 不一致,复用容器直接启动失败。这是好事,但会让人误以为“复用坏了”。
  • CI 上通常不建议开启复用。CI 的 runner 是一次性的,复用没有收益,反而可能让并行 job 争抢同一个容器。

等待策略也值得显式设置。PostgreSQLContainer 默认等待端口和初始化日志,但在慢机器或第一次拉镜像时,60 秒可能不够:

new PostgreSQLContainer<>("postgres:17-alpine")
        .withDatabaseName("libraryd")
        .withUsername("libraryd")
        .withPassword("libraryd")
        .withStartupTimeout(Duration.ofMinutes(2));

withStartupTimeout 只影响等待上限,不会让失败变慢;如果容器真的起不来,两分钟后会抛出带容器日志的异常,比无限等待容易排查。

在 libraryd 里落地:9 个持久化测试

PostgresRepositoriesTest 覆盖的不是“仓储能不能编译”,而是 PostgreSQL 特有的行为:

测试方法验证点
flywayCreatesSchemaAndSearchIndexesV1、V2 都执行成功,两个 GIN 索引存在
bookCrudAndPaginationRoundTripsave、update、search 的分页与总数
duplicateIsbnBecomesDomainError主键冲突映射成 DUPLICATE_ISBN
memberUpsertKeepsExistingNameon conflict do nothing 不覆盖已有姓名
activeLoanIsUniquePerIsbn局部唯一索引阻止同一本书被借两次
returnUpdatesFineAndOverdueQuery归还更新罚金,活动借阅查询排除已归还
deleteWithLoanHistoryIsRejectedByForeignKey有借阅历史的图书不能删除
transactionRollsBackAllWrites事务失败后所有写入回滚
nestedTransactionJoinsOuterTransaction嵌套事务加入外层事务

第一个测试直接查 pg_indexes,确认迁移真的建了索引:

try (Connection connection = dataSource.getConnection();
     PreparedStatement statement = connection.prepareStatement(
             "select count(*) from pg_indexes where tablename = 'books'"
                     + " and indexname in ('ix_books_title_trgm', 'ix_books_author_trgm')");
     ResultSet resultSet = statement.executeQuery()) {
    resultSet.next();
    assertEquals(2, resultSet.getInt(1));
}

这个断言看起来“实现细节”,但它验证的是迁移文件和应用代码的契约:V2__book_search_index.sql 必须安装 pg_trgm 并建两个 GIN 索引。如果迁移文件被误删或改名,搜索查询会退化成全表扫描,单元测试发现不了。

执行持久化模块测试,本机输出:

[INFO] Tests run: 9, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 2.976 s -- in com.plume.library.persistence.PostgresRepositoriesTest
[INFO] BUILD SUCCESS
[INFO] Total time:  4.361 s

9 个测试、约 3 秒,其中容器启动和 Flyway 迁移占了大头。这个成本可以接受,因为它换来的是真实 PostgreSQL 行为,而不是 H2 的近似。

测试数据隔离与并行

一个测试类共用一个容器,测试方法之间必须隔离。libraryd 用 @BeforeEach 清空三张表:

@BeforeEach
void cleanTables() throws Exception {
    try (Connection connection = dataSource.getConnection();
         PreparedStatement statement = connection.prepareStatement(
                 "truncate loans, members, books restart identity cascade")) {
        statement.execute();
    }
}

清空顺序和 cascade 都要注意。loans 有外键指向 books 和 members,直接 truncate books 会报错;cascade 会连同引用表一起清空,但如果顺序写错,仍然可能留下部分数据。restart identity 让 bigserial 从 1 重新开始,测试才能断言第一个借阅的 id 是 1。

如果多个测试类并行执行,每个类都有自己的静态容器,互不干扰。JUnit 默认不并行,Maven Surefire 也默认串行;如果开启并行,要确保每个类用独立的数据库名或 schema,不能共用一个容器。

另一种隔离方式是每个测试用独立 schema:

create schema if not exists test_1;
set search_path to test_1;

但这样 Flyway 迁移要对每个 schema 跑一遍,成本更高。libraryd 的数据量小,truncate 足够快,没必要引入 schema 隔离。

如果某张表的数据是测试的固定前提,可以用 withInitScript 在容器启动后执行一次初始化脚本:

new PostgreSQLContainer<>("postgres:17-alpine")
        .withInitScript("db/test-seed.sql");

withInitScript 在容器第一次启动时执行,复用容器时不会重复执行。所以它适合放“所有测试都需要的字典数据”,不适合放“每个测试自己的数据”。后者仍然应该在 @BeforeEach 里准备,否则测试顺序一变就会互相污染。

常见错误与边界

错误一:Docker 客户端找不到。 本机第一次跑持久化测试时,如果没有把 Docker socket 暴露给 Testcontainers,会得到:

java.lang.IllegalStateException: Could not find a valid Docker environment. Please see logs and check configuration
    at org.testcontainers.dockerclient.DockerClientProviderStrategy...

在 macOS 上用 Colima 或 Rancher Desktop 时,Docker socket 不在 /var/run/docker.sock。需要设置:

export DOCKER_HOST="unix://$HOME/.colima/default/docker.sock"
mvn -pl libraryd-persistence -am test

Docker Desktop 通常会自动创建 /var/run/docker.sock,但 CI 上仍要确认 Docker daemon 已启动。Testcontainers 不会替你安装 Docker。

错误二:容器启动太慢,测试超时。 第一次运行要拉 postgres:17-alpine 镜像,网络慢时 60 秒不够。可以提前 docker pull postgres:17-alpine,或者把 withStartupTimeout 调到两分钟。不要把超时当成“测试不稳定”,先看容器日志。

错误三:复用容器导致迁移校验失败。 开启 withReuse(true) 后修改 V1__init.sql,下一次测试会报:

FlywayValidateException: Validate failed: Migrations have failed validation
Migration checksum mismatch for migration version 1

这不是复用坏了,而是 Flyway 在保护已经应用过的迁移。正确做法是新增 V3__...sql,不要改历史文件;调试时可以删掉复用容器,但不要在生产上这么做。

错误四:在容器里跑测试,却连到宿主机数据库。 如果 DatabaseConfig 误用了 localhost:5432,测试会连到开发机的本地 PostgreSQL,容器白启动。测试里的 URL 必须来自 POSTGRES.getJdbcUrl(),不要手写。

边界:Ryuk 容器。 Testcontainers 默认启动 Ryuk 容器,在 JVM 退出后清理资源。某些 CI 环境不允许 Ryuk 连接 Docker socket,可以设置 TESTCONTAINERS_RYUK_DISABLED=true,但要自己保证容器被清理。这个开关适合调试,不适合长期开启。

边界:时区与 locale。 容器默认 UTC,JDBC 会话的时区也可能与 JVM 不同。涉及 timestamptz 的断言要用 Instant 或 OffsetDateTime,不要用本地时间字符串直接比较。libraryd 的借阅日期是 date 类型,不受时区影响,但 created_at 是 timestamptz,测试里不要断言它的具体值。

小结

  • Testcontainers 把 PostgreSQL 的生命周期绑到测试类上,镜像版本写在代码里,开发机和 CI 行为一致。
  • @Testcontainers + 静态 @Container 让一个测试类共用一个容器;非静态字段会导致每个方法重启容器。
  • libraryd 没有 Spring,直接用 POSTGRES.getJdbcUrl() 构造 DatabaseConfig;@DynamicPropertySource 不适用。
  • 迁移必须在同一个容器上执行,测试验证的是 Flyway 实际建出的 schema。
  • withReuse(true) 能把重复启动从约 2.6 秒降到约 0.8 秒,但要求手动清数据,并且不能改历史迁移。
  • 容器找不到时先检查 DOCKER_HOST;Colima 的 socket 不在 /var/run/docker.sock。

持久化测试能证明 SQL 和 schema 正确,Web 测试能证明路由和 DTO 正确,但它们都不证明“整个应用能启动”。下一节把 LibraryApplication 完整跑起来,用 HTTP 走一遍借阅、续借、归还和健康检查,并把它放进 CI。

阅读导航:上一节:15.1 测试金字塔:单元、集成与端到端 · 下一节:15.3 端到端与 CI:把服务跑起来验证 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发