Skip to content
第 76 / 250 章后端⏱ 10 分钟阅读

第 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=true
java
@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
CIDocker in Docker 自动支持

动手练习

练习 1:基础题

用 Testcontainers 启动一个 MySQL 容器,写一个集成测试验证你的 UserMapper 的所有方法。

练习 2:进阶题

集成 MySQL + Redis 容器,写测试验证:用户注册成功 → 缓存写入 → 后续查询走缓存。

练习 3:思考题

你的项目原来用 H2 做集成测试,结果生产 MySQL 上发现 JSON 字段查询失效。如何用 Testcontainers 杜绝这类问题?


下一章第 77 章:Arthas 与线上诊断

本站基于 VitePress 构建 · 由 Codebook 团队维护