Testcontainers 完整实战——用真实 Docker 容器做集成测试的完整方案
Testcontainers 完整实战——用真实 Docker 容器做集成测试的完整方案
适读人群:Java 后端开发者、测试工程师 | 阅读时长:约 18 分钟 | 核心价值:掌握 Testcontainers 从入门到完整落地的全流程
去年双十一前两周,我们团队经历了一次让人印象深刻的线上事故。
那是一个看起来非常普通的需求:用户下单后,系统把订单写入 MySQL,同时往 Redis 写一条缓存,然后发一条 Kafka 消息通知下游结算服务。整个链路在本地测过、CI 跑过,单元测试全绿,代码评审通过,自信满满上线。
上线后的第三个小时,监控告警炸了。下游结算服务报错:消息体里的订单状态字段是 null。查日志,查代码,前后折腾了一个半小时,才发现问题出在一行看似无害的 @Transactional 上。我们的 Kafka 消息是在事务提交之前发出去的,下游拿到消息去查 MySQL,数据还没提交,读到了脏数据,字段自然是 null。
这个 bug,Mock 测试完全发现不了。因为 Mock 的 KafkaTemplate 根本不关心事务,MockBean 里的数据库也不存在真实的事务提交时序。只有当 Kafka 真正连接到一个真实的 Broker、MySQL 真正运行在一个有事务机制的引擎里,这个问题才会暴露。
那次事故之后,我在团队里推行了一条铁律:所有跨中间件的集成路径,必须用真实容器跑集成测试。Testcontainers,就是这条铁律的技术载体。
今天这篇,我把我们团队从零到生产落地 Testcontainers 的完整方案写出来,不讲原理讲实战,踩过的坑全部记录在案。
一、Testcontainers 是什么,解决什么问题
在聊具体用法之前,我先说清楚它解决了什么痛点,这样你才知道该在什么场景用它。
传统集成测试的困境:
H2 内存数据库不够真实:H2 的 SQL 语法和 MySQL 有差异,特别是 JSON 函数、窗口函数、索引行为,线上跑 MySQL 的代码在 H2 里完全可能测通但上线挂。
Mock 掩盖了真实交互问题:MockBean 把 Redis、Kafka、ES 全部 Mock 掉之后,你测的其实是一个"假的集成",中间件的真实行为(事务、消息顺序、序列化)全部绕过了。
依赖本地环境导致"在我机器上是好的":如果测试依赖本地启动的 MySQL,团队不同成员机器上的版本、配置不一样,测试结果不稳定。
Testcontainers 的核心思路:
在测试运行时,用 Docker 动态拉起真实的中间件容器,测试结束后自动销毁。测试代码直接连接这个真实容器,行为与生产环境高度一致。
整个过程对测试代码透明——你只需要拿到容器暴露的端口和连接参数,其他的什么都不用管。
二、环境准备与依赖引入
前置条件:
- Java 17+
- Maven 3.8+ 或 Gradle 7+
- Docker Desktop(本地开发)/ Docker Engine(CI)
- Spring Boot 3.x(本文示例基于 Spring Boot 3.2)
Maven 依赖:
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-bom</artifactId>
<version>1.19.3</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<!-- 核心依赖 -->
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers</artifactId>
<scope>test</scope>
</dependency>
<!-- JUnit 5 支持 -->
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<!-- MySQL 模块 -->
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>mysql</artifactId>
<scope>test</scope>
</dependency>
<!-- Spring Boot 测试支持 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
</dependencies>三、第一个完整示例:MySQL 集成测试
我用一个真实的业务场景来演示:用户注册服务,写入 MySQL,验证幂等性。
被测服务:
@Service
@RequiredArgsConstructor
public class UserRegistrationService {
private final UserRepository userRepository;
@Transactional
public User register(RegisterRequest request) {
if (userRepository.existsByEmail(request.getEmail())) {
throw new DuplicateEmailException("邮箱已注册: " + request.getEmail());
}
User user = User.builder()
.email(request.getEmail())
.username(request.getUsername())
.passwordHash(BCrypt.hashpw(request.getPassword(), BCrypt.gensalt()))
.createdAt(LocalDateTime.now())
.build();
return userRepository.save(user);
}
}集成测试:
@SpringBootTest
@Testcontainers
@Transactional
class UserRegistrationServiceIntegrationTest {
@Container
static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0.36")
.withDatabaseName("testdb")
.withUsername("testuser")
.withPassword("testpass")
.withReuse(true); // 开发阶段复用容器加速
@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");
}
@Autowired
private UserRegistrationService userRegistrationService;
@Autowired
private UserRepository userRepository;
@Test
void 注册成功_新用户_返回用户实体() {
// given
RegisterRequest request = new RegisterRequest("zhang@example.com", "老张", "secure123");
// when
User user = userRegistrationService.register(request);
// then
assertThat(user.getId()).isNotNull();
assertThat(user.getEmail()).isEqualTo("zhang@example.com");
assertThat(userRepository.count()).isEqualTo(1);
}
@Test
void 注册失败_邮箱重复_抛出异常() {
// given
RegisterRequest request = new RegisterRequest("duplicate@example.com", "用户A", "pass123");
userRegistrationService.register(request);
// when & then
assertThatThrownBy(() -> userRegistrationService.register(
new RegisterRequest("duplicate@example.com", "用户B", "pass456")))
.isInstanceOf(DuplicateEmailException.class)
.hasMessageContaining("邮箱已注册");
}
@Test
void 注册失败_邮箱格式错误_数据库不写入() {
// given - 构造一个会触发数据库约束违反的请求
// 假设 email 字段有唯一索引,直接尝试写入空 email
assertThatThrownBy(() -> userRegistrationService.register(
new RegisterRequest(null, "用户", "pass")))
.isInstanceOf(Exception.class);
// 验证数据库确实没有脏数据
assertThat(userRepository.count()).isZero();
}
}四、三个核心踩坑实录
坑 1:容器启动超时导致 CI 不稳定
现象: 本地测试稳定通过,CI 上每隔几次就失败一次,错误是 ContainerLaunchException: Timed out waiting for container port to open。
原因: CI 机器 Docker pull 镜像时受网络影响,默认超时时间(60 秒)不够用。第一次拉镜像的时候尤其明显。
解法:
// 方式一:延长超时
@Container
static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0.36")
.withStartupTimeout(Duration.ofMinutes(5)); // 给足时间
// 方式二:CI 环境预先 pull 镜像(在 pipeline yaml 里加)
// docker pull mysql:8.0.36
// docker pull redis:7.2
// docker pull confluentinc/cp-kafka:7.5.0我们最终在 CI 的 pipeline 开头加了一个 docker pull 步骤,把常用镜像预热,彻底解决了这个问题。
坑 2:@DynamicPropertySource 与 Spring 上下文缓存冲突
现象: 多个测试类各自定义了 @DynamicPropertySource,跑全量测试时某些类偶发拿到了错误的数据库连接。
原因: Spring 的测试上下文会被缓存复用。如果两个测试类的 @DynamicPropertySource 注入了不同的属性值,后一个类可能复用了前一个类创建的上下文,连接参数没有更新。
解法: 把容器定义抽取到共享的基类或 AbstractIntegrationTest 中,所有集成测试继承它,保证容器实例和上下文唯一。
// 所有集成测试的基类
@SpringBootTest
@Testcontainers
public abstract class AbstractIntegrationTest {
@Container
protected static final MySQLContainer<?> MYSQL = new MySQLContainer<>("mysql:8.0.36")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test");
@DynamicPropertySource
static void overrideProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", MYSQL::getJdbcUrl);
registry.add("spring.datasource.username", MYSQL::getUsername);
registry.add("spring.datasource.password", MYSQL::getPassword);
}
}这样全套测试共享同一个 MySQL 容器实例,上下文缓存也能正常工作,速度大幅提升。
坑 3:测试间数据污染导致顺序依赖
现象: 单独跑某个测试方法全部通过,一起跑就有几个随机失败,失败原因都是"数据已存在"或"数据不存在"。
原因: 测试方法之间共享数据库,前一个测试写入的数据没有清理,影响了后一个测试的断言。
解法: 对于 Spring Data JPA + MySQL,最简单的方式是在测试类上加 @Transactional,每个测试方法结束后自动回滚:
@SpringBootTest
@Testcontainers
@Transactional // 每个测试方法结束后自动回滚
class OrderServiceIntegrationTest extends AbstractIntegrationTest {
// 测试方法之间完全隔离
}但注意:@Transactional 不适用于涉及异步操作或多线程的测试。这种场景需要在 @AfterEach 中手动清理数据:
@AfterEach
void cleanUp() {
orderRepository.deleteAll();
userRepository.deleteAll();
}五、Spring Boot 3.x 新特性:ServiceConnection
Spring Boot 3.1 引入了 @ServiceConnection,可以省去 @DynamicPropertySource 的样板代码:
@SpringBootTest
@Testcontainers
class ModernIntegrationTest {
@Container
@ServiceConnection // 自动配置数据源,无需 DynamicPropertySource
static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0.36");
@Container
@ServiceConnection
static RedisContainer redis = new RedisContainer("redis:7.2");
// Spring Boot 自动识别容器类型,注入正确的连接参数
// 不需要写任何 @DynamicPropertySource 代码
}@ServiceConnection 支持的容器类型包括:MySQL、PostgreSQL、MongoDB、Redis、Kafka、RabbitMQ 等主流中间件。这是目前最推荐的写法。
六、完整的多容器集成测试示例
真实业务往往涉及多个中间件。下面是一个订单创建的完整集成测试,同时使用 MySQL 和 Redis:
@SpringBootTest
@Testcontainers
class OrderCreationIntegrationTest {
@Container
@ServiceConnection
static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0.36")
.withInitScript("sql/schema.sql"); // 自动执行建表脚本
@Container
@ServiceConnection
static GenericContainer<?> redis = new GenericContainer<>("redis:7.2-alpine")
.withExposedPorts(6379);
@Autowired
private OrderService orderService;
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private OrderRepository orderRepository;
@Test
void 创建订单_写入数据库_同步更新缓存() throws InterruptedException {
// given
CreateOrderRequest request = CreateOrderRequest.builder()
.userId(1001L)
.productId(500L)
.quantity(2)
.build();
// when
Order order = orderService.createOrder(request);
// then - 验证数据库
Optional<Order> savedOrder = orderRepository.findById(order.getId());
assertThat(savedOrder).isPresent();
assertThat(savedOrder.get().getStatus()).isEqualTo(OrderStatus.PENDING);
// then - 验证 Redis 缓存
String cacheKey = "order:" + order.getId();
Object cachedOrder = redisTemplate.opsForValue().get(cacheKey);
assertThat(cachedOrder).isNotNull();
}
@Test
void 创建订单_库存不足_回滚所有操作() {
// given - 库存为 0 的商品
CreateOrderRequest request = CreateOrderRequest.builder()
.userId(1001L)
.productId(999L) // 库存为 0 的商品
.quantity(1)
.build();
// when & then
assertThatThrownBy(() -> orderService.createOrder(request))
.isInstanceOf(InsufficientStockException.class);
// 验证数据库没有脏数据(事务回滚生效)
assertThat(orderRepository.count()).isZero();
// 验证 Redis 没有脏数据
String cacheKey = "order:*";
Set<String> keys = redisTemplate.keys(cacheKey);
assertThat(keys).isEmpty();
}
}七、项目结构建议
推荐把集成测试相关的基础设施代码统一组织:
src/
└── test/
├── java/
│ └── com/example/
│ ├── base/
│ │ ├── AbstractIntegrationTest.java # 所有集成测试基类
│ │ ├── AbstractApiTest.java # API 测试基类(含 RestAssured 配置)
│ │ └── TestContainersConfig.java # 容器定义和配置
│ ├── service/
│ │ ├── UserServiceIntegrationTest.java
│ │ └── OrderServiceIntegrationTest.java
│ └── api/
│ ├── UserApiIntegrationTest.java
│ └── OrderApiIntegrationTest.java
└── resources/
├── application-test.yml # 测试专用配置
└── sql/
├── schema.sql # 建表脚本
└── test-data.sql # 基础测试数据八、性能优化要点
集成测试最大的抱怨是"太慢"。以下是我们团队用的优化手段,把测试时间从 8 分钟压到了 2 分 40 秒:
容器复用(Reuse):在
~/.testcontainers.properties里加testcontainers.reuse.enable=true,开发阶段容器启动一次反复使用。共享容器实例:用
static+ 基类继承,全套测试共享一组容器,避免每个测试类都启动新容器。并行执行:在
src/test/resources/junit-platform.properties中配置:
junit.jupiter.execution.parallel.enabled=true
junit.jupiter.execution.parallel.mode.default=concurrent
junit.jupiter.execution.parallel.config.strategy=fixed
junit.jupiter.execution.parallel.config.fixed.parallelism=4- 按需引入模块:不要把所有容器都放在基类里。只有真正需要 Redis 的测试才引入 Redis 容器,避免不必要的启动开销。
Testcontainers 不是银弹,它需要 Docker 环境,启动比纯单元测试慢。但对于任何涉及跨中间件交互的业务逻辑,它是目前我见过的最务实的集成测试方案。
那次双十一的事故之后,我们团队再没有因为"Mock 掩盖了真实行为"而上线出问题。这条防线,值得建。
