Testcontainers
现代化集成测试方案,用真实容器进行测试。
一、Testcontainers 概述
1.1 为什么需要 Testcontainers
在传统的 Java 后端测试中,团队通常面临以下困境:
H2 内存数据库的局限性
- H2 虽然兼容 MySQL / PostgreSQL 的 SQL 方言,但终究是模拟行为
- 生产环境数据库特有的函数、索引类型、锁机制在 H2 中无法完整复现
- 分页 SQL、JSON 函数、全文索引等特性在 H2 与真实数据库之间存在差异
- 每次升级数据库版本时,H2 的模拟行为可能滞后
Mock 框架的不足
- Mockito 等框架擅长模拟外部依赖的返回值,但无法验证真实的网络交互
- 当 Redis / Kafka 的行为发生变更时,Mock 测试无法感知
- Mock 测试通过率很高,但上线后依然出现因中间件差异导致的问题
- 团队需要为每个 Mock 对象编写大量桩代码,维护成本高昂
Testcontainers 的优势
- 在 JUnit 生命周期内启动真实的 Docker 容器
- 测试结束后自动清理容器资源
- 支持绝大多数主流中间件的一键启动
- 无需在开发机或 CI 环境中预先安装特定中间件版本
- 测试代码与生产环境使用完全相同的数据库、消息队列版本
1.2 工作原理
Testcontainers 的核心工作流程如下:
- JUnit 引擎在测试类初始化阶段识别
@Testcontainers注解 - Testcontainers 通过 Docker API(本地 Socket 或 TCP)拉取镜像
- 容器启动后,框架等待容器就绪(通过日志匹配、端口检测或自定义策略)
- 测试代码通过容器暴露的主机名和端口建立连接
- 测试执行完成后,Testcontainers 自动关闭并移除容器
- Ryuk 守护容器(Resource Reaper)作为兜底机制,确保异常退出时也能清理残留容器
架构组件
| 组件 | 角色 |
|---|---|
| Docker 环境 | 本地 Docker Desktop 或远程 Docker 守护进程 |
| Testcontainers 库 | 管理容器生命周期,提供 Java API |
| Ryuk 容器 | 资源回收器,在 JVM 崩溃时清理残留容器 |
| JUnit 扩展 | 将容器生命周期与测试生命周期绑定 |
1.3 对比传统测试方案
| 维度 | Mock / Stub | H2 内存数据库 | Testcontainers |
|---|---|---|---|
| 真实度 | 低 | 中 | 高 |
| 启动速度 | 极快 | 快 | 中(需拉取镜像) |
| 隔离性 | 方法级 | 方法级 | 类级或套件级 |
| 维护成本 | 高(桩代码多) | 低 | 中 |
| 版本一致性 | 无 | 不一定 | 完全一致 |
| CI 兼容性 | 好 | 好 | 需 Docker 环境 |
| 适用场景 | 纯逻辑测试 | 简单 CRUD | 集成测试 |
1.4 适用场景
- 数据库集成测试:验证 JPA / MyBatis 的 SQL 执行结果、事务行为、锁机制
- 消息队列测试:验证消息发送与消费的生产者-消费者场景
- 缓存测试:验证 Redis 缓存命中、过期策略、序列化行为
- 搜索引擎测试:验证 Elasticsearch 索引构建与搜索查询
- 外部服务测试:验证 HTTP 客户端调用第三方服务的行为
- 数据迁移测试:验证 Flyway / Liquibase 迁移脚本的正确性
二、快速开始
2.1 Maven 依赖
<properties>
<testcontainers.version>1.20.4</testcontainers.version>
</properties>
<dependencies>
<!-- JUnit 5 集成 -->
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers</artifactId>
<version>${testcontainers.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<version>${testcontainers.version}</version>
<scope>test</scope>
</dependency>
<!-- MySQL 模块 -->
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>mysql</artifactId>
<version>${testcontainers.version}</version>
<scope>test</scope>
</dependency>
</dependencies><properties>
<testcontainers.version>1.20.4</testcontainers.version>
</properties>
<dependencies>
<!-- JUnit 5 集成 -->
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers</artifactId>
<version>${testcontainers.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<version>${testcontainers.version}</version>
<scope>test</scope>
</dependency>
<!-- MySQL 模块 -->
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>mysql</artifactId>
<version>${testcontainers.version}</version>
<scope>test</scope>
</dependency>
</dependencies>2.2 Gradle 依赖
testImplementation platform('org.testcontainers:testcontainers-bom:1.20.4')
testImplementation 'org.testcontainers:testcontainers'
testImplementation 'org.testcontainers:junit-jupiter'
testImplementation 'org.testcontainers:mysql'testImplementation platform('org.testcontainers:testcontainers-bom:1.20.4')
testImplementation 'org.testcontainers:testcontainers'
testImplementation 'org.testcontainers:junit-jupiter'
testImplementation 'org.testcontainers:mysql'2.3 JUnit 5 集成
Testcontainers 通过 JUnit 5 的扩展机制与测试生命周期无缝集成。
核心注解
| 注解 | 作用 |
|---|---|
@Testcontainers | 标记测试类启用 Testcontainers 扩展 |
@Container | 标记容器字段,由 Testcontainers 管理生命周期 |
2.4 第一个测试:MySQL 容器
import org.junit.jupiter.api.Test;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.containers.MySQLContainer;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.ResultSet;
import static org.junit.jupiter.api.Assertions.assertEquals;
@Testcontainers
class MySQLSimpleTest {
@Container
private MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test");
@Test
void shouldQueryDatabase() throws Exception {
// 容器启动后,自动分配可用的主机端口
String jdbcUrl = mysql.getJdbcUrl();
String username = mysql.getUsername();
String password = mysql.getPassword();
try (Connection conn = DriverManager.getConnection(jdbcUrl, username, password)) {
var stmt = conn.createStatement();
stmt.execute("CREATE TABLE users (id INT PRIMARY KEY, name VARCHAR(50))");
stmt.execute("INSERT INTO users VALUES (1, 'Alice')");
ResultSet rs = stmt.executeQuery("SELECT name FROM users WHERE id = 1");
rs.next();
assertEquals("Alice", rs.getString("name"));
}
}
}import org.junit.jupiter.api.Test;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.containers.MySQLContainer;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.ResultSet;
import static org.junit.jupiter.api.Assertions.assertEquals;
@Testcontainers
class MySQLSimpleTest {
@Container
private MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test");
@Test
void shouldQueryDatabase() throws Exception {
// 容器启动后,自动分配可用的主机端口
String jdbcUrl = mysql.getJdbcUrl();
String username = mysql.getUsername();
String password = mysql.getPassword();
try (Connection conn = DriverManager.getConnection(jdbcUrl, username, password)) {
var stmt = conn.createStatement();
stmt.execute("CREATE TABLE users (id INT PRIMARY KEY, name VARCHAR(50))");
stmt.execute("INSERT INTO users VALUES (1, 'Alice')");
ResultSet rs = stmt.executeQuery("SELECT name FROM users WHERE id = 1");
rs.next();
assertEquals("Alice", rs.getString("name"));
}
}
}2.5 容器启动策略
Testcontainers 提供了多种容器就绪检测策略:
- 默认策略:等待容器暴露的 TCP 端口可连接
- 日志策略:等待容器日志中出现特定文本(如
ready for connections) - HTTP 策略:等待 HTTP 端点返回预期状态码
- 组合策略:同时等待多个条件
@Container
private MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0")
.withStartupTimeout(Duration.ofSeconds(60))
.waitingFor(Wait.forLogMessage(".*ready for connections.*", 1));@Container
private MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0")
.withStartupTimeout(Duration.ofSeconds(60))
.waitingFor(Wait.forLogMessage(".*ready for connections.*", 1));三、模块化容器
Testcontainers 为常见中间件提供了开箱即用的模块化容器类。
3.1 MySQL
@Container
private MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0")
.withDatabaseName("integration_test")
.withUsername("test")
.withPassword("secret")
.withInitScript("init-schema.sql"); // 初始化脚本@Container
private MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0")
.withDatabaseName("integration_test")
.withUsername("test")
.withPassword("secret")
.withInitScript("init-schema.sql"); // 初始化脚本配置参数
| 方法 | 说明 |
|---|---|
withDatabaseName() | 设置数据库名称 |
withUsername() / withPassword() | 设置认证信息 |
withInitScript() | 指定初始化 SQL 脚本 |
withUrlParam() | 添加 JDBC URL 参数 |
withCommand() | 覆盖容器启动命令 |
3.2 PostgreSQL
PostgreSQL 容器支持额外的 PostgreSQL 特定配置:
@Container
private PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test");@Container
private PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test");PostgreSQL 特色特性
- 支持 PostGIS 扩展(通过
withInitScript启用) - 支持 TimescaleDB / Citus 等 PG 生态镜像
- 支持
pg_hba.conf自定义配置
3.3 Redis
@Container
private GenericContainer<?> redis = new GenericContainer<>("redis:7-alpine")
.withExposedPorts(6379);
// 获取连接地址
String host = redis.getHost();
Integer port = redis.getMappedPort(6379);
// 用于 Jedis / Lettuce 连接@Container
private GenericContainer<?> redis = new GenericContainer<>("redis:7-alpine")
.withExposedPorts(6379);
// 获取连接地址
String host = redis.getHost();
Integer port = redis.getMappedPort(6379);
// 用于 Jedis / Lettuce 连接或者使用专用模块(如果项目中添加了 testcontainers-redis 依赖):
// RedisContainer 提供更丰富的等待策略
@Container
private RedisContainer redis = new RedisContainer("redis:7-alpine");// RedisContainer 提供更丰富的等待策略
@Container
private RedisContainer redis = new RedisContainer("redis:7-alpine");3.4 Kafka
@Container
private KafkaContainer kafka = new KafkaContainer(
DockerImageName.parse("confluentinc/cp-kafka:7.6.0")
);
@Test
void testKafkaProducerAndConsumer() {
String bootstrapServers = kafka.getBootstrapServers();
// 使用 Kafka 客户端发送和消费消息
Properties props = new Properties();
props.put("bootstrap.servers", bootstrapServers);
props.put("key.serializer", "org.apache.kafka.common.serialization.StringSerializer");
props.put("value.serializer", "org.apache.kafka.common.serialization.StringSerializer");
// 执行生产-消费测试...
}@Container
private KafkaContainer kafka = new KafkaContainer(
DockerImageName.parse("confluentinc/cp-kafka:7.6.0")
);
@Test
void testKafkaProducerAndConsumer() {
String bootstrapServers = kafka.getBootstrapServers();
// 使用 Kafka 客户端发送和消费消息
Properties props = new Properties();
props.put("bootstrap.servers", bootstrapServers);
props.put("key.serializer", "org.apache.kafka.common.serialization.StringSerializer");
props.put("value.serializer", "org.apache.kafka.common.serialization.StringSerializer");
// 执行生产-消费测试...
}Kafka 容器特性
- 自动处理 Kafka 内部配置(如
KAFKA_ADVERTISED_LISTENERS) - 支持 KRaft 模式(无 ZooKeeper)
- 可与 Schema Registry 容器配合使用
3.5 RabbitMQ
@Container
private RabbitMQContainer rabbit = new RabbitMQContainer("rabbitmq:3.13-management")
.withExchange("test.exchange", "direct")
.withQueue("test.queue")
.withBinding("test.exchange", "test.queue", "test.key");
@Test
void testRabbitMQ() {
String amqpUrl = rabbit.getAmqpUrl();
Integer httpPort = rabbit.getHttpPort(); // 管理控制台端口
// 通过 AMQP 协议发送 / 接收消息
}@Container
private RabbitMQContainer rabbit = new RabbitMQContainer("rabbitmq:3.13-management")
.withExchange("test.exchange", "direct")
.withQueue("test.queue")
.withBinding("test.exchange", "test.queue", "test.key");
@Test
void testRabbitMQ() {
String amqpUrl = rabbit.getAmqpUrl();
Integer httpPort = rabbit.getHttpPort(); // 管理控制台端口
// 通过 AMQP 协议发送 / 接收消息
}3.6 Elasticsearch
@Container
private ElasticsearchContainer elasticsearch = new ElasticsearchContainer(
DockerImageName.parse("docker.elastic.co/elasticsearch/elasticsearch:8.14.0")
.asCompatibleSubstituteFor("docker.elastic.co/elasticsearch/elasticsearch")
);
@Test
void testElasticsearch() {
String httpHostAddress = elasticsearch.getHttpHostAddress();
// 使用 Elasticsearch Client 进行搜索测试
RestClient client = RestClient.builder(
HttpHost.create(httpHostAddress)
).build();
// 构建索引 → 写入文档 → 搜索验证
}@Container
private ElasticsearchContainer elasticsearch = new ElasticsearchContainer(
DockerImageName.parse("docker.elastic.co/elasticsearch/elasticsearch:8.14.0")
.asCompatibleSubstituteFor("docker.elastic.co/elasticsearch/elasticsearch")
);
@Test
void testElasticsearch() {
String httpHostAddress = elasticsearch.getHttpHostAddress();
// 使用 Elasticsearch Client 进行搜索测试
RestClient client = RestClient.builder(
HttpHost.create(httpHostAddress)
).build();
// 构建索引 → 写入文档 → 搜索验证
}3.7 MongoDB
@Container
private MongoDBContainer mongoDB = new MongoDBContainer("mongo:7.0");
@Test
void testMongoDB() {
String connectionString = mongoDB.getConnectionString();
// 使用 MongoDB Driver 进行操作
MongoClient client = MongoClients.create(connectionString);
MongoDatabase db = client.getDatabase("testdb");
MongoCollection<Document> collection = db.getCollection("users");
collection.insertOne(new Document("name", "Alice").append("age", 30));
Document found = collection.find(Filters.eq("name", "Alice")).first();
assertEquals("Alice", found.getString("name"));
}@Container
private MongoDBContainer mongoDB = new MongoDBContainer("mongo:7.0");
@Test
void testMongoDB() {
String connectionString = mongoDB.getConnectionString();
// 使用 MongoDB Driver 进行操作
MongoClient client = MongoClients.create(connectionString);
MongoDatabase db = client.getDatabase("testdb");
MongoCollection<Document> collection = db.getCollection("users");
collection.insertOne(new Document("name", "Alice").append("age", 30));
Document found = collection.find(Filters.eq("name", "Alice")).first();
assertEquals("Alice", found.getString("name"));
}3.8 GenericContainer(自定义镜像)
对于 Testcontainers 未提供专用模块的中间件,可以使用 GenericContainer 启动任意 Docker 镜像:
@Container
private GenericContainer<?> nginx = new GenericContainer<>("nginx:1.27-alpine")
.withExposedPorts(80)
.withEnv("NGINX_HOST", "localhost")
.withCommand("nginx", "-g", "daemon off;")
.waitingFor(Wait.forHttp("/"));
@Test
void testNginx() {
String url = String.format("http://%s:%d",
nginx.getHost(), nginx.getMappedPort(80));
// 对 Nginx 进行 HTTP 测试
RestTemplate rest = new RestTemplate();
String response = rest.getForObject(url, String.class);
assertNotNull(response);
}@Container
private GenericContainer<?> nginx = new GenericContainer<>("nginx:1.27-alpine")
.withExposedPorts(80)
.withEnv("NGINX_HOST", "localhost")
.withCommand("nginx", "-g", "daemon off;")
.waitingFor(Wait.forHttp("/"));
@Test
void testNginx() {
String url = String.format("http://%s:%d",
nginx.getHost(), nginx.getMappedPort(80));
// 对 Nginx 进行 HTTP 测试
RestTemplate rest = new RestTemplate();
String response = rest.getForObject(url, String.class);
assertNotNull(response);
}GenericContainer 常用方法
| 方法 | 说明 |
|---|---|
withExposedPorts(Integer...) | 声明需要暴露的容器端口 |
withEnv(String, String) | 设置环境变量 |
withCommand(String...) | 覆盖容器入口命令 |
withNetwork() / withNetworkAliases() | 设置网络与别名 |
withClasspathResourceMapping() | 将类路径资源映射到容器 |
withFileSystemBind() | 绑定宿主机目录 |
waitingFor(WaitStrategy) | 设置等待策略 |
四、Spring Boot 集成
4.1 @DynamicPropertySource 动态配置
在 Spring Boot 测试中,容器启动后才能确定端口和连接地址。@DynamicPropertySource 可以将容器属性动态注入 Spring Environment:
@SpringBootTest
@Testcontainers
class UserRepositoryTest {
@Container
static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0");
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", mysql::getJdbcUrl);
registry.add("spring.datasource.username", mysql::getUsername);
registry.add("spring.datasource.password", mysql::getPassword);
registry.add("spring.datasource.driver-class-name", () -> "com.mysql.cj.jdbc.Driver");
}
@Autowired
private UserRepository userRepository;
@Test
void shouldSaveUser() {
User user = new User("Alice", "alice@example.com");
userRepository.save(user);
Optional<User> found = userRepository.findByName("Alice");
assertTrue(found.isPresent());
assertEquals("alice@example.com", found.get().getEmail());
}
}@SpringBootTest
@Testcontainers
class UserRepositoryTest {
@Container
static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0");
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", mysql::getJdbcUrl);
registry.add("spring.datasource.username", mysql::getUsername);
registry.add("spring.datasource.password", mysql::getPassword);
registry.add("spring.datasource.driver-class-name", () -> "com.mysql.cj.jdbc.Driver");
}
@Autowired
private UserRepository userRepository;
@Test
void shouldSaveUser() {
User user = new User("Alice", "alice@example.com");
userRepository.save(user);
Optional<User> found = userRepository.findByName("Alice");
assertTrue(found.isPresent());
assertEquals("alice@example.com", found.get().getEmail());
}
}4.2 静态容器(所有测试共用)
将容器字段声明为 static,并在测试类上使用 @Testcontainers,可使该容器在所有测试方法之间共享:
@SpringBootTest
@Testcontainers
class ApplicationIntegrationTest {
@Container
static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0");
@Container
static GenericContainer<?> redis = new GenericContainer<>("redis:7-alpine")
.withExposedPorts(6379);
@DynamicPropertySource
static void configure(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", mysql::getJdbcUrl);
registry.add("spring.datasource.username", mysql::getUsername);
registry.add("spring.datasource.password", mysql::getPassword);
registry.add("spring.data.redis.host", redis::getHost);
registry.add("spring.data.redis.port", () -> redis.getMappedPort(6379));
}
// 所有测试共用同一个 MySQL + Redis 实例
}@SpringBootTest
@Testcontainers
class ApplicationIntegrationTest {
@Container
static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0");
@Container
static GenericContainer<?> redis = new GenericContainer<>("redis:7-alpine")
.withExposedPorts(6379);
@DynamicPropertySource
static void configure(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", mysql::getJdbcUrl);
registry.add("spring.datasource.username", mysql::getUsername);
registry.add("spring.datasource.password", mysql::getPassword);
registry.add("spring.data.redis.host", redis::getHost);
registry.add("spring.data.redis.port", () -> redis.getMappedPort(6379));
}
// 所有测试共用同一个 MySQL + Redis 实例
}静态 vs 非静态容器
| 类型 | 生命周期 | 启动次数 | 隔离性 |
|---|---|---|---|
| 非静态字段 | 每个测试方法 | 每次测试都启动 | 最高 |
| 静态字段 | 整个测试类 | 类级别执行一次 | 中等 |
@Testcontainers(parallel = true) | 并行测试 | 线程级 | 取决于配置 |
4.3 与 Spring Boot Test 深度结合
使用 @TestConfiguration 配置容器化 Bean
@TestConfiguration
class TestContainerConfig {
private static final MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0");
static {
mysql.start();
}
@Bean
public DataSource dataSource() {
var ds = new DataSource();
ds.setUrl(mysql.getJdbcUrl());
ds.setUsername(mysql.getUsername());
ds.setPassword(mysql.getPassword());
ds.setDriverClassName(mysql.getDriverClassName());
return ds;
}
}@TestConfiguration
class TestContainerConfig {
private static final MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0");
static {
mysql.start();
}
@Bean
public DataSource dataSource() {
var ds = new DataSource();
ds.setUrl(mysql.getJdbcUrl());
ds.setUsername(mysql.getUsername());
ds.setPassword(mysql.getPassword());
ds.setDriverClassName(mysql.getDriverClassName());
return ds;
}
}抽象测试基类
@Testcontainers
public abstract class IntegrationTestBase {
@Container
protected static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0");
@Container
protected static GenericContainer<?> redis = new GenericContainer<>("redis:7-alpine")
.withExposedPorts(6379);
@DynamicPropertySource
static void setup(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", mysql::getJdbcUrl);
registry.add("spring.datasource.username", mysql::getUsername);
registry.add("spring.datasource.password", mysql::getPassword);
registry.add("spring.data.redis.host", redis::getHost);
registry.add("spring.data.redis.port", () -> redis.getMappedPort(6379));
}
}@Testcontainers
public abstract class IntegrationTestBase {
@Container
protected static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0");
@Container
protected static GenericContainer<?> redis = new GenericContainer<>("redis:7-alpine")
.withExposedPorts(6379);
@DynamicPropertySource
static void setup(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", mysql::getJdbcUrl);
registry.add("spring.datasource.username", mysql::getUsername);
registry.add("spring.datasource.password", mysql::getPassword);
registry.add("spring.data.redis.host", redis::getHost);
registry.add("spring.data.redis.port", () -> redis.getMappedPort(6379));
}
}使用 @ServiceConnection(Spring Boot 3.1+)
Spring Boot 3.1 引入了 @ServiceConnection 注解,可自动将容器连接信息注入到自动配置中:
@SpringBootTest
@Testcontainers
class ApplicationTest {
@Container
@ServiceConnection
static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0");
@Container
@ServiceConnection
static GenericContainer<?> redis = new GenericContainer<>("redis:7-alpine")
.withExposedPorts(6379);
// 无需 @DynamicPropertySource,@ServiceConnection 自动处理数据源配置
}@SpringBootTest
@Testcontainers
class ApplicationTest {
@Container
@ServiceConnection
static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0");
@Container
@ServiceConnection
static GenericContainer<?> redis = new GenericContainer<>("redis:7-alpine")
.withExposedPorts(6379);
// 无需 @DynamicPropertySource,@ServiceConnection 自动处理数据源配置
}使用 ConnectionDetails(Spring Boot 3.1+)
@Bean
@ServiceConnection
MySQLContainer<?> mysqlContainer() {
return new MySQLContainer<>("mysql:8.0");
}@Bean
@ServiceConnection
MySQLContainer<?> mysqlContainer() {
return new MySQLContainer<>("mysql:8.0");
}Spring Boot 自动创建 DataSourceConnectionDetails Bean,无需手动配置连接属性。
4.4 测试切片与容器
在 Spring Boot 中,测试切片如 @DataJpaTest 默认不加载完整上下文。需要手动引入容器配置:
@DataJpaTest
@ImportAutoConfiguration(TestDatabaseAutoConfiguration.class)
@Testcontainers
class UserRepositoryJpaTest {
@Container
static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0");
@DynamicPropertySource
static void props(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", mysql::getJdbcUrl);
registry.add("spring.datasource.username", mysql::getUsername);
registry.add("spring.datasource.password", mysql::getPassword);
}
@Autowired
private TestEntityManager entityManager;
@Autowired
private UserRepository userRepository;
@Test
void testJpaMapping() {
User user = new User("Bob", "bob@test.com");
entityManager.persistAndFlush(user);
Optional<User> result = userRepository.findById(user.getId());
assertTrue(result.isPresent());
assertEquals("bob@test.com", result.get().getEmail());
}
}@DataJpaTest
@ImportAutoConfiguration(TestDatabaseAutoConfiguration.class)
@Testcontainers
class UserRepositoryJpaTest {
@Container
static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0");
@DynamicPropertySource
static void props(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", mysql::getJdbcUrl);
registry.add("spring.datasource.username", mysql::getUsername);
registry.add("spring.datasource.password", mysql::getPassword);
}
@Autowired
private TestEntityManager entityManager;
@Autowired
private UserRepository userRepository;
@Test
void testJpaMapping() {
User user = new User("Bob", "bob@test.com");
entityManager.persistAndFlush(user);
Optional<User> result = userRepository.findById(user.getId());
assertTrue(result.isPresent());
assertEquals("bob@test.com", result.get().getEmail());
}
}五、高级用法
5.1 Docker Compose 支持
Testcontainers 支持通过 DockerComposeContainer 启动 docker-compose.yml 中定义的多服务环境:
@Testcontainers
class DockerComposeTest {
@Container
static DockerComposeContainer<?> environment =
new DockerComposeContainer<>(
new File("src/test/resources/docker-compose-test.yml")
)
.withExposedService("mysql_1", 3306)
.withExposedService("redis_1", 6379);
@Test
void testServices() {
String mysqlHost = environment.getServiceHost("mysql_1", 3306);
Integer mysqlPort = environment.getServicePort("mysql_1", 3306);
String redisHost = environment.getServiceHost("redis_1", 6379);
Integer redisPort = environment.getServicePort("redis_1", 6379);
assertNotNull(mysqlHost);
assertNotNull(redisHost);
}
}@Testcontainers
class DockerComposeTest {
@Container
static DockerComposeContainer<?> environment =
new DockerComposeContainer<>(
new File("src/test/resources/docker-compose-test.yml")
)
.withExposedService("mysql_1", 3306)
.withExposedService("redis_1", 6379);
@Test
void testServices() {
String mysqlHost = environment.getServiceHost("mysql_1", 3306);
Integer mysqlPort = environment.getServicePort("mysql_1", 3306);
String redisHost = environment.getServiceHost("redis_1", 6379);
Integer redisPort = environment.getServicePort("redis_1", 6379);
assertNotNull(mysqlHost);
assertNotNull(redisHost);
}
}docker-compose-test.yml 示例
version: "3.8"
services:
mysql:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: test
MYSQL_DATABASE: testdb
redis:
image: redis:7-alpine
kafka:
image: confluentinc/cp-kafka:7.6.0
environment:
KAFKA_BROKER_ID: 1
KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092
zookeeper:
image: confluentinc/cp-zookeeper:7.6.0
environment:
ZOOKEEPER_CLIENT_PORT: 2181version: "3.8"
services:
mysql:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: test
MYSQL_DATABASE: testdb
redis:
image: redis:7-alpine
kafka:
image: confluentinc/cp-kafka:7.6.0
environment:
KAFKA_BROKER_ID: 1
KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092
zookeeper:
image: confluentinc/cp-zookeeper:7.6.0
environment:
ZOOKEEPER_CLIENT_PORT: 2181Docker Compose 容器注意事项
- Docker Compose 容器的服务名可能与容器名不同,注意使用正确的服务标识符
- 可以指定
withStartupTimeout()控制整体启动超时 - 支持
withEnv()覆盖 Compose 文件中的环境变量 - 支持
withLocalCompose(true)使用本地安装的docker-compose命令
5.2 网络(Network)+ 多容器通信
当多个容器需要互相通信时,可以创建自定义网络并为容器设置别名:
@Testcontainers
class MultiContainerNetworkTest {
static Network network = Network.newNetwork();
@Container
static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0")
.withNetwork(network)
.withNetworkAliases("mysql-db");
@Container
static GenericContainer<?> app = new GenericContainer<>("my-app:latest")
.withNetwork(network)
.withNetworkAliases("app")
.withEnv("SPRING_DATASOURCE_URL",
"jdbc:mysql://mysql-db:3306/testdb")
.withExposedPorts(8080)
.dependsOn(mysql);
@Test
void testAppCanConnectToDatabase() {
// app 容器可以通过网络别名 "mysql-db" 访问 MySQL 容器
Integer appPort = app.getMappedPort(8080);
String response = RestTemplate.getForObject(
"http://" + app.getHost() + ":" + appPort + "/health",
String.class
);
assertTrue(response.contains("UP"));
}
}@Testcontainers
class MultiContainerNetworkTest {
static Network network = Network.newNetwork();
@Container
static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0")
.withNetwork(network)
.withNetworkAliases("mysql-db");
@Container
static GenericContainer<?> app = new GenericContainer<>("my-app:latest")
.withNetwork(network)
.withNetworkAliases("app")
.withEnv("SPRING_DATASOURCE_URL",
"jdbc:mysql://mysql-db:3306/testdb")
.withExposedPorts(8080)
.dependsOn(mysql);
@Test
void testAppCanConnectToDatabase() {
// app 容器可以通过网络别名 "mysql-db" 访问 MySQL 容器
Integer appPort = app.getMappedPort(8080);
String response = RestTemplate.getForObject(
"http://" + app.getHost() + ":" + appPort + "/health",
String.class
);
assertTrue(response.contains("UP"));
}
}网络类型
| 网络模式 | 说明 | 使用场景 |
|---|---|---|
Network.newNetwork() | 创建 Bridge 网络 | 多容器互联 |
Network.SHARED | 共享网络 | 与宿主机或其他测试共用网络 |
Network.newNetwork(Network.NetworkImpl.BRIDGE) | 显式指定桥接 | 兼容性要求 |
5.3 可重复测试(静态容器 + 数据清理)
在多测试方法之间共享静态容器时,需要确保测试之间互不干扰:
@SpringBootTest
@Testcontainers
class ReusableContainerTest {
@Container
static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0");
@DynamicPropertySource
static void props(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", mysql::getJdbcUrl);
registry.add("spring.datasource.username", mysql::getUsername);
registry.add("spring.datasource.password", mysql::getPassword);
}
@Autowired
private JdbcTemplate jdbcTemplate;
@BeforeEach
void cleanUp() {
// 每个测试方法执行前清理数据
jdbcTemplate.execute("TRUNCATE TABLE users");
jdbcTemplate.execute("TRUNCATE TABLE orders");
}
@Test
void testInsertUser() {
jdbcTemplate.update("INSERT INTO users (id, name) VALUES (1, 'Alice')");
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM users", Integer.class
);
assertEquals(1, count);
}
@Test
void testAnotherOperation() {
// 由于 @BeforeEach 清理了数据,此测试拥有干净的状态
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM users", Integer.class
);
assertEquals(0, count);
}
}@SpringBootTest
@Testcontainers
class ReusableContainerTest {
@Container
static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0");
@DynamicPropertySource
static void props(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", mysql::getJdbcUrl);
registry.add("spring.datasource.username", mysql::getUsername);
registry.add("spring.datasource.password", mysql::getPassword);
}
@Autowired
private JdbcTemplate jdbcTemplate;
@BeforeEach
void cleanUp() {
// 每个测试方法执行前清理数据
jdbcTemplate.execute("TRUNCATE TABLE users");
jdbcTemplate.execute("TRUNCATE TABLE orders");
}
@Test
void testInsertUser() {
jdbcTemplate.update("INSERT INTO users (id, name) VALUES (1, 'Alice')");
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM users", Integer.class
);
assertEquals(1, count);
}
@Test
void testAnotherOperation() {
// 由于 @BeforeEach 清理了数据,此测试拥有干净的状态
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM users", Integer.class
);
assertEquals(0, count);
}
}清理策略选择
| 策略 | 优点 | 缺点 |
|---|---|---|
@BeforeEach 清表 | 简单直接 | 有外键时需注意顺序 |
事务回滚(@Transactional) | 自动回滚 | 限制某些测试场景 |
| 每次重新创建 Schema | 彻底隔离 | 执行耗时 |
| 不同测试使用不同数据库 | 完全隔离 | 数据库数量有限 |
5.4 容器执行命令与复制文件
在容器内部执行命令
@Test
void executeCommandInContainer() {
Container.ExecResult result = mysql.execInContainer(
"mysql",
"-u", "test",
"-ptest",
"-e", "SELECT COUNT(*) FROM users"
);
assertEquals(0, result.getExitCode());
System.out.println(result.getStdout());
}@Test
void executeCommandInContainer() {
Container.ExecResult result = mysql.execInContainer(
"mysql",
"-u", "test",
"-ptest",
"-e", "SELECT COUNT(*) FROM users"
);
assertEquals(0, result.getExitCode());
System.out.println(result.getStdout());
}复制文件到容器
@Test
void copyFileToContainer() throws Exception {
// 将类路径下的文件复制到容器中
mysql.copyFileToContainer(
MountableFile.forClasspathResource("data/import.sql"),
"/docker-entrypoint-initdb.d/import.sql"
);
}@Test
void copyFileToContainer() throws Exception {
// 将类路径下的文件复制到容器中
mysql.copyFileToContainer(
MountableFile.forClasspathResource("data/import.sql"),
"/docker-entrypoint-initdb.d/import.sql"
);
}从容器复制文件
@Test
void copyFileFromContainer() throws Exception {
// 从容器中复制文件到指定路径
mysql.copyFileFromContainer(
"/var/lib/mysql/ibdata1",
"target/backup/ibdata1"
);
}@Test
void copyFileFromContainer() throws Exception {
// 从容器中复制文件到指定路径
mysql.copyFileFromContainer(
"/var/lib/mysql/ibdata1",
"target/backup/ibdata1"
);
}5.5 自定义容器镜像
基于 Dockerfile 构建
@Testcontainers
class CustomImageTest {
@Container
private GenericContainer<?> customApp = new GenericContainer<>(
new ImageFromDockerfile("my-custom-app", false)
.withDockerfileFromBuilder(builder -> builder
.from("openjdk:21-slim")
.copy("app.jar", "/app/app.jar")
.workDir("/app")
.cmd("java", "-jar", "app.jar")
)
.withFileFromClasspath("app.jar", "apps/my-app.jar")
)
.withExposedPorts(8080);
@Test
void testCustomApp() {
// 测试自定义构建的容器
}
}@Testcontainers
class CustomImageTest {
@Container
private GenericContainer<?> customApp = new GenericContainer<>(
new ImageFromDockerfile("my-custom-app", false)
.withDockerfileFromBuilder(builder -> builder
.from("openjdk:21-slim")
.copy("app.jar", "/app/app.jar")
.workDir("/app")
.cmd("java", "-jar", "app.jar")
)
.withFileFromClasspath("app.jar", "apps/my-app.jar")
)
.withExposedPorts(8080);
@Test
void testCustomApp() {
// 测试自定义构建的容器
}
}基于现有镜像扩展
@Container
private GenericContainer<?> extendedPostgres = new GenericContainer<>(
DockerImageName.parse("postgres:16")
.asCompatibleSubstituteFor("postgres")
)
.withEnv("POSTGRES_DB", "testdb")
.withEnv("POSTGRES_USER", "test")
.withEnv("POSTGRES_PASSWORD", "test")
.withCommand("postgres", "-c", "max_connections=200")
.withExposedPorts(5432);@Container
private GenericContainer<?> extendedPostgres = new GenericContainer<>(
DockerImageName.parse("postgres:16")
.asCompatibleSubstituteFor("postgres")
)
.withEnv("POSTGRES_DB", "testdb")
.withEnv("POSTGRES_USER", "test")
.withEnv("POSTGRES_PASSWORD", "test")
.withCommand("postgres", "-c", "max_connections=200")
.withExposedPorts(5432);5.6 数据库迁移测试
与 Flyway 或 Liquibase 结合,验证迁移脚本的正确性:
@SpringBootTest
@Testcontainers
class FlywayMigrationTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16");
@DynamicPropertySource
static void configure(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
@Autowired
private Flyway flyway;
@Test
void shouldApplyMigrationsSuccessfully() {
// Flyway 在 Spring 上下文初始化时会自动执行迁移
var info = flyway.info();
assertFalse(info.all().length == 0);
assertEquals("1.0", info.current().getVersion().toString());
}
@Test
void shouldHaveExpectedSchemaAfterMigration() {
// 验证迁移后的数据库结构
jdbcTemplate.queryForList("SHOW TABLES");
// 断言表结构符合预期
}
}@SpringBootTest
@Testcontainers
class FlywayMigrationTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16");
@DynamicPropertySource
static void configure(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
@Autowired
private Flyway flyway;
@Test
void shouldApplyMigrationsSuccessfully() {
// Flyway 在 Spring 上下文初始化时会自动执行迁移
var info = flyway.info();
assertFalse(info.all().length == 0);
assertEquals("1.0", info.current().getVersion().toString());
}
@Test
void shouldHaveExpectedSchemaAfterMigration() {
// 验证迁移后的数据库结构
jdbcTemplate.queryForList("SHOW TABLES");
// 断言表结构符合预期
}
}5.7 并行测试
Testcontainers 支持并行测试执行,每个测试线程使用独立的容器:
@Testcontainers(parallel = true)
class ParallelContainerTest {
// 每个并行执行的测试方法会获得独立的容器实例
@Container
private MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0");
@Test
void testOne() { /* 使用独立的 MySQL 实例 */ }
@Test
void testTwo() { /* 使用另一个独立的 MySQL 实例 */ }
}@Testcontainers(parallel = true)
class ParallelContainerTest {
// 每个并行执行的测试方法会获得独立的容器实例
@Container
private MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0");
@Test
void testOne() { /* 使用独立的 MySQL 实例 */ }
@Test
void testTwo() { /* 使用另一个独立的 MySQL 实例 */ }
}并行测试配置要点
- 确保 Docker 环境有足够的资源(CPU、内存)
- 使用 Maven Surefire 或 Gradle 的
maxParallelForks配置 - 为每个容器实例分配唯一的数据库名称或资源标识
- 注意宿主机端口冲突(容器端口映射到宿主机的随机端口)
5.8 容器日志与调试
@Container
private MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0")
.withLogConsumer(new Slf4jLogConsumer(LoggerFactory.getLogger("testcontainers.mysql")));
// 或直接输出到 stdout
@Container
private GenericContainer<?> app = new GenericContainer<>("my-app")
.withLogConsumer(outputFrame -> System.out.print(outputFrame.getUtf8String()));@Container
private MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0")
.withLogConsumer(new Slf4jLogConsumer(LoggerFactory.getLogger("testcontainers.mysql")));
// 或直接输出到 stdout
@Container
private GenericContainer<?> app = new GenericContainer<>("my-app")
.withLogConsumer(outputFrame -> System.out.print(outputFrame.getUtf8String()));六、最佳实践
6.1 容器资源管理(Ryuk 清理)
Testcontainers 使用 Ryuk(Resource Reaper)容器来自动清理测试结束后残留的容器。
Ryuk 工作原理
- JVM 启动后,Testcontainers 自动启动 Ryuk 容器
- Ryuk 与主 JVM 之间维持一个 TCP 连接
- 当 JVM 正常退出或崩溃时,TCP 连接断开
- Ryuk 检测到连接断开后,清理所有由 Testcontainers 创建的容器
禁用 Ryuk(不推荐)
# 在 testcontainers.properties 或系统属性中设置
testcontainers.ryuk.disabled=true# 在 testcontainers.properties 或系统属性中设置
testcontainers.ryuk.disabled=true手动清理
// 调用后立即停止并移除容器
mysql.stop();// 调用后立即停止并移除容器
mysql.stop();配置资源限制
@Container
private GenericContainer<?> container = new GenericContainer<>("my-service")
.withCreateContainerCmdModifier(cmd -> cmd
.withHostConfig(
new HostConfig()
.withMemory(512 * 1024 * 1024L) // 512MB
.withCpuCount(2L) // 2 核
.withMemorySwap(0L) // 禁用 swap
)
);@Container
private GenericContainer<?> container = new GenericContainer<>("my-service")
.withCreateContainerCmdModifier(cmd -> cmd
.withHostConfig(
new HostConfig()
.withMemory(512 * 1024 * 1024L) // 512MB
.withCpuCount(2L) // 2 核
.withMemorySwap(0L) // 禁用 swap
)
);6.2 测试超时设置
容器启动超时
@Container
private MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0")
.withStartupTimeout(Duration.ofMinutes(3));@Container
private MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0")
.withStartupTimeout(Duration.ofMinutes(3));JUnit 测试方法超时
@Test
@Timeout(value = 30, unit = TimeUnit.SECONDS)
void testWithTimeout() {
// 如果测试执行超过 30 秒,自动失败
}@Test
@Timeout(value = 30, unit = TimeUnit.SECONDS)
void testWithTimeout() {
// 如果测试执行超过 30 秒,自动失败
}全局超时配置
# src/test/resources/testcontainers.properties
testcontainers.container.startup-timeout=120# src/test/resources/testcontainers.properties
testcontainers.container.startup-timeout=1206.3 CI 环境配置
Docker in Docker(Docker)
在 Jenkins / GitLab CI / GitHub Actions 中使用 Docker 套接字绑定:
# GitHub Actions 示例
jobs:
test:
runs-on: ubuntu-latest
services:
docker:
image: docker:dind
options: --privileged
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
java-version: "21"
distribution: "temurin"
- run: ./mvnw verify# GitHub Actions 示例
jobs:
test:
runs-on: ubuntu-latest
services:
docker:
image: docker:dind
options: --privileged
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
java-version: "21"
distribution: "temurin"
- run: ./mvnw verifyDocker Socket 绑定
# 将宿主机的 Docker Socket 挂载到 CI 容器中
services:
docker:
image: docker:latest
volumes:
- /var/run/docker.sock:/var/run/docker.sock# 将宿主机的 Docker Socket 挂载到 CI 容器中
services:
docker:
image: docker:latest
volumes:
- /var/run/docker.sock:/var/run/docker.sockTestcontainers 环境变量
| 变量 | 说明 | 默认值 |
|---|---|---|
TESTCONTAINERS_RYUK_DISABLED | 禁用手动清理容器 | false |
TESTCONTAINERS_CHECKS_DISABLE | 禁用环境检查 | false |
DOCKER_HOST | Docker 守护进程地址 | 从本地 Socket 自动检测 |
DOCKER_TLS_VERIFY | TLS 校验 | "" |
DOCKER_CERT_PATH | Docker 证书路径 | "" |
CI 环境优化
- 镜像预热:在 CI 流水线的预处理步骤中提前拉取测试所需的 Docker 镜像
- name: Pull test images
run: |
docker pull mysql:8.0
docker pull redis:7-alpine
docker pull confluentinc/cp-kafka:7.6.0- name: Pull test images
run: |
docker pull mysql:8.0
docker pull redis:7-alpine
docker pull confluentinc/cp-kafka:7.6.0镜像缓存:利用 CI 的缓存机制持久化 Docker 镜像层
资源限制:在资源受限的 CI 环境中设置内存和 CPU 限制
重试机制:网络不稳定的 CI 环境中可添加容器启动重试
@Container
private MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0")
.withStartupTimeout(Duration.ofMinutes(5))
.withStartupAttempts(3);@Container
private MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0")
.withStartupTimeout(Duration.ofMinutes(5))
.withStartupAttempts(3);6.4 分层测试策略
测试金字塔在 Testcontainers 语境下的实践
╱╲
╱ ╲ 端到端测试(少量)
╱ ╲
╱ Test ╲
╱containers╲ 集成测试(适量)
╱____________╲
╱ ╲
╱ Unit Test ╲ 单元测试(大量,快速)
╱__________________╲ ╱╲
╱ ╲ 端到端测试(少量)
╱ ╲
╱ Test ╲
╱containers╲ 集成测试(适量)
╱____________╲
╱ ╲
╱ Unit Test ╲ 单元测试(大量,快速)
╱__________________╲分层建议
| 层级 | 测试范围 | Mock 使用 | 容器使用 | 速度要求 |
|---|---|---|---|---|
| 单元测试 | 单一类/方法 | 频繁 | 不使用 | 毫秒级 |
| 仓库层测试 | DAO / Repository | 不使用 | 数据库容器 | 秒级 |
| 服务层测试 | Service 逻辑 | 可选 | 可选 | 秒级 |
| 集成测试 | 跨组件交互 | 不使用 | 多容器 | 分钟级 |
| 端到端测试 | 系统整体 | 不使用 | 全量容器 | 分钟级 |
代码组织示例
src/
├── main/
│ └── java/
└── test/
├── java/
│ ├── unit/ # 纯单元测试,不需要 Docker
│ ├── integration/ # 集成测试,使用 Testcontainers
│ │ └── base/
│ │ └── IntegrationTestBase.java
│ └── e2e/ # 端到端测试
└── resources/
├── testcontainers.properties
└── docker-compose-test.ymlsrc/
├── main/
│ └── java/
└── test/
├── java/
│ ├── unit/ # 纯单元测试,不需要 Docker
│ ├── integration/ # 集成测试,使用 Testcontainers
│ │ └── base/
│ │ └── IntegrationTestBase.java
│ └── e2e/ # 端到端测试
└── resources/
├── testcontainers.properties
└── docker-compose-test.ymlMaven 配置分层执行
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<excludes>
<exclude>**/integration/**</exclude>
<exclude>**/e2e/**</exclude>
</excludes>
</configuration>
<executions>
<execution>
<id>integration-tests</id>
<goals><goal>test</goal></goals>
<configuration>
<includes>
<include>**/integration/**</include>
</includes>
</configuration>
</execution>
</executions>
</plugin><plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<excludes>
<exclude>**/integration/**</exclude>
<exclude>**/e2e/**</exclude>
</excludes>
</configuration>
<executions>
<execution>
<id>integration-tests</id>
<goals><goal>test</goal></goals>
<configuration>
<includes>
<include>**/integration/**</include>
</includes>
</configuration>
</execution>
</executions>
</plugin>6.5 性能优化
容器复用(Testcontainers Desktop)
Testcontainers Desktop 提供了容器复用功能,在开发阶段可以显著加速:
- 安装 Testcontainers Desktop 应用
- 启用容器复用
- 配置测试代码使用可复用模式
// 使用可复用容器(配合 Testcontainers Desktop)
@Container
private MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0")
.withReuse(true);// 使用可复用容器(配合 Testcontainers Desktop)
@Container
private MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0")
.withReuse(true);避免不必要的容器启动
- 使用静态容器而非实例容器
- 在测试套件级别共享容器(使用
@TestInstance(Lifecycle.PER_CLASS)) - 在
@BeforeAll中一次性启动所有需要的容器
数据库初始化优化
- 在容器初始化脚本中预先创建表和种子数据
- 使用 Flyway / Liquibase 迁移脚本代替手动 DDL
- 考虑使用数据库快照恢复代替每次完整迁移
6.6 常见问题与解决方案
Docker 环境不可用
异常: Could not find a valid Docker environment.异常: Could not find a valid Docker environment.解决方案:
- 确保 Docker Desktop 正在运行
- 检查 Docker Socket 权限
- 设置
DOCKER_HOST环境变量指向远程 Docker - 在无法使用 Docker 的环境中跳过 Testcontainers 测试
@EnabledIfDockerAvailable
@Testcontainers
class DockerRequiredTest {
// 仅在 Docker 可用时执行
}@EnabledIfDockerAvailable
@Testcontainers
class DockerRequiredTest {
// 仅在 Docker 可用时执行
}端口冲突
Testcontainers 默认自动映射容器端口到宿主机的随机端口,通常不会冲突。但如果使用固定端口:
// 避免使用固定端口
new GenericContainer<>("nginx")
.withExposedPorts(80) // 正确:自动映射到随机端口
// 不推荐
new GenericContainer<>("nginx")
.withFixedExposedPort(8080, 80); // 可能与其他测试冲突// 避免使用固定端口
new GenericContainer<>("nginx")
.withExposedPorts(80) // 正确:自动映射到随机端口
// 不推荐
new GenericContainer<>("nginx")
.withFixedExposedPort(8080, 80); // 可能与其他测试冲突容器启动缓慢
- 使用 Alpine 版本的基础镜像
- 提前拉取镜像(
docker pull) - 在 CI 中预热镜像缓存
- 增加启动超时时间
- 使用
withReuse(true)(需 Testcontainers Desktop)
6.7 安全性考虑
- 测试容器不应暴露到生产网络
- 不要在测试容器中使用生产密码或密钥
- 测试完成后确保容器被彻底清理(依赖 Ryuk 和显式
stop()) - 在 CI 中使用非特权 Docker 运行测试
- 避免在测试容器中挂载宿主机敏感路径
6.8 测试质量指标
| 指标 | 目标 | 衡量方式 |
|---|---|---|
| 覆盖真实中间件 | 100% | 不使用 H2/Mock 替代数据库 |
| 容器启动时间 | < 60 秒 | CI 流水线记录 |
| 测试隔离性 | 无跨测试数据污染 | 随机执行顺序检查 |
| CI 通过率 | > 99% | CI 构建历史 |
总结
Testcontainers 为 Java 应用提供了一种优雅而强大的集成测试方案。通过使用真实的 Docker 容器替代 Mock 和内存数据库,测试的可靠性和真实度得到显著提升。
核心优势回顾
- 真实性:测试运行在与生产环境一致的中间件之上
- 可重复性:每次测试都从干净的容器状态开始
- 可维护性:无需编写和维护大量的 Mock 桩代码
- 可移植性:开发环境和 CI 环境使用相同的测试基础设施
适用场景
- 涉及数据库、消息队列、缓存的集成测试
- 多服务依赖的端到端测试
- 数据库迁移脚本验证
- 容器化应用的验收测试
Testcontainers 并非万能药,在纯逻辑单元测试中仍应使用 Mock 以获得毫秒级的执行速度。合理的分层测试策略将单元测试的速度与集成测试的真实度相结合,构建出既高效又可靠的测试体系。
Cao Note