第 76 章:集成测试与 Testcontainers
学习目标
- 掌握 Testcontainers 用真实数据库/中间件做集成测试
- 学会 Docker Compose 集成测试
- 理解测试隔离与并发执行
一、为什么需要集成测试?
单元测试的局限:
- MyBatis 的 SQL 写错了,单元测试发现不了(Mapper 被 Mock 了)
- @Transactional 回滚逻辑,单元测试验证不了
- Redis 缓存序列化问题,只有真跑 Redis 才知道
集成测试:用真实的 MySQL、Redis、MQ 验证完整流程。
二、传统做法的痛点
java
// ❌ 用 H2 内存数据库做集成测试
@AutoConfigureTestDatabase(replace = Replace.ANY) // 替换为 H2
class TestRepository {
// MySQL 特有的 JSON 字段、ON UPDATE CURRENT_TIMESTAMP → H2 不支持
// SQL 方言差异 → 测试通过但生产报错
}
// ❌ 用开发环境数据库
class TestRepository {
// 多分支同时跑测试 → 数据污染
// 测试用例之间互相影响
}血泪教训:测试用 H2,生产用 MySQL,因为方言差异上线挂了。
三、Testcontainers 救星
Testcontainers:测试启动时自动拉起 Docker 容器(MySQL、Redis、MQ),测试用真实的环境跑,跑完销毁。
四、集成
xml
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers</artifactId>
<version>1.20.1</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<version>1.20.1</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>mysql</artifactId>
<version>1.20.1</version>
<scope>test</scope>
</dependency>五、基础用法
java
@SpringBootTest
@Testcontainers // ① 启用 Testcontainers
class UserRepositoryIT { // IT = Integration Test
// ② 定义容器
@Container
static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0")
.withDatabaseName("taskflow_test")
.withUsername("test")
.withPassword("test")
.withReuse(true); // ③ 复用容器(开发提速)
// ④ 动态属性注入
@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);
}
@Autowired
private UserMapper userMapper;
@Test
void testInsertAndSelect() {
// 用真实的 MySQL 跑测试
User user = new User();
user.setUsername("test_user");
userMapper.insert(user);
User found = userMapper.selectById(user.getId());
assertThat(found.getUsername()).isEqualTo("test_user");
}
}六、常用容器
java
// MySQL
static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0")
.withDatabaseName("test")
.withInitScript("db/init.sql"); // 启动时执行 SQL
// Redis
static GenericContainer<?> redis = new GenericContainer<>("redis:7-alpine")
.withExposedPorts(6379);
// RabbitMQ
static RabbitMQContainer rabbit = new RabbitMQContainer("rabbitmq:3-management");
// Kafka
static KafkaContainer kafka = new KafkaContainer(
DockerImageName.parse("confluentinc/cp-kafka:7.5.0"));
// Elasticsearch
static ElasticsearchContainer es = new ElasticsearchContainer(
DockerImageName.parse("docker.elastic.co/elasticsearch/elasticsearch:8.11.0"));
// Nginx
static GenericContainer<?> nginx = new GenericContainer<>("nginx:alpine")
.withCopyFileToContainer(MountableFile.forClasspathResource("nginx.conf"), "/etc/nginx/nginx.conf")
.withExposedPorts(80);七、多容器组合
java
@Testcontainers
class FullStackIT {
@Container
static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0")
.withDatabaseName("taskflow");
@Container
static GenericContainer<?> redis = new GenericContainer<>("redis:7-alpine")
.withExposedPorts(6379);
@Container
static RabbitMQContainer rabbit = new RabbitMQContainer("rabbitmq:3-management");
@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.redis.host", redis::getHost);
registry.add("spring.redis.port", () -> redis.getMappedPort(6379));
registry.add("spring.rabbitmq.host", rabbit::getHost);
registry.add("spring.rabbitmq.port", rabbit::getAmqpPort);
registry.add("spring.rabbitmq.username", rabbit::getAdminUsername);
registry.add("spring.rabbitmq.password", rabbit::getAdminPassword);
}
@Test
void testCompleteFlow() {
// 测试完整业务:DB + Redis + MQ 都真实
}
}八、容器复用(开发提速)
xml
<!-- testcontainers.properties(src/test/resources) -->
testcontainers.reuse.enable=truejava
@Container
static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0")
.withReuse(true); // ① 容器复用效果:第一次跑测试启动 MySQL 容器(30 秒),后续测试直接复用(0 秒)。开发体验大幅提升。
九、数据初始化
java
// ① SQL 脚本(db/init.sql)
CREATE TABLE sys_user (
id BIGINT PRIMARY KEY,
username VARCHAR(50),
...
);
INSERT INTO sys_user (id, username) VALUES (1, 'admin');
// ② 挂载到容器
@Container
static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0")
.withInitScript("db/init.sql");java
// ③ 用 Flyway / Liquibase 初始化(推荐)
registry.add("spring.flyway.enabled", () -> "true");
registry.add("spring.flyway.locations", () -> "classpath:db/migration");十、测试隔离
用 @Transactional 回滚
java
@SpringBootTest
@Transactional // ① 每个测试回滚
@Rollback
class UserServiceIT {
@Test
void test1() { /* 改 DB,测试后回滚 */ }
@Test
void test2() { /* 干净的 DB */ }
}用 TRUNCATE 清理
java
@Component
public class DatabaseCleaner {
@Autowired
private JdbcTemplate jdbcTemplate;
@Autowired
private DataSource dataSource;
public void clean() {
// ① 禁用外键检查
jdbcTemplate.execute("SET FOREIGN_KEY_CHECKS = 0");
// ② 清空所有表
List<String> tables = getAllTables();
for (String table : tables) {
jdbcTemplate.execute("TRUNCATE TABLE " + table);
}
jdbcTemplate.execute("SET FOREIGN_KEY_CHECKS = 1");
}
}十一、Docker Compose 集成
yaml
# docker-compose-test.yml
version: '3.8'
services:
mysql:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: test
MYSQL_DATABASE: taskflow_test
ports:
- "3306"java
@Testcontainers
class ComposeIT {
@Container
static DockerComposeContainer<?> compose =
new DockerComposeContainer<>(new File("docker-compose-test.yml"))
.withExposedService("mysql", 3306);
@DynamicPropertySource
static void configure(DynamicPropertyRegistry registry) {
compose.getContainerByServiceName("mysql").ifPresent(c -> {
registry.add("spring.datasource.url",
() -> "jdbc:mysql://" + c.getHost() + ":" + c.getMappedPort(3306) + "/taskflow_test");
});
}
}十二、性能优化
java
// ① 启动一次容器,跑所有测试
@Testcontainers
class BaseIT {
@Container
@Restart // ② 自动重启
static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0")
.withReuse(true);
@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);
}
}
// 子类继承,不用重复定义容器
class UserServiceIT extends BaseIT {
@Test
void test1() { ... } // 共享父类的 MySQL 容器
}
class OrderServiceIT extends BaseIT {
@Test
void test2() { ... }
}java
// ③ 并行执行测试
@Testcontainers
@Execution(ExecutionMode.SAME_THREAD) // 容器间不并行
class SerialIT { }
@Testcontainers
@Execution(ExecutionMode.CONCURRENT) // 并行执行(需要独立容器)
class ParallelIT { }十三、CI/CD 集成
yaml
# GitHub Actions
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v3
with:
java-version: '17'
- name: Run tests
run: mvn test
# Testcontainers 会自动在 CI 中启动 Docker 容器yaml
# .gitlab-ci.yml
test:
stage: test
image: maven:3.9-eclipse-temurin-17
services:
- docker:dind # 启用 Docker in Docker
variables:
DOCKER_HOST: tcp://docker:2375
script:
- mvn test十四、最佳实践
yaml
# ① 测试分层
unit tests: ~10ms 每个,跑 1000 个
integration tests: ~1s 每个,跑 100 个
E2E tests: ~10s 每个,跑 10 个
# ② CI 流水线
- 单元测试:每次 push 必跑
- 集成测试:PR merge 前跑
- E2E 测试:发布前跑
# ③ 容器优化
- 用 .withReuse(true) 复用容器
- 用更小的镜像(alpine)
- 提前拉镜像(CI 缓存)| 实践 | 说明 |
|---|---|
| 一个容器一个测试类 | 容器状态隔离 |
| 静态容器 + 子类继承 | 启动开销最小化 |
@Transactional + @Rollback | 测试自动清理 |
| 真用真数据库 | 不再为方言差异买单 |
| CI 缓存镜像 | Docker 镜像预拉取 |
十五、本章小结
| 要点 | 关键 |
|---|---|
| 本质 | 用 Docker 容器跑真实数据库/中间件 |
| 依赖 | testcontainers + junit-jupiter + 容器模块 |
| 容器 | MySQL / Redis / RabbitMQ / Kafka / ES |
| 注入 | @DynamicPropertySource 动态获取端口 |
| 复用 | .withReuse(true) + testcontainers.reuse.enable=true |
| 隔离 | @Transactional + @Rollback / DatabaseCleaner |
| CI | Docker in Docker 自动支持 |
动手练习
练习 1:基础题
用 Testcontainers 启动一个 MySQL 容器,写一个集成测试验证你的 UserMapper 的所有方法。
练习 2:进阶题
集成 MySQL + Redis 容器,写测试验证:用户注册成功 → 缓存写入 → 后续查询走缓存。
练习 3:思考题
你的项目原来用 H2 做集成测试,结果生产 MySQL 上发现 JSON 字段查询失效。如何用 Testcontainers 杜绝这类问题?
下一章:第 77 章:Arthas 与线上诊断 →