Skip to content

集成测试最大的坑不是代码,是环境造假。H2 内存数据库号称能模拟 MySQL,但 ON DUPLICATE KEY UPDATEGROUP_CONCAT、JSON 字段、锁行为……这些 MySQL 方言它全跑不了。测试过了,一上真实环境就炸。

Testcontainers 的思路很直接:测试时用 Docker 起一个真的 MySQL/Redis/RabbitMQ,测完自动销毁。测的就是真实组件的真实行为。

本文是《集成测试快速入门》的深化篇——快速入门讲 Testcontainers 的基本用法,这篇讲清楚「为什么比 H2 靠谱」「容器生命周期怎么管」「static 单例怎么复用」这些实际用起来绕不开的问题。

一、H2 到底差在哪

H2 是纯 Java 写的内存数据库,启动快、零依赖,所以老项目集成测试爱用它。但它是模拟,不是真的 MySQL:

维度H2 模拟Testcontainers 真容器
SQL 方言只支持公共子集,ON DUPLICATE KEY UPDATEGROUP_CONCAT 要开 MySQL 兼容模式还不完整原生支持,是什么就是什么
行为锁、事务隔离、索引优化的行为和 MySQL 不一致和生产完全一致
中间件Redis/RabbitMQ/Kafka 没法模拟(要么不测、要么写 mock)全都能起真容器
版本H2 版本 ≠ MySQL 版本镜像 tag 指定版本,mysql:8.0 就是 8.0

一句话:H2 测的是「你的 SQL 在 H2 里能跑」,Testcontainers 测的是「你的 SQL 在真 MySQL 8.0 里能跑」。前者给你的安全感是假的。

二、最小可用:起一个真 MySQL

Spring Boot 3.1+ 官方提供了 @ServiceConnection,连手动注入连接串都省了:

java
@SpringBootTest
@Testcontainers
class UserDaoTest {

    @Container
    @ServiceConnection
    static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0");

    @Autowired
    private UserDao userDao;

    @Test
    void upsertTest() {
        // ON DUPLICATE KEY UPDATE 是 MySQL 方言,H2 直接报错
        userDao.upsert(new User("BinMaker", 30));
        userDao.upsert(new User("BinMaker", 31));

        User user = userDao.findByName("BinMaker");
        assertEquals(31, user.getAge());  // 第二次是更新,不是插入
    }
}

三个注解各干一件事:

  • @Testcontainers:开启 JUnit 5 扩展,负责容器的启停
  • @Container:标记这个字段是容器,测试方法跑之前启动、类里所有测试跑完销毁
  • @ServiceConnection:Spring Boot 自动把容器的 IP、端口、账号注入数据源,不用手写 spring.datasource.url

new MySQLContainer<>("mysql:8.0") 里的 mysql:8.0 是镜像 tag——测试用的数据库版本可以精确到和生产一致,这是 H2 永远做不到的。

三、自定义初始化脚本

真实业务的表结构、初始数据比 CREATE TABLE 复杂得多。withInitScript 指定 classpath 下的 SQL 脚本,容器启动后自动执行:

java
static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0")
        .withInitScript("init.sql")           // classpath:init.sql
        .withUsername("test")
        .withDatabaseName("testdb");

init.sqlsrc/test/resources/ 下,写建表语句 + 初始数据都行。复杂场景(多个脚本、存储过程)用 withCopyFileToContainer 或挂载目录解决。

四、不只是数据库:任意中间件都能起

MySQL 有专门的容器类(MySQLContainer),其他中间件用通用的 GenericContainer

java
@Testcontainers
class RedisCacheTest {

    @Container
    static GenericContainer<?> redis = new GenericContainer<>("redis:7")
            .withExposedPorts(6379);   // 声明容器内的端口

    @Test
    void cacheTest() {
        // 容器端口是随机映射的,getMappedPort 拿真实端口
        String host = redis.getHost();
        Integer port = redis.getMappedPort(6379);

        // 用 host + port 构造 Lettuce/Jedis 客户端,测真实 Redis 行为
    }
}

这里有个必懂机制withExposedPorts(6379) 只是告诉 Testcontainers「容器内这个端口有用」,宿主机上映射的端口是随机的(比如 54321)。所以代码里永远用 getMappedPort(6379) 拿真实端口,不要硬编码。

为什么要随机端口?多个测试类并行跑时,如果都固定映射 6379,第二个容器起来就端口冲突了。随机映射天然支持并行。

五、容器生命周期:最贵的教训是「每次测试都重启容器」

MySQL 容器一次启动要 10-30 秒(拉镜像更久,但镜像只拉一次)。如果每个测试类都起一个新容器,20 个测试类就是 20 次启动——测试慢到没法用。

Testcontainers 的生命周期由 @Container + static 决定:

写法生命周期适用
static 字段 + @Container整个测试类共用一个容器(类里所有 @Test 跑完才销毁)绝大多数场景,默认选这个
非静态字段 + @Container每个测试方法都起一个新容器需要完全隔离状态时才用(比如测「空库初始化」)

所以默认用 static。非 static 写法会把 20 个测试变成 20 次容器启动,是最常见的性能坑。

Testcontainers 生命周期与 static 复用

想跨测试类复用同一个容器(整个测试会话只启动一次 MySQL),有两种方式:

方式一:公共基类

java
public abstract class IntegrationTestBase {

    @Container
    @ServiceConnection
    static final MySQLContainer<?> MYSQL = new MySQLContainer<>("mysql:8.0");
}
// 所有集成测试类 extends IntegrationTestBase,JVM 里共享同一个容器实例

只要测试在同一个 JVM 里跑,static 字段天然单例。JUnit 的默认执行策略下这是最简单的做法。

方式二:singleton 模式(容器实例存在你自己的单例里,配合 withReuse 或手动控制启停)——适合多模块项目,各子模块的测试进程共享容器,但配置更繁琐,单模块项目没必要。

六、测试数据隔离:容器共享了,数据怎么办

static 复用带来新问题:类 A 的测试写进去的数据,类 B 能看到,测试之间互相污染。解法按优先级:

  1. @Transactional 回滚(Spring 测试默认行为):测试方法里的事务结束自动回滚,数据不落库。注意事务提交后触发的逻辑(如 REQUIRES_NEW、异步线程)不在回滚范围
  2. @Sql 脚本清理@Sql(executionPhase = AFTER_TEST_METHOD, scripts = "/cleanup.sql") 显式清表
  3. 每方法新容器:完全隔离,但性能最差,只留给必须的场景

实践中绝大多数用第 1 种,个别有「提交后副作用」的用例用第 2 种。

七、它怎么保证容器「测完不留垃圾」

Testcontainers 有个守护容器 Ryuk(第一次运行自动拉 testcontainers/ryuk 镜像),它监控你起的所有测试容器——JVM 挂了、进程被 kill、测试异常中断,Ryuk 都会把漏掉的容器清掉,不会留下一堆僵尸容器吃内存。

这也是它比「自己写 Docker 命令起容器」靠谱的地方:自己起,忘记清理就是僵尸容器;Ryuk 兜底,测试崩了也能收干净。

CI 环境(Docker-in-Docker 或无权限拉 Ryuk 镜像)可以设环境变量 TESTCONTAINERS_RYUK_DISABLED=true 关掉它,代价是要自己保证清理。

八、验证说明

本文代码在本地实跑验证过(Testcontainers 1.21.4 + JUnit 5 + Maven + Docker Engine 29):MySQLContainer("mysql:8.0") + withInitScript 真容器 18 秒启动、init 脚本 118ms 执行完;ON DUPLICATE KEY UPDATE 冲突更新返回 2(H2 跑不了的方言断言通过);GenericContainer("redis:7.2.8") 随机端口映射、PING 返回 +PONG、SET/GET 读回写入值;static 容器跨测试方法复用(两个测试方法拿到同一个 JDBC URL)。

验证过程踩了两个真实的坑,比文章本身还值钱:

  1. Could not find a valid Docker environment 不一定是 Docker 没装。Docker CLI 一切正常,但 Testcontainers 1.20.6 内置的 docker-java 版本老,跟新版 Docker Engine(29.x)的 API 协商不上,直接报这个错——升级 Testcontainers 到 1.21.x 即解。遇到这个报错先看版本兼容,别急着排查 Docker Desktop
  2. readAllBytes() 读 Redis 响应会永久阻塞。Redis 是长连接,不会主动关闭,readAllBytes 要等 EOF 才返回——永远等不到。正确姿势按 RESP 协议逐行读(先读类型行 $20,再读值行)。这个坑的本质:流式协议的读取方式要匹配协议边界,不能想当然读到结尾

小结

  • H2 是模拟,容器是真环境:MySQL 方言、锁行为、中间件行为,只有真容器才测得出来
  • @Container + static:整个测试类共用一个容器,默认这么写;非 static 每方法一容器是性能坑
  • getMappedPort:容器端口随机映射,永远动态获取,不硬编码
  • @ServiceConnection:Spring Boot 3.1+ 自动注入连接,告别手写连接串
  • Ryuk 兜底清理:测试崩溃也不留僵尸容器

接下来可以看: