Spring Boot 测试与 Testcontainers 实战

单元测试用 H2 内存数据库跑得好好的,上线到 MySQL 就出问题?Testcontainers 让你在测试时启动一个真实的 Docker 数据库容器,测试环境和生产环境完全一致。本文从依赖配置到容器复用,讲清 Testcontainers 在 Spring Boot 中的实战用法。

Testcontainers 配图:蓝色渐变背景上容器方块通过管道连接数据库圆柱与应用窗口,扁平科技插画
Testcontainers:测试环境与生产环境用同一个真实数据库

一、集成测试的痛点

传统 Spring Boot 集成测试用 H2 内存数据库做替代。问题是 H2 不是 MySQL:自增主键写法不同、SQL 方言有差异、索引行为不一样。测试过了不代表生产过了。Testcontainers 的解决方案很直接:测试时用 Docker 启动一个和生产一模一样的 MySQL 容器,测试结束自动销毁。

方案数据库与生产一致速度
H2 内存库H2不一致快
TestcontainersMySQL/PG完全一致慢但可靠

二、引入依赖

<!-- pom.xml -->
<!-- Spring Boot Test -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>

<!-- Testcontainers -->
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>testcontainers</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>mysql</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>junit-jupiter</artifactId>
    <scope>test</scope>
</dependency>
前置条件:本机必须安装 Docker 并运行中。Testcontainers 通过 Docker 守护进程启动容器。CI 环境也要配置 Docker 支持。

三、第一个 Testcontainers 测试

@SpringBootTest
@Testcontainers
class UserRepositoryTest {

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

    @DynamicPropertySource
    static void configureProperties(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 UserRepository userRepository;

    @Test
    void shouldSaveAndFindUser() {
        User user = new User();
        user.setName("小明");
        user.setEmail("xm@example.com");
        userRepository.save(user);

        Optional<User> found = userRepository.findById(user.getId());
        assertThat(found).isPresent();
        assertThat(found.get().getName()).isEqualTo("小明");
    }
}

@Container 加 static 表示所有测试方法共享同一个容器,避免每个方法都启动一次。@DynamicPropertySource 把容器的连接信息动态注入 Spring 配置。

四、容器复用模式

每次测试启动 MySQL 要 10-20 秒,整个测试套件跑下来很慢。Testcontainers 提供了单例容器模式:整个测试类只启动一次容器。

// 单例容器:整个 JVM 只启动一次
public abstract class AbstractContainerBaseTest {

    static final MySQLContainer<?> MYSQL;

    static {
        MYSQL = new MySQLContainer<>("mysql:8.0")
            .withDatabaseName("testdb")
            .withUsername("test")
            .withPassword("test");
        MYSQL.start();
    }

    @DynamicPropertySource
    static void databaseProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", MYSQL::getJdbcUrl);
        registry.add("spring.datasource.username", MYSQL::getUsername);
        registry.add("spring.datasource.password", MYSQL::getPassword);
    }
}

// 所有集成测试继承这个基类
class UserServiceTest extends AbstractContainerBaseTest {
    // 测试方法...
}

五、测试 REST API

结合 @SpringBootTest 和 TestRestTemplate,可以测试完整的 HTTP 接口。

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
class UserApiTest extends AbstractContainerBaseTest {

    @Autowired
    private TestRestTemplate restTemplate;

    @Test
    void shouldCreateUser() {
        UserCreateRequest request = new UserCreateRequest("小红", "xh@example.com");

        ResponseEntity<UserResponse> response = restTemplate.postForEntity(
            "/users", request, UserResponse.class
        );

        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
        assertThat(response.getBody().getName()).isEqualTo("小红");
    }
}

六、常见问题

Docker 没启动:报 Could not find a valid Docker environment。先启动 Docker Desktop。

容器启动慢:用单例容器模式 + 配置 Docker 镜像加速。CI 里可以用 Testcontainers 的 Cloud 功能。

数据残留:每个测试方法前用 @Transactional 回滚,或者用 @Sql 重置数据。

七、总结

Testcontainers 解决了集成测试最大的可信度问题:测试环境和生产环境用同一个数据库。代价是测试变慢,但换来的是"测试通过就能上线"的信心。建议核心业务路径的集成测试都用 Testcontainers,纯逻辑单元测试继续用 Mockito。