Skip to content

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 的核心工作流程如下:

  1. JUnit 引擎在测试类初始化阶段识别 @Testcontainers 注解
  2. Testcontainers 通过 Docker API(本地 Socket 或 TCP)拉取镜像
  3. 容器启动后,框架等待容器就绪(通过日志匹配、端口检测或自定义策略)
  4. 测试代码通过容器暴露的主机名和端口建立连接
  5. 测试执行完成后,Testcontainers 自动关闭并移除容器
  6. Ryuk 守护容器(Resource Reaper)作为兜底机制,确保异常退出时也能清理残留容器

架构组件

组件角色
Docker 环境本地 Docker Desktop 或远程 Docker 守护进程
Testcontainers 库管理容器生命周期,提供 Java API
Ryuk 容器资源回收器,在 JVM 崩溃时清理残留容器
JUnit 扩展将容器生命周期与测试生命周期绑定

1.3 对比传统测试方案 ​

维度Mock / StubH2 内存数据库Testcontainers
真实度低中高
启动速度极快快中(需拉取镜像)
隔离性方法级方法级类级或套件级
维护成本高(桩代码多)低中
版本一致性无不一定完全一致
CI 兼容性好好需 Docker 环境
适用场景纯逻辑测试简单 CRUD集成测试

1.4 适用场景 ​

  • 数据库集成测试:验证 JPA / MyBatis 的 SQL 执行结果、事务行为、锁机制
  • 消息队列测试:验证消息发送与消费的生产者-消费者场景
  • 缓存测试:验证 Redis 缓存命中、过期策略、序列化行为
  • 搜索引擎测试:验证 Elasticsearch 索引构建与搜索查询
  • 外部服务测试:验证 HTTP 客户端调用第三方服务的行为
  • 数据迁移测试:验证 Flyway / Liquibase 迁移脚本的正确性

二、快速开始 ​

2.1 Maven 依赖 ​

xml
<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 依赖 ​

groovy
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 容器 ​

java
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 端点返回预期状态码
  • 组合策略:同时等待多个条件
java
@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 ​

java
@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 特定配置:

java
@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 ​

java
@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 依赖):

java
// RedisContainer 提供更丰富的等待策略
@Container
private RedisContainer redis = new RedisContainer("redis:7-alpine");
// RedisContainer 提供更丰富的等待策略
@Container
private RedisContainer redis = new RedisContainer("redis:7-alpine");

3.4 Kafka ​

java
@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 ​

java
@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 ​

java
@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 ​

java
@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 镜像:

java
@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:

java
@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,可使该容器在所有测试方法之间共享:

java
@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

java
@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;
    }
}

抽象测试基类

java
@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 注解,可自动将容器连接信息注入到自动配置中:

java
@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+)

java
@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 默认不加载完整上下文。需要手动引入容器配置:

java
@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 中定义的多服务环境:

java
@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 示例

yaml
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: 2181
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: 2181

Docker Compose 容器注意事项

  • Docker Compose 容器的服务名可能与容器名不同,注意使用正确的服务标识符
  • 可以指定 withStartupTimeout() 控制整体启动超时
  • 支持 withEnv() 覆盖 Compose 文件中的环境变量
  • 支持 withLocalCompose(true) 使用本地安装的 docker-compose 命令

5.2 网络(Network)+ 多容器通信 ​

当多个容器需要互相通信时,可以创建自定义网络并为容器设置别名:

java
@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 可重复测试(静态容器 + 数据清理) ​

在多测试方法之间共享静态容器时,需要确保测试之间互不干扰:

java
@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 容器执行命令与复制文件 ​

在容器内部执行命令

java
@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());
}

复制文件到容器

java
@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"
    );
}

从容器复制文件

java
@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 构建

java
@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() {
        // 测试自定义构建的容器
    }
}

基于现有镜像扩展

java
@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 结合,验证迁移脚本的正确性:

java
@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 支持并行测试执行,每个测试线程使用独立的容器:

java
@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 容器日志与调试 ​

java
@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 工作原理

  1. JVM 启动后,Testcontainers 自动启动 Ryuk 容器
  2. Ryuk 与主 JVM 之间维持一个 TCP 连接
  3. 当 JVM 正常退出或崩溃时,TCP 连接断开
  4. Ryuk 检测到连接断开后,清理所有由 Testcontainers 创建的容器

禁用 Ryuk(不推荐)

properties
# 在 testcontainers.properties 或系统属性中设置
testcontainers.ryuk.disabled=true
# 在 testcontainers.properties 或系统属性中设置
testcontainers.ryuk.disabled=true

手动清理

java
// 调用后立即停止并移除容器
mysql.stop();
// 调用后立即停止并移除容器
mysql.stop();

配置资源限制

java
@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 测试超时设置 ​

容器启动超时

java
@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 测试方法超时

java
@Test
@Timeout(value = 30, unit = TimeUnit.SECONDS)
void testWithTimeout() {
    // 如果测试执行超过 30 秒,自动失败
}
@Test
@Timeout(value = 30, unit = TimeUnit.SECONDS)
void testWithTimeout() {
    // 如果测试执行超过 30 秒,自动失败
}

全局超时配置

properties
# src/test/resources/testcontainers.properties
testcontainers.container.startup-timeout=120
# src/test/resources/testcontainers.properties
testcontainers.container.startup-timeout=120

6.3 CI 环境配置 ​

Docker in Docker(Docker)

在 Jenkins / GitLab CI / GitHub Actions 中使用 Docker 套接字绑定:

yaml
# 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 verify

Docker Socket 绑定

yaml
# 将宿主机的 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.sock

Testcontainers 环境变量

变量说明默认值
TESTCONTAINERS_RYUK_DISABLED禁用手动清理容器false
TESTCONTAINERS_CHECKS_DISABLE禁用环境检查false
DOCKER_HOSTDocker 守护进程地址从本地 Socket 自动检测
DOCKER_TLS_VERIFYTLS 校验""
DOCKER_CERT_PATHDocker 证书路径""

CI 环境优化

  1. 镜像预热:在 CI 流水线的预处理步骤中提前拉取测试所需的 Docker 镜像
yaml
- 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
  1. 镜像缓存:利用 CI 的缓存机制持久化 Docker 镜像层

  2. 资源限制:在资源受限的 CI 环境中设置内存和 CPU 限制

  3. 重试机制:网络不稳定的 CI 环境中可添加容器启动重试

java
@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.yml
src/
├── main/
│   └── java/
└── test/
    ├── java/
    │   ├── unit/              # 纯单元测试,不需要 Docker
    │   ├── integration/       # 集成测试,使用 Testcontainers
    │   │   └── base/
    │   │       └── IntegrationTestBase.java
    │   └── e2e/              # 端到端测试
    └── resources/
        ├── testcontainers.properties
        └── docker-compose-test.yml

Maven 配置分层执行

xml
<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 提供了容器复用功能,在开发阶段可以显著加速:

  1. 安装 Testcontainers Desktop 应用
  2. 启用容器复用
  3. 配置测试代码使用可复用模式
java
// 使用可复用容器(配合 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 测试
java
@EnabledIfDockerAvailable
@Testcontainers
class DockerRequiredTest {
    // 仅在 Docker 可用时执行
}
@EnabledIfDockerAvailable
@Testcontainers
class DockerRequiredTest {
    // 仅在 Docker 可用时执行
}

端口冲突

Testcontainers 默认自动映射容器端口到宿主机的随机端口,通常不会冲突。但如果使用固定端口:

java
// 避免使用固定端口
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 以获得毫秒级的执行速度。合理的分层测试策略将单元测试的速度与集成测试的真实度相结合,构建出既高效又可靠的测试体系。