版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
手打不易,如果转摘,请注明出处!
本文链接:
https://blog.csdn.net/q258523454/article/details/164508048
🔥 项目开源地址:https://github.com/q258523454/java-testcontainers-dt
本教程配套的完整示例代码、Skill 规范模板已开源,欢迎 Star ⭐ 和 Fork 🍴
文章目录
-
- [为什么需要容器级 DT](#为什么需要容器级 DT)
- 三步搭好测试骨架
-
- [第一步:Maven 依赖配置](#第一步:Maven 依赖配置)
- [第二步:Singleton 基类设计](#第二步:Singleton 基类设计)
- [第三步:@DynamicPropertySource 属性注入](#第三步:@DynamicPropertySource 属性注入)
- [写一个完整的 DT 测试](#写一个完整的 DT 测试)
-
- 数据隔离:容器共享,数据隔离
- [BCDE 原则](#BCDE 原则)
- [完整 UserService DT 测试](#完整 UserService DT 测试)
- [DT vs UT 决策树](#DT vs UT 决策树)
- 跑起来验证
- 避坑指南
- [用 Skill 加速开发](#用 Skill 加速开发)
--
为什么需要容器级 DT
本地跑单元测试一片绿油油全通过,推到生产环境 SQL 语法报错?H2 测试库里跑得好好的 JPA 查询,换了 MySQL 直接抛异常?干过 Java 后端的都碰上过,写测试时有几个绕不开的坑。
第一个坑:H2 和 MySQL 语法不一致。 H2 启动快没错,但它的 SQL 方言和 MySQL 不完全一样。你在 H2 上跑通的 SQL,到 MySQL 上可能因为关键字冲突、数据类型差异直接报错。比如 INSERT ... ON DUPLICATE KEY UPDATE 这种 MySQL 特有语法,H2 支持就有限。H2 是"模拟器",不是"真机",模拟器上练得再熟,上了真机还是可能翻车。
第二个坑:Mock 验证不了真实序列化。 用 Mockito Mock 掉 RedisTemplate,你能验证 set 被调了几次,但验证不了对象序列化后存进 Redis 的实际格式。生产环境的序列化配置跟 Mock 行为一不一致,反序列化就炸了。
第三个坑:嵌入式数据库跟生产环境不像。 Flyway 迁移脚本在 H2 上跑得好好的,到 MySQL 上可能因为 FULLTEXT 索引、存储引擎差异执行失败,测试阶段根本发现不了。
根源就一个:测试环境跟生产环境不够像。
Testcontainers 就是来填这个坑的。把它想象成"随用随起的迷你机房",需要 MySQL 就起真实 MySQL 容器,需要 Redis 就起真实 Redis 容器。测试跑完自动清理,不留痕迹。它不是替代 UT 和 Embedded DT,而是补位:纯逻辑用 UT,业务流程用 Embedded DT,需要真实基础设施交互才上 Testcontainers DT。
| 测试类型 | 目标 | 测试范围 | 逻辑实现 | 适用场景 | 前置条件 |
|---|---|---|---|---|---|
| UT 单元测试 | 验证代码逻辑 | 单个方法、类 | Mockito Mock 所有依赖 | 纯逻辑验证、参数校验 | 无 |
| DT 开发测试(embedded) | 验证业务流程 | API 接口、业务场景 | @SpringBootTest + H2 + jedis-mock |
业务流程验证,环境差异可接受 | 无 |
| DT 开发测试(Testcontainers) | 验证真实基础设施交互 | API 接口、完整业务场景 | @SpringBootTest + 真实容器 |
SQL 语法、JPA 映射、序列化验证 | Docker 环境 |
| 覆盖场景 | UT | Embedded DT | Testcontainers DT |
|---|---|---|---|
| SQL 语法正确性 | ❌ | ⚠️ H2 兼容性 | ✅ 真实 MySQL |
| JPA 映射正确性 | ❌ | ⚠️ H2 差异 | ✅ 真实 MySQL |
| Redis 序列化 | ❌ | ⚠️ jedis-mock | ✅ 真实 Redis |
| ES 索引映射 | ❌ | ❌ | ✅ 真实 ES |
| 数据库迁移脚本 | ❌ | ❌ | ✅ 与生产一致 |
两张表一摆,差距一目了然。UT 覆盖最少,Embedded DT 能覆盖一部分但有兼容性盲区,Testcontainers DT 覆盖最全。
三步搭好测试骨架
搭测试骨架分三步:配依赖、设计基类、注入属性。三步搞完,后面写测试就是填空题。
第一步:Maven 依赖配置
前置条件:Java 21 + Spring Boot 3.1+ + Docker 环境 ,三者缺一不可。在 pom.xml 中添加 Testcontainers 相关依赖,推荐用 BOM 统一管理版本:
xml
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-bom</artifactId>
<version>1.20.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>mysql</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
再在 src/test/resources/testcontainers.properties 中加全局配置:
properties
testcontainers.image.pull.policy=missing
testcontainers.ryuk.disabled=false
注意:Docker 是前置条件,本地需安装 Docker Desktop 并保持运行。IDE 强制终止测试时 Ryuk 可能来不及清理,残留容器用 docker rm -f $(docker ps -aq --filter label=org.testcontainers=true) 手动清理。
第二步:Singleton 基类设计
基类是容器级 DT 的骨架。好的基类能让几十个测试类共享同一个容器实例,避免每个测试类各起一个 MySQL,把测试时间从分钟级拖到小时级。
先看三种容器生命周期模式的对比:
| 模式 | 启动时机 | 停止时机 | 清理可靠性 | 适用场景 | 性能 |
|---|---|---|---|---|---|
| Singleton(static块) | 基类首次加载 | JVM退出时 | 依赖Ryuk | 多测试类共享(推荐) | 5星 |
| @Container注解 | beforeAll | afterAll后 | JUnit主动清理 | 单测试类共享 | 4星 |
| Reuse容器复用 | 首次start() | 不停止 | 需手动清理 | 本地快速迭代 | 5星(复用后) |
CI 环境推荐 Singleton 模式,容器只启动一次,所有测试类共享,性能最好且清理有保障。核心思路:在基类的 static 块中启动容器,所有继承该基类的测试类共享同一个容器实例,不显式调用 stop(),清理依赖 Ryuk 在 JVM 退出时自动执行。
java
package com.example.dt;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.MySQLContainer;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.utility.DockerImageName;
// Singleton Container 基类:所有子类共享同一个 MySQL 容器
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
@ActiveProfiles("testcontainers")
@Testcontainers(disabledWithoutDocker = true)
public abstract class AbstractMySQLTestcontainers {
protected static final MySQLContainer<?> MYSQL;
static {
MYSQL = new MySQLContainer<>(DockerImageName.parse("mysql:8.0"))
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test")
.withInitScript("mysql/testcontainers/init_charset.sql");
MYSQL.start();
}
@DynamicPropertySource
static void mysqlProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", MYSQL::getJdbcUrl);
registry.add("spring.datasource.username", MYSQL::getUsername);
registry.add("spring.datasource.password", MYSQL::getPassword);
}
}
static 块保证 JVM 生命周期内只启动一次,三个测试类继承同一个基类,MySQL 容器只起一次约 5 到 10 秒,后续直接复用。
实际项目中一个测试可能同时需要 MySQL、Redis 和 ES。串行启动要等 30 秒以上,用 Startables.deepStart 并行启动只需 10 秒左右:
java
// 多容器并行启动
static {
Startables.deepStart(MYSQL, REDIS, ES).join();
}
Startables.deepStart 底层并行调用每个容器的 start(),总耗时约等于最慢的那个容器。
第三步:@DynamicPropertySource 属性注入
容器启动后,得把连接信息告诉 Spring。@DynamicPropertySource 能在容器启动后动态获取实际端口和连接信息,因为 Testcontainers 每次启动容器会随机映射宿主机端口,不能写死在配置文件里。多容器场景下,在同一个方法中注入所有中间件属性:
java
@DynamicPropertySource
static void containerProperties(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::getFirstMappedPort);
registry.add("spring.elasticsearch.uris", ES::getHttpHostAddress);
}
registry.add 接受 Spring 属性名和方法引用(Supplier 类型),Spring 刷新上下文时调用它获取实际值。容器先启动,属性后注入,时序正确。
关于测试注解的选择:Service 层用 @SpringBootTest(webEnvironment=NONE),Controller 层用 @SpringBootTest(webEnvironment=RANDOM_PORT),Repository 层用 @DataJpaTest + @AutoConfigureTestDatabase(replace=NONE)。关键点在第三种,@DataJpaTest 默认会用 H2 替换真实数据源,加上 replace = NONE 才能禁用。一个完整的 DT 测试可能同时用到三种工具,分工要搞清楚:能用真实容器的就不用 Mock,外部 HTTP 调用用 WireMock 模拟,纯本地依赖才用 Mockito。比如测试订单服务,MySQL 用 Testcontainers 验证 SQL,支付 API 用 WireMock 模拟 HTTP 响应,配置缓存用 Mockito Mock 返回值。既然用了真实 MySQL 容器,就别再 Mock UserRepository,否则失去真实验证的意义。
写一个完整的 DT 测试
数据隔离:容器共享,数据隔离
容器是共享的,但数据必须隔离。Singleton 模式下所有测试类共用同一个 MySQL 容器,几十个测试方法都往里面写数据。如果测试 A 插入的数据没清理,测试 B 查询时就会查到 A 的脏数据,断言直接挂掉。
打个比方,容器就像公共厨房,大家共用灶台和锅具,但做完菜必须自己洗碗。你不洗,下一个人就没法用。数据隔离就是"洗碗"。
| 策略 | 方式 | 推荐度 | 适用场景 |
|---|---|---|---|
| @Sql注解 | SQL脚本管理数据 | ⭐⭐⭐⭐ | 需要精确控制SQL的场景 |
| @BeforeEach/@AfterEach | 代码管理数据+统一前缀清理 | ⭐⭐⭐⭐⭐ | 大多数DT场景(推荐) |
| TRUNCATE全表 | 每次清空全表 | ⭐⭐⭐ | 需要绝对干净状态的场景 |
策略一:@Sql 注解。 通过 SQL 脚本管理测试数据,测试前执行插入脚本,测试后执行清理脚本。脚本与测试代码分离,逻辑清晰,但每个测试方法都要维护一套脚本文件,多了管理成本高。
java
@Test
@Sql(scripts = {
"classpath:data/mysql/fixtures/global/schema.sql",
"classpath:data/mysql/fixtures/tests/single_record.sql"
}, executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD)
@Sql(scripts = "classpath:data/mysql/fixtures/tests/cleanup_single_record.sql",
executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD)
void findById_existingUser_returnUser() { ... }
策略二:@BeforeEach/@AfterEach + 统一前缀(推荐)。 在 @BeforeEach 中通过 Repository 插入测试数据,@AfterEach 中按统一前缀批量清理。所有测试数据使用 DT_TEST_ 前缀,一眼就能识别哪些是测试数据:
java
private User createTestUser(String userId, String userName) {
User user = new User();
user.setUserId("DT_TEST_" + userId);
user.setUserName(userName);
return user;
}
@AfterEach
void tearDown() {
userRepository.deleteByUserIdStartingWith("DT_TEST_");
}
deleteByUserIdStartingWith("DT_TEST_") 只删以 DT_TEST_ 开头的记录,不会误删其他数据。
策略三:TRUNCATE 全表。 需要绝对干净的状态时,直接清空整张表。先关闭外键检查避免关联表无法 TRUNCATE,清理完再打开。最彻底,但会清掉所有数据包括种子数据。
java
@BeforeEach
void cleanUp() {
jdbcTemplate.execute("SET FOREIGN_KEY_CHECKS = 0");
jdbcTemplate.execute("TRUNCATE TABLE t_user");
jdbcTemplate.execute("TRUNCATE TABLE t_order");
jdbcTemplate.execute("SET FOREIGN_KEY_CHECKS = 1");
}
Redis 数据隔离 思路一样:统一前缀 + 批量清理。@BeforeEach 的 flushAll() 保证启动时 Redis 干净,@AfterEach 按前缀删除是双重保险:
java
private static final String KEY_PREFIX = "DT_TEST:";
@BeforeEach
void setUp() {
redisTemplate.getConnectionFactory().getConnection().flushAll();
}
@AfterEach
void tearDown() {
Set<String> keys = redisTemplate.keys(KEY_PREFIX + "*");
if (keys != null && !keys.isEmpty()) {
redisTemplate.delete(keys);
}
}
String cacheKey = KEY_PREFIX + "user:" + userId; // DT_TEST:user:001
BCDE 原则
容器级 DT 遵循 BCDE 原则,确保用例覆盖全面:
| 原则 | 含义 | 示例 |
|---|---|---|
| Border 边界值 | 测试边界情况 | 空值、最大值、最小值、空集合 |
| Correct 正常场景 | 测试正常业务流程 | 正常创建用户、正常查询返回结果 |
| Design 结合设计 | 覆盖设计文档关键场景 | 唯一约束、级联删除、事务回滚 |
| Error 异常场景 | 测试异常输入和错误处理 | 重复创建、查询不存在、参数非法 |
关键点:正例写在第一个。读者打开测试类,第一眼看到的就是正常业务流程,快速理解这个方法"应该怎么工作"。
完整 UserService DT 测试
把前面的东西组装起来,写一个完整的 UserService 容器级 DT 测试类,继承 AbstractMySQLTestcontainers 基类,包含 2 个正例和 2 个反例:
java
package com.example.service.impl;
import com.example.dt.AbstractMySQLTestcontainers;
import com.example.entity.User;
import com.example.repository.UserRepository;
import com.example.service.UserService;
import org.junit.jupiter.api.*;
import org.springframework.beans.factory.annotation.Autowired;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class UserServiceImplTest extends AbstractMySQLTestcontainers {
@Autowired
private UserService userService;
@Autowired
private UserRepository userRepository;
@BeforeEach
void setUp() {
userRepository.save(createTestUser("user_001", "张三", "zhangsan@test.com"));
userRepository.save(createTestUser("user_002", "李四", "lisi@test.com"));
}
@AfterEach
void tearDown() {
userRepository.deleteByUserIdStartingWith("DT_TEST_");
}
@Test
@DisplayName("正常用例-根据ID查询用户返回正确结果")
void findById_existingUser_returnUser() {
// Given:数据已在 setUp 中插入
// When
User result = userService.findById("DT_TEST_user_001");
// Then:验证真实 MySQL 查询结果
assertThat(result).isNotNull();
assertThat(result.getUserId()).isEqualTo("DT_TEST_user_001");
assertThat(result.getUserName()).isEqualTo("张三");
assertThat(result.getEmail()).isEqualTo("zhangsan@test.com");
}
@Test
@DisplayName("正常用例-创建用户并持久化到数据库")
void createUser_validInput_persistAndReturn() {
// Given
User newUser = createTestUser("user_003", "王五", "wangwu@test.com");
// When
User result = userService.createUser(newUser);
// Then:验证真实数据库持久化
assertThat(result.getUserId()).isEqualTo("DT_TEST_user_003");
User dbUser = userRepository.findById("DT_TEST_user_003").orElseThrow();
assertThat(dbUser.getUserName()).isEqualTo("王五");
}
@Test
@DisplayName("异常用例-查询不存在的用户返回空")
void findById_nonExistentUser_returnEmpty() {
assertThatThrownBy(() -> userService.findById("DT_TEST_nonexistent"))
.isInstanceOf(RuntimeException.class)
.hasMessageContaining("USER_NOT_FOUND");
}
@Test
@DisplayName("异常用例-创建重复用户抛出异常")
void createUser_duplicateUser_throwException() {
// Given:setUp 中已插入 DT_TEST_user_001
User duplicate = createTestUser("user_001", "重复", "dup@test.com");
// When & Then:验证真实数据库唯一约束
assertThatThrownBy(() -> userService.createUser(duplicate))
.isInstanceOf(RuntimeException.class);
}
private User createTestUser(String userId, String name, String email) {
User user = new User();
user.setUserId("DT_TEST_" + userId);
user.setUserName(name);
user.setEmail(email);
return user;
}
}
注意第四个测试方法。createUser_duplicateUser_throwException 验证的是真实 MySQL 唯一约束,用 H2 可能因为索引差异发现不了问题。每个测试方法用 GWT 结构,用 assertThat() 流式断言,方法命名采用蛇形命名法。
DT vs UT 决策树
不是所有测试都需要上 Testcontainers。写测试之前,先判断该用 UT 还是 DT:
需要编写测试
├─ 涉及数据库/Redis/ES操作?
│ ├─ YES → 需要验证SQL/序列化/映射?
│ │ ├─ YES → 需要与生产一致的环境?
│ │ │ ├─ YES → Testcontainers DT
│ │ │ └─ NO → Embedded DT(H2/jedis-mock)
│ │ └─ NO → UT(Mockito Mock)
│ └─ NO → UT
└─ 需要验证完整业务流程?
├─ YES → DT(@SpringBootTest + 真实基础设施)
└─ NO → UT
核心判断三层递进:是否涉及中间件操作?是否需要验证 SQL 语法、序列化、映射?是否要求跟生产环境一致?比如测试纯计算方法 calculateDiscount(),不碰数据库不走 Redis,直接 UT。测试 UserRepository.findByUserName(),需要验证 JPA 映射和 SQL 语法,用 Testcontainers DT。
跑起来验证
代码写完了,跑起来才知道行不行。
在项目根目录执行,只运行 UserServiceImplTest 这一个测试类:
bash
mvn test -Dtest=UserServiceImplTest
4 个测试方法全部通过,控制台输出类似这样:
[INFO] Container mysql:8.0 started
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0
[INFO] 正常用例-根据ID查询用户返回正确结果 ✓
[INFO] 正常用例-创建用户并持久化到数据库 ✓
[INFO] 异常用例-查询不存在的用户返回空 ✓
[INFO] 异常用例-创建重复用户抛出异常 ✓
[INFO] BUILD SUCCESS
看到 BUILD SUCCESS 就说明全部通过了。首次运行会拉取 MySQL 镜像,大概 1 到 3 分钟,后续直接用本地缓存,几秒搞定。跑完别急着关终端,回头看看这几个点:日志里有 Container mysql:8.0 started,说明拉起的是真实 MySQL 不是 H2;查询和写入走的是真实 MySQL 引擎,JPA 生成的 SQL 有语法问题测试直接报错;插入重复 userName 时触发 DataIntegrityViolationException,数据库唯一约束生效了。
避坑指南
容器级 DT 的核心价值就三句话:真实环境验证,SQL 语法、JPA 映射、序列化、事务回滚等问题测试阶段就能提前发现;容器随用随起,测试完自动清理,不污染本地环境;容器化测试天然适合 CI,机器装个 Docker 就行。但要用得好,得避开几个常见的坑。
常见反模式
| 反模式 | 问题 | 正确做法 |
|---|---|---|
| 每个测试方法启动新容器 | 每次启动 5 到 10 秒,10 个方法就是 1 分钟 | 用 static 容器或 Singleton Container 模式共享 |
| 测试间数据未清理 | 测试 A 的数据影响测试 B 的断言 | @AfterEach 清理测试数据,或用 @Sql 脚本重置 |
| 在 DT 中 Mock 容器化基础设施 | Mock 掉就失去真实验证价值 | 用真实容器,@Autowired 注入 |
| 硬编码端口 | 多人同时跑测试端口冲突 | 用动态端口 getFirstMappedPort() |
性能优化建议
- Singleton Container 模式 :容器放
static块,JVM 生命周期内只启动一次,所有测试类共享同一个容器实例 Startables.deepStart并行启动:多容器并行启动,串行 30 秒变并行 10 秒,总耗时约等于最慢的那个容器- 选用 Alpine 镜像 :更轻量的镜像拉取快启动快,ES 容器加
withEnv("ES_JAVA_OPTS", "-Xms512m -Xmx512m")限制堆内存
用 Skill 加速开发
写容器级 DT 测试要记的规范不少:基类怎么设计、数据怎么隔离、用例按什么原则设计、命名有什么讲究。Skill 就是一套预先写好的规范模板,告诉 AI 助手怎么按规范帮你写容器级 DT 测试。你不用记住所有细节,只要跟 AI 说一句"帮我写个 DT 测试",Skill 会自动指导它按规范来,该选哪个基类、该按什么原则设计用例,全都帮你搞定。
安装方式
三步搞定:
- 克隆仓库:
git clone https://github.com/q258523454/java-testcontainers-dt.git - 放到 OpenCode skills 目录:
~/.config/opencode/skills/zj-java-testcontainers-dt/ - 重启 OpenCode 即可生效
使用示例
装好之后,用大白话跟 AI 助手说需求就行:
- 场景1:"帮我写一个 UserService 的 DT 测试" → Skill 自动选择 MySQL 基类,生成完整测试代码
- 场景2:"补充 MySQL 容器级测试用例" → Skill 自动按 BCDE 原则设计用例,正例在前反例在后
- 场景3:"测试 Redis 序列化" → Skill 加载 Redis 容器规范,生成序列化验证代码
Skill 中还包含 UT/DT 区别对比、工作流程、规范文档索引、多中间件基类模板等完整内容,感兴趣的可以去 GitHub 看完整文档:https://github.com/q258523454/java-testcontainers-dt
希望本文的实践能够为有类似需求的开发者提供一些参考和启发!!!