跳到主内容
创建时间: 2026-09-16最后更新: 2026-09-16

前面的数据库测试依赖开发者先启动本地 PostgreSQL,并提供固定的连接配置。这样的测试会受到本地数据残留、端口占用和数据库版本差异影响,也很难直接放进持续集成环境。

Testcontainers 可以在测试期间启动一个真实的 PostgreSQL 容器。Spring Boot 再通过服务连接取得随机端口、用户名和密码,Flyway 则对空数据库执行与应用相同的迁移。本章沿用「事务」章节的案例,验证完整的数据保存路径。

1. 测试边界

单元测试与数据库集成测试关注的范围不同:

测试类型是否启动 Spring是否连接 PostgreSQL适合验证
单元测试否否Java 分支、校验规则、对象转换
数据库集成测试是是SQL、迁移、约束、事务和结果映射

InMemoryHealthStatusRepositoryTests 属于单元测试,它可以快速验证 Repository 契约,但无法发现 PostgreSQL SQL 语法错误。Testcontainers 测试则会经过下面的实际组件:

testcontainers-flow.txt
1
测试进程
2
-> 启动临时 PostgreSQL 容器
3
-> @ServiceConnection 提供连接信息
4
-> Spring Boot 创建 DataSource
5
-> Flyway 执行 V1、V2
6
-> HealthService 执行事务
7
-> JdbcClient 查询断言
8
-> 测试结束后停止容器

这里不使用 H2 替代 PostgreSQL。当前 SQL 包含 ON CONFLICT、timestamptz 和 PostgreSQL 索引定义,换成另一种数据库会让测试环境与真实语义产生偏差。

Testcontainers 需要本机或 CI 执行环境能够访问兼容的容器运行时。第一次测试还需要拉取 PostgreSQL 镜像,因此通常会比后续执行更慢。

2. 添加测试依赖

在 pom.xml 中增加两个测试范围依赖:

pom.xml
01
<dependency>
02
<groupId>org.springframework.boot</groupId>
03
<artifactId>spring-boot-testcontainers</artifactId>
04
<scope>test</scope>
05
</dependency>
06
07
<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 容器

在测试源码中创建专用配置:

src/test/java/cn/usehook/spring/TestcontainersConfiguration.java
01
package cn.usehook.spring;
02
03
import org.springframework.boot.test.context.TestConfiguration;
04
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
05
import org.springframework.context.annotation.Bean;
06
import org.testcontainers.postgresql.PostgreSQLContainer;
07
08
@TestConfiguration(proxyBeanMethods = false)
09
public class TestcontainersConfiguration {
10
11
@Bean
12
@ServiceConnection
13
PostgreSQLContainer postgresContainer() {
14
return 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. 编写集成测试

创建测试并导入容器配置:

src/test/java/cn/usehook/spring/health/HealthPersistenceIntegrationTests.java
01
package cn.usehook.spring.health;
02
03
import cn.usehook.spring.TestcontainersConfiguration;
04
import org.junit.jupiter.api.Test;
05
import org.springframework.beans.factory.annotation.Autowired;
06
import org.springframework.boot.test.context.SpringBootTest;
07
import org.springframework.context.annotation.Import;
08
import org.springframework.jdbc.core.simple.JdbcClient;
09
import org.springframework.transaction.annotation.Transactional;
10
11
import static org.assertj.core.api.Assertions.assertThat;
12
13
@SpringBootTest
14
@Import(TestcontainersConfiguration.class)
15
@Transactional
16
class HealthPersistenceIntegrationTests {
17
18
private final HealthService healthService;
19
private final HealthStatusRepository repository;
20
private final JdbcClient jdbcClient;
21
22
@Autowired
23
HealthPersistenceIntegrationTests(
24
HealthService healthService,
25
HealthStatusRepository repository,
26
JdbcClient jdbcClient
27
) {
28
this.healthService = healthService;
29
this.repository = repository;
30
this.jdbcClient = jdbcClient;
31
}
32
33
@Test
34
void migratesSchemaAndPersistsHealthCheck() {
35
long migrations = jdbcClient.sql("""
36
select count(*)
37
from flyway_schema_history
38
where success = true
39
""")
40
.query(Long.class)
41
.single();
42
43
HealthStatus result = healthService.check("testcontainers");
44
45
long historyCount = jdbcClient.sql("""
46
select count(*)
47
from health_check_history
48
where service = :service
49
""")
50
.param("service", result.service())
51
.query(Long.class)
52
.single();
53
54
assertThat(migrations).isGreaterThanOrEqualTo(2);
55
assertThat(repository.findByService("testcontainers")).contains(result);
56
assertThat(historyCount).isEqualTo(1);
57
}
58
}

@SpringBootTest 加载完整应用,适合验证 Service 事务、两个 Repository、Flyway 和 PostgreSQL 的协作。@Import 只把测试容器配置加入这次测试上下文。

第一条查询确认 Flyway 已经登记迁移,随后 healthService.check() 在同一业务事务中更新最新状态并插入历史。最后两个断言分别验证最新状态映射和历史写入。

5. 数据隔离

测试类上的 @Transactional 会在每个测试方法前开始测试事务,并在方法结束后默认回滚。HealthService.check() 使用默认的 REQUIRED 传播,因此加入测试已经建立的事务,测试插入的数据不会污染同一容器中的其他测试。

Flyway 迁移发生在测试方法之前,不属于测试方法的回滚范围。表结构和初始数据会保留,只有测试方法中的业务修改被撤销。这个区别正是我们需要的:每个测试共享相同结构,但不共享各自创建的业务数据。

数据库序列的取值通常不会随事务回滚,所以测试不应该假设下一条历史记录的 id 一定连续。测试业务含义,而不是依赖没有写进契约的编号细节。

测试事务也可能掩盖提交之后才发生的问题。需要验证真实提交、跨事务可见性或提交回调时,可以为特定测试关闭测试事务,并显式清理它创建的数据;不要因此默认关闭全部测试隔离。

容器提供进程级隔离,事务提供测试方法级隔离。两者作用不同:容器避免连接到开发数据库,事务让同一测试数据库中的用例互不留下业务数据。

6. 执行与排查

确保容器运行时已经启动,然后执行:

terminal.bash
1
./mvnw -Dtest=HealthPersistenceIntegrationTests test

不需要提供 DB_PASSWORD。测试中的服务连接会覆盖普通数据库配置,容器启动后才生成实际凭据。

遇到失败时,可以按照执行顺序定位:

现象优先检查
无法连接容器运行时Docker 或兼容运行时是否启动、当前用户是否有权限
镜像拉取失败网络、镜像仓库和代理配置
Spring 上下文启动失败@Import、@ServiceConnection 和测试依赖是否完整
Flyway 迁移失败迁移 SQL、文件名、执行版本与 PostgreSQL 日志
业务断言失败Repository SQL、事务边界与行映射

在 CI 中同样需要提供 Testcontainers 可访问的容器运行时,并允许拉取固定镜像。镜像标签应该与项目支持的 PostgreSQL 主版本一致,不使用 latest,避免一次无关的镜像升级突然改变测试环境。

Testcontainers 让数据库测试可重复,但不意味着所有测试都应该加载完整容器。纯业务规则继续使用快速单元测试,只有需要验证真实数据库行为的测试才进入这一层。下一篇回到现有历史表,实现分页、排序和可选服务条件;后续 JPA 测试也会复用同一份容器配置。