Testcontainers + MySQL 实战——数据库集成测试彻底告别 H2 内存数据库
Testcontainers + MySQL 实战——数据库集成测试彻底告别 H2 内存数据库
适读人群:Java 后端开发者、DBA 出身的后端工程师 | 阅读时长:约 16 分钟 | 核心价值:用真实 MySQL 跑集成测试,彻底消灭因数据库差异导致的线上 Bug
我以前在公司里是出了名的"H2 卫道士"。
那时候我觉得 H2 特别好:轻量、快、不需要依赖外部环境,在内存里跑完 schema、跑完测试,几秒钟搞定。每当有同事说"H2 和 MySQL 行为不一样",我都一副"小题大做"的表情:不就是个测试数据库嘛,能验证逻辑就行了。
然后有一天,这个态度被狠狠地教育了一顿。
我们的项目有一个分页查询接口,用的是 MySQL 的 JSON_CONTAINS 函数来过滤标签。代码在本地用 H2 跑测试,全部通过。上线后,测试环境也通过了(测试环境 QA 手动测的,没有跑自动化测试)。到了生产环境,某个用户点开带标签过滤的列表页面,白屏,500 Internal Server Error。
日志里清清楚楚:Function 'JSON_CONTAINS' is not defined。
H2 不支持 JSON_CONTAINS,但它不报错,它悄悄地……返回了空结果集。所有用这个函数过滤的测试用例,断言的是"结果为空列表",所以全部通过了。真正的问题,一直等到生产环境才爆。
从那天起,我成了真实 MySQL 集成测试的强烈支持者。今天这篇,把我们彻底告别 H2 的完整迁移方案和踩坑经验全部写出来。
一、H2 vs MySQL:差异远超你想象
在开始写代码之前,我先列几个 H2 和 MySQL 的典型差异,让你感受一下这个坑有多深:
| 特性 | MySQL 8.0 | H2(MySQL 兼容模式) |
|---|---|---|
| JSON 函数 | 完整支持 | 部分支持,行为有差异 |
| 窗口函数 | 完整支持 | 支持,但语法细节有差异 |
| 存储过程 | 支持 | 有限支持 |
| 字符集/排序 | utf8mb4,大小写不敏感 | 大小写敏感 |
| 自增主键行为 | 事务回滚后 ID 不回退 | 可能回退 |
| 隔离级别 | REPEATABLE READ | READ COMMITTED |
| 索引行为 | 真实 B+Tree | 模拟 |
| ON DUPLICATE KEY | 支持 | 支持,但并发行为不同 |
每一行都是一个潜在的线上 Bug。
二、依赖引入
<dependencies>
<!-- Testcontainers 核心 -->
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers</artifactId>
<version>1.19.3</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<version>1.19.3</version>
<scope>test</scope>
</dependency>
<!-- MySQL 模块 -->
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>mysql</artifactId>
<version>1.19.3</version>
<scope>test</scope>
</dependency>
<!-- MySQL JDBC 驱动 -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>test</scope>
</dependency>
<!-- Spring Boot Testcontainers 集成 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
</dependencies>三、基础集成测试配置
3.1 最简单的写法(Spring Boot 3.1+)
@SpringBootTest
@Testcontainers
class UserRepositoryTest {
@Container
@ServiceConnection
static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0.36");
@Autowired
private UserRepository userRepository;
@Test
void 保存用户_MySQL_真实写入() {
User user = new User("test@example.com", "测试用户");
User saved = userRepository.save(user);
assertThat(saved.getId()).isNotNull();
assertThat(userRepository.findByEmail("test@example.com")).isPresent();
}
}3.2 生产级配置(带初始化脚本和参数调优)
@SpringBootTest
@Testcontainers
class ProductionGradeIntegrationTest {
@Container
static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0.36")
.withDatabaseName("app_test")
.withUsername("appuser")
.withPassword("apppass")
// 初始化脚本(建表、基础数据)
.withInitScript("db/init.sql")
// MySQL 参数调优,与生产环境保持一致
.withCommand(
"--character-set-server=utf8mb4",
"--collation-server=utf8mb4_unicode_ci",
"--default-time-zone=+08:00",
"--sql-mode=STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION"
)
.withStartupTimeout(Duration.ofMinutes(3));
@DynamicPropertySource
static void configureDataSource(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");
// 关掉 Flyway/Liquibase 自动迁移,避免与 initScript 冲突
registry.add("spring.flyway.enabled", () -> "false");
}
}四、真实业务场景:JSON 字段查询测试
正是当年那个教训,现在我们必须用真实 MySQL 测 JSON 函数:
@SpringBootTest
@Testcontainers
@Transactional
class ArticleRepositoryIntegrationTest {
@Container
@ServiceConnection
static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0.36")
.withInitScript("db/article_schema.sql");
@Autowired
private ArticleRepository articleRepository;
@Test
void JSON_CONTAINS查询_按标签过滤_返回正确结果() {
// given - 插入测试数据
Article javaArticle = Article.builder()
.title("Java 并发编程")
.tags(List.of("java", "concurrent", "backend"))
.status(ArticleStatus.PUBLISHED)
.build();
Article pythonArticle = Article.builder()
.title("Python 数据分析")
.tags(List.of("python", "data", "analysis"))
.status(ArticleStatus.PUBLISHED)
.build();
articleRepository.saveAll(List.of(javaArticle, pythonArticle));
// when - 用 JSON_CONTAINS 过滤
List<Article> result = articleRepository.findByTag("java");
// then
assertThat(result).hasSize(1);
assertThat(result.get(0).getTitle()).isEqualTo("Java 并发编程");
}
@Test
void 窗口函数_按分类统计排名_结果正确() {
// given
for (int i = 1; i <= 5; i++) {
articleRepository.save(Article.builder()
.title("文章" + i)
.category("技术")
.viewCount(i * 100)
.build());
}
// when - 使用 ROW_NUMBER() 窗口函数
List<ArticleRankDto> ranked = articleRepository.findRankedByCategory("技术");
// then - 验证排名顺序
assertThat(ranked).hasSize(5);
assertThat(ranked.get(0).getRank()).isEqualTo(1);
assertThat(ranked.get(0).getViewCount()).isEqualTo(500);
}
}五、三个深度踩坑实录
坑 1:字符集不一致导致中文查询乱码
现象: 插入中文数据正常,但用中文条件查询返回空结果,换 ASCII 字符查询正常。
原因: 容器默认字符集是 latin1,而应用连接用的是 utf8mb4,导致存储和查询的编码不一致,排序规则不匹配,LIKE 查询无结果。
解法:
@Container
static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0.36")
.withCommand(
"--character-set-server=utf8mb4",
"--collation-server=utf8mb4_unicode_ci"
);
// 同时在连接 URL 中指定字符集
// JDBC URL 自动加上:?characterEncoding=UTF-8&useUnicode=true
// MySQLContainer 的 getJdbcUrl() 已经包含这些参数,不用手动加坑 2:时区问题导致日期存储偏差 8 小时
现象: 存入数据库的 created_at 字段比实际时间少了 8 小时,查询时间范围的测试偶尔失败。
原因: MySQL 容器默认时区是 UTC,而测试机器是 UTC+8,日期对象在 JDBC 序列化时发生了时区转换。
解法:
@Container
static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0.36")
.withCommand("--default-time-zone=+08:00");
// 或者在 JDBC URL 里指定时区
// serverTimezone=Asia/Shanghai
// MySQLContainer 可以通过 withUrlParam 添加更根本的解法是在代码里统一用 Instant 而非 LocalDateTime,彻底避免时区问题。
坑 3:事务隔离级别导致并发测试出现幻读
现象: 有一个测试模拟并发创建相同用户(唯一键冲突),在本地跑没问题,CI 上偶发性地两条数据都插进去了。
原因: H2 默认隔离级别是 READ_COMMITTED,而真实 MySQL 是 REPEATABLE_READ。CI 上某个测试类在 H2 模式下跑,隔离级别不对,并发控制失效。
解法: 彻底删除项目里的 H2 依赖,强制所有数据库相关测试都用 Testcontainers + MySQL:
<!-- 删掉这个依赖,彻底断后路 -->
<!--
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
-->同时在 application-test.yml 里明确配置:
spring:
datasource:
# 不配置默认 url,强制必须通过 DynamicPropertySource 注入
# url: 故意留空,缺少 url 会让测试启动时立即报错,而不是悄悄用错配置六、多 Schema 测试场景
有些项目用多个 Schema,测试时需要同时建多个库:
@Container
static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0.36")
.withUsername("root")
.withPassword("root")
.withDatabaseName("main_db");
@BeforeAll
static void initSchemas() {
// 创建额外的 Schema
try (Connection conn = mysql.createConnection("")) {
conn.createStatement().execute("CREATE DATABASE IF NOT EXISTS log_db");
conn.createStatement().execute("CREATE DATABASE IF NOT EXISTS audit_db");
// 执行各 Schema 的建表脚本
ScriptUtils.executeSqlScript(conn,
new ClassPathResource("sql/log_schema.sql"));
}
}七、与 Flyway 集成
很多项目用 Flyway 管理数据库迁移,Testcontainers 可以完美配合:
@SpringBootTest
@Testcontainers
class FlywayIntegrationTest {
@Container
@ServiceConnection
static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0.36")
.withDatabaseName("appdb");
// application-test.yml 里开启 Flyway
// spring.flyway.enabled=true
// spring.flyway.locations=classpath:db/migration
// Spring Boot 启动时自动执行所有迁移脚本
@Autowired
private Flyway flyway;
@Test
void Flyway迁移_所有版本_无冲突() {
MigrationInfo[] applied = flyway.info().applied();
assertThat(applied).isNotEmpty();
// 验证所有迁移都成功
Arrays.stream(applied).forEach(migration ->
assertThat(migration.getState()).isIn(
MigrationState.SUCCESS,
MigrationState.BASELINE
));
}
}八、生产建议:如何平滑迁移
如果你的项目已经大量使用 H2,迁移到 Testcontainers 建议分阶段:
第一阶段(1-2 天): 引入依赖,创建 AbstractMySQLTest 基类,先让一两个关键测试用上真实 MySQL,验证可行性。
第二阶段(1-2 周): 逐步把涉及 JSON、窗口函数、复杂查询的测试迁移过来。
第三阶段(完成时): 删除 H2 依赖,配置 CI 必须用 MySQL 跑所有数据库测试。
不要试图一次性全部迁移——这会让整个团队产生抵触,分阶段推进成功率更高。
H2 的消亡对我来说是一个解脱,而不是损失。真实 MySQL 的集成测试,是对数据库相关代码最诚实的检验。
