前面的数据库测试依赖开发者先启动本地 PostgreSQL,并提供固定的连接配置。这样的测试会受到本地数据残留、端口占用和数据库版本差异影响,也很难直接放进持续集成环境。
Testcontainers 可以在测试期间启动一个真实的 PostgreSQL 容器。Spring Boot 再通过服务连接取得随机端口、用户名和密码,Flyway 则对空数据库执行与应用相同的迁移。本章沿用「事务」章节的案例,验证完整的数据保存路径。
1. 测试边界
单元测试与数据库集成测试关注的范围不同:
| 测试类型 | 是否启动 Spring | 是否连接 PostgreSQL | 适合验证 |
|---|---|---|---|
| 单元测试 | 否 | 否 | Java 分支、校验规则、对象转换 |
| 数据库集成测试 | 是 | 是 | SQL、迁移、约束、事务和结果映射 |
InMemoryHealthStatusRepositoryTests 属于单元测试,它可以快速验证 Repository 契约,但无法发现 PostgreSQL SQL 语法错误。Testcontainers 测试则会经过下面的实际组件:
1测试进程2-> 启动临时 PostgreSQL 容器3-> @ServiceConnection 提供连接信息4-> Spring Boot 创建 DataSource5-> Flyway 执行 V1、V26-> HealthService 执行事务7-> JdbcClient 查询断言8-> 测试结束后停止容器
这里不使用 H2 替代 PostgreSQL。当前 SQL 包含 ON CONFLICT、timestamptz 和 PostgreSQL 索引定义,换成另一种数据库会让测试环境与真实语义产生偏差。
Testcontainers 需要本机或 CI 执行环境能够访问兼容的容器运行时。第一次测试还需要拉取 PostgreSQL 镜像,因此通常会比后续执行更慢。
2. 添加测试依赖
在 pom.xml 中增加两个测试范围依赖:
01<dependency>02<groupId>org.springframework.boot</groupId>03<artifactId>spring-boot-testcontainers</artifactId>04<scope>test</scope>05</dependency>0607<dependency>08<groupId>org.testcontainers</groupId>09<artifactId>testcontainers-postgresql</artifactId>10<scope>test</scope>11</dependency>
spring-boot-testcontainers 提供 @ServiceConnection 等 Spring Boot 集成,testcontainers-postgresql 提供 PostgreSQL 容器类型。两个依赖都由 Spring Boot Parent 管理版本,不需要手动指定版本。
项目原有的 spring-boot-starter-webmvc-test 继续提供 JUnit、AssertJ、Mockito 和 Spring 测试支持。由于本章让 Spring 管理容器 Bean 的生命周期,没有使用 Testcontainers 的 JUnit @Container 扩展,所以不需要再增加 testcontainers-junit-jupiter。
这些依赖都使用 test scope,不会打进应用的生产 JAR。生产运行时也不会因为测试代码存在而尝试启动容器。
3. 声明 PostgreSQL 容器
在测试源码中创建专用配置:
01package cn.usehook.spring;0203import org.springframework.boot.test.context.TestConfiguration;04import org.springframework.boot.testcontainers.service.connection.ServiceConnection;05import org.springframework.context.annotation.Bean;06import org.testcontainers.postgresql.PostgreSQLContainer;0708@TestConfiguration(proxyBeanMethods = false)09public class TestcontainersConfiguration {1011@Bean12@ServiceConnection13PostgreSQLContainer postgresContainer() {14return new PostgreSQLContainer("postgres:17-alpine");15}16}
@TestConfiguration 表示这份配置只供测试显式导入,不会被普通应用组件扫描自动加入生产容器。proxyBeanMethods = false 适合这里没有相互调用的简单 Bean 方法。
PostgreSQLContainer 会创建随机宿主机端口和临时凭据。测试不应该假设端口是 5432,也不需要自己读取这些值。
@ServiceConnection 根据容器类型生成 JDBC 和 Flyway 所需的连接详情。这些连接详情优先于 application.yaml 中的 URL、用户名和密码,因此测试不会误连开发数据库;DataSource 和 Flyway 又会指向同一个临时 PostgreSQL。
把容器声明为 Spring Bean,可以让它的生命周期与缓存的测试 ApplicationContext 对齐。测试类增多以后,容器不会先于仍在复用它的 Spring 上下文意外停止。
4. 编写集成测试
创建测试并导入容器配置:
01package cn.usehook.spring.health;0203import cn.usehook.spring.TestcontainersConfiguration;04import org.junit.jupiter.api.Test;05import org.springframework.beans.factory.annotation.Autowired;06import org.springframework.boot.test.context.SpringBootTest;07import org.springframework.context.annotation.Import;08import org.springframework.jdbc.core.simple.JdbcClient;09import org.springframework.transaction.annotation.Transactional;1011import static org.assertj.core.api.Assertions.assertThat;1213@SpringBootTest14@Import(TestcontainersConfiguration.class)15@Transactional16class HealthPersistenceIntegrationTests {1718private final HealthService healthService;19private final HealthStatusRepository repository;20private final JdbcClient jdbcClient;2122@Autowired23HealthPersistenceIntegrationTests(24HealthService healthService,25HealthStatusRepository repository,26JdbcClient jdbcClient27) {28this.healthService = healthService;29this.repository = repository;30this.jdbcClient = jdbcClient;31}3233@Test34void migratesSchemaAndPersistsHealthCheck() {35long migrations = jdbcClient.sql("""36select count(*)37from flyway_schema_history38where success = true39""")40.query(Long.class)41.single();4243HealthStatus result = healthService.check("testcontainers");4445long historyCount = jdbcClient.sql("""46select count(*)47from health_check_history48where service = :service49""")50.param("service", result.service())51.query(Long.class)52.single();5354assertThat(migrations).isGreaterThanOrEqualTo(2);55assertThat(repository.findByService("testcontainers")).contains(result);56assertThat(historyCount).isEqualTo(1);57}58}
@SpringBootTest 加载完整应用,适合验证 Service 事务、两个 Repository、Flyway 和 PostgreSQL 的协作。@Import 只把测试容器配置加入这次测试上下文。
第一条查询确认 Flyway 已经登记迁移,随后 healthService.check() 在同一业务事务中更新最新状态并插入历史。最后两个断言分别验证最新状态映射和历史写入。
5. 数据隔离
测试类上的 @Transactional 会在每个测试方法前开始测试事务,并在方法结束后默认回滚。HealthService.check() 使用默认的 REQUIRED 传播,因此加入测试已经建立的事务,测试插入的数据不会污染同一容器中的其他测试。
Flyway 迁移发生在测试方法之前,不属于测试方法的回滚范围。表结构和初始数据会保留,只有测试方法中的业务修改被撤销。这个区别正是我们需要的:每个测试共享相同结构,但不共享各自创建的业务数据。
数据库序列的取值通常不会随事务回滚,所以测试不应该假设下一条历史记录的 id 一定连续。测试业务含义,而不是依赖没有写进契约的编号细节。
测试事务也可能掩盖提交之后才发生的问题。需要验证真实提交、跨事务可见性或提交回调时,可以为特定测试关闭测试事务,并显式清理它创建的数据;不要因此默认关闭全部测试隔离。
容器提供进程级隔离,事务提供测试方法级隔离。两者作用不同:容器避免连接到开发数据库,事务让同一测试数据库中的用例互不留下业务数据。
6. 执行与排查
确保容器运行时已经启动,然后执行:
1./mvnw -Dtest=HealthPersistenceIntegrationTests test
不需要提供 DB_PASSWORD。测试中的服务连接会覆盖普通数据库配置,容器启动后才生成实际凭据。
遇到失败时,可以按照执行顺序定位:
| 现象 | 优先检查 |
|---|---|
| 无法连接容器运行时 | Docker 或兼容运行时是否启动、当前用户是否有权限 |
| 镜像拉取失败 | 网络、镜像仓库和代理配置 |
| Spring 上下文启动失败 | @Import、@ServiceConnection 和测试依赖是否完整 |
| Flyway 迁移失败 | 迁移 SQL、文件名、执行版本与 PostgreSQL 日志 |
| 业务断言失败 | Repository SQL、事务边界与行映射 |
在 CI 中同样需要提供 Testcontainers 可访问的容器运行时,并允许拉取固定镜像。镜像标签应该与项目支持的 PostgreSQL 主版本一致,不使用 latest,避免一次无关的镜像升级突然改变测试环境。
Testcontainers 让数据库测试可重复,但不意味着所有测试都应该加载完整容器。纯业务规则继续使用快速单元测试,只有需要验证真实数据库行为的测试才进入这一层。下一篇回到现有历史表,实现分页、排序和可选服务条件;后续 JPA 测试也会复用同一份容器配置。