跳至主要內容

Spring Boot 集成测试:Testcontainers 让测试环境不再痛苦

郑天祺大约 10 分钟测试测试TestcontainersUnit Test

Spring Boot 集成测试:Testcontainers 让测试环境不再痛苦

"在我的机器上能跑" —— 这句话终结者。用 Testcontainers 让集成测试环境与生产保持一致。

1. 集成测试 vs 单元测试

1.1 两者的定位

单元测试(Unit Test)
├── 测试范围:单个类/方法
├── 依赖处理:Mock 所有外部依赖
├── 执行速度:毫秒级
├── 目的:验证代码逻辑正确性
└── 类比:测试一个齿轮是否能正常运转

集成测试(Integration Test)
├── 测试范围:多个组件协作
├── 依赖处理:使用真实的数据库、消息队列等
├── 执行速度:秒级
├── 目的:验证组件间交互正确性
└── 类比:测试齿轮组装后整台机器能否运转

1.2 什么时候该写集成测试

必须写集成测试的场景:
├── Repository 层 → 验证 SQL/JPQL 查询是否正确
├── 数据库迁移脚本 → 验证 Flyway/Liquibase 脚本
├── 消息队列 → 验证消息发送和消费
├── 外部 API 调用 → 验证请求/响应序列化
├── 事务边界 → 验证事务回滚和隔离级别
└── 安全配置 → 验证认证授权过滤器链

可以不写的场景:
├── 纯业务逻辑(单元测试更合适)
└── 简单 CRUD(Repository 层的集成测试已经覆盖)

2. Spring Boot Test 核心注解

2.1 关键注解速查

注解作用性能
@SpringBootTest启动完整 Spring 上下文最慢
@WebMvcTest仅加载 Web 层(Controller)
@DataJpaTest仅加载 JPA 层(Repository)较快
@DataMongoTest仅加载 MongoDB 层较快
@RestClientTest仅加载 RestTemplate 相关 Bean
@JsonTest仅加载 JSON 序列化相关 Bean最快
@AutoConfigureMockMvc配置 MockMvc(配合 @SpringBootTest)-
@TestConfiguration仅在测试中使用的额外配置-
@MockBean在容器中替换 Bean 为 Mock-
@SpyBean在容器中包装 Bean 为 Spy-

2.2 分层的测试策略

// ═══ Layer 1: Repository 层(@DataJpaTest) ═══
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
// ↑ 关键:不替换数据源(使用 Testcontainers 提供的真实数据库)
class UserRepositoryTest {

    @Autowired
    private UserRepository userRepository;

    @Autowired
    private TestEntityManager entityManager;

    @Test
    @DisplayName("根据用户名查询 → 存在")
    void findByUsername() {
        // Given
        User user = new User();
        user.setUsername("zhangsan");
        user.setEmail("zhangsan@example.com");
        entityManager.persistAndFlush(user);  // 直接写入数据库

        // When
        Optional<User> result = userRepository.findByUsername("zhangsan");

        // Then
        assertTrue(result.isPresent());
        assertEquals("zhangsan@example.com", result.get().getEmail());
    }

    @Test
    @DisplayName("自定义查询 → 分页查询活跃用户")
    void findActiveUsersWithPagination() {
        // Given
        for (int i = 0; i < 10; i++) {
            User u = new User();
            u.setUsername("user" + i);
            u.setStatus(i % 2 == 0 ? 1 : 0);
            entityManager.persist(u);
        }
        entityManager.flush();

        // When
        Page<User> page = userRepository.findByStatus(1, PageRequest.of(0, 5));

        // Then
        assertEquals(5, page.getContent().size());
        assertEquals(10, page.getTotalElements());
    }
}

// ═══ Layer 2: Controller 层(@WebMvcTest) ═══
@WebMvcTest(UserController.class)
class UserControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private UserService userService;

    @Test
    @DisplayName("GET /api/users → 返回分页用户列表")
    void listUsers() throws Exception {
        Page<User> page = new PageImpl<>(List.of(
            User.builder().id(1L).username("用户A").build(),
            User.builder().id(2L).username("用户B").build()
        ));

        when(userService.listUsers(anyInt(), anyInt())).thenReturn(page);

        mockMvc.perform(get("/api/users")
                .param("page", "1")
                .param("size", "20"))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.data.content.length()").value(2))
            .andExpect(jsonPath("$.data.content[0].username").value("用户A"));
    }
}

// ═══ Layer 3: 全链路(@SpringBootTest + Testcontainers) ═══
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureMockMvc
class UserApiIntegrationTest {

    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private ObjectMapper objectMapper;

    @Test
    @DisplayName("完整流程:注册 → 登录 → 查询个人信息")
    void fullUserJourney() throws Exception {
        // 1. 注册
        String registerResponse = mockMvc.perform(post("/api/auth/register")
                .contentType(MediaType.APPLICATION_JSON)
                .content("""
                    {"username":"testuser","password":"P@ssw0rd123!","email":"test@test.com"}
                    """))
            .andExpect(status().isCreated())
            .andReturn().getResponse().getContentAsString();

        // 2. 登录
        String loginResponse = mockMvc.perform(post("/api/auth/login")
                .contentType(MediaType.APPLICATION_JSON)
                .content("""
                    {"username":"testuser","password":"P@ssw0rd123!"}
                    """))
            .andExpect(status().isOk())
            .andReturn().getResponse().getContentAsString();

        String token = objectMapper.readTree(loginResponse)
                .get("data").get("accessToken").asText();

        // 3. 查询个人信息
        mockMvc.perform(get("/api/users/me")
                .header("Authorization", "Bearer " + token))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.data.username").value("testuser"));
    }
}

3. Testcontainers 简介:为什么比 H2 更好

3.1 H2 的问题

很多团队在测试中使用 H2 内存数据库来替代真实数据库。看似方便,实际隐患重重:

┌─────────────────────────────────────────────────────────┐
│              为什么 H2 ≠ MySQL/PostgreSQL                 │
├─────────────────────────────────────────────────────────┤
│                                                         │
│  H2 做不到的事情:                                        │
│  ├── 不支持的函数:GROUP_CONCAT、JSON_EXTRACT、窗口函数     │
│  ├── 语法差异:LIMIT vs FETCH FIRST、自增主键策略          │
│  ├── 类型差异:TINYINT → Boolean 自动转换                  │
│  ├── 锁行为:InnoDB 行锁 vs H2 表锁完全不同                │
│  └── 字符集:MySQL utf8mb4 表情符号 vs H2 不兼容           │
│                                                         │
│  结果:测试通过,生产炸掉 → "假绿灯"                         │
│                                                         │
└─────────────────────────────────────────────────────────┘

真实案例

// 这段代码在 H2 上测试通过,但在 MySQL 生产环境抛出异常
@Query("SELECT u FROM User u WHERE u.name IN :names ORDER BY FIELD(u.name, :names)")
// FIELD() 是 MySQL 特有函数 → H2 不支持 → 测试通过不了(如果你用 H2)
// 但如果你用 H2 并且这个查询在别处 → 测试就骗了你

3.2 Testcontainers 的优势

┌─────────────────────────────────────────────────────────┐
│            Testcontainers = 真实数据库 + Docker            │
├─────────────────────────────────────────────────────────┤
│                                                         │
│  一次配置,永久受益:                                      │
│  ├── 与生产环境相同的数据库版本                             │
│  ├── 支持 MySQL、PostgreSQL、Redis、Kafka、Elasticsearch  │
│  ├── 容器自动启停,测试结束自动销毁                          │
│  ├── 支持 CI/CD(GitHub Actions、Jenkins)                 │
│  └── 代码即文档:看测试就知道依赖了哪些中间件                  │
│                                                         │
└─────────────────────────────────────────────────────────┘

3.3 依赖配置

<!-- pom.xml -->
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>testcontainers</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>
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>1.20.1</version>
    <scope>test</scope>
</dependency>

4. MySQL/Redis/Kafka 容器配置

4.1 MySQL 容器

// ─── 方式一:编程式启动(推荐,灵活) ───
@Testcontainers  // ← 自动管理容器生命周期
@SpringBootTest
abstract class BaseMySqlTest {

    @Container  // ← 单例容器(所有测试类共享)
    static final MySQLContainer<?> MYSQL = new MySQLContainer<>("mysql:8.0.36")
            .withDatabaseName("testdb")
            .withUsername("test")
            .withPassword("test")
            .withReuse(true);  // ← 开启容器复用(加速)

    @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);
        registry.add("spring.datasource.driver-class-name",
                () -> "com.mysql.cj.jdbc.Driver");
    }
}

// ─── 方式二:自定义 MySQL 镜像(含初始化脚本) ───
static final MySQLContainer<?> CUSTOM_MYSQL = new MySQLContainer<>(
        DockerImageName.parse("mysql:8.0.36")
                .asCompatibleSubstituteFor("mysql"))
        .withDatabaseName("appdb")
        .withUsername("appuser")
        .withPassword("apppass")
        .withInitScript("sql/init-test-data.sql")  // ← 容器启动后执行
        .withConfigurationOverride("mysql-conf");  // ← 自定义 MySQL 配置

testcontainers.properties~/.testcontainers.properties 或项目 src/test/resources):

# 开启容器复用(加速 CI 和本地测试)
testcontainers.reuse.enable=true

# 如果 Docker 不在默认位置
# docker.host=tcp://localhost:2375

# 使用 Ryuk 自动清理(默认开启,CI 中可能需要关闭)
# ryuk.disabled=true

4.2 Redis 容器

@Testcontainers
@SpringBootTest
abstract class BaseRedisTest {

    @Container
    static final GenericContainer<?> REDIS = new GenericContainer<>(
            DockerImageName.parse("redis:7.2-alpine"))
            .withExposedPorts(6379)
            .withReuse(true)
            .withCommand("redis-server", "--requirepass", "testpass");

    @DynamicPropertySource
    static void configureRedis(DynamicPropertyRegistry registry) {
        registry.add("spring.data.redis.host", REDIS::getHost);
        registry.add("spring.data.redis.port", () -> REDIS.getMappedPort(6379));
        registry.add("spring.data.redis.password", () -> "testpass");
    }
}

4.3 Kafka 容器

@Testcontainers
@SpringBootTest
abstract class BaseKafkaTest {

    @Container
    static final KafkaContainer KAFKA = new KafkaContainer(
            DockerImageName.parse("confluentinc/cp-kafka:7.6.1"))
            .withReuse(true)
            .withKraft();  // ← 使用 KRaft 模式(不需要 ZooKeeper)

    @DynamicPropertySource
    static void configureKafka(DynamicPropertyRegistry registry) {
        registry.add("spring.kafka.bootstrap-servers", KAFKA::getBootstrapServers);
    }
}

4.4 组合容器(ComposeContainer)

@Testcontainers
@SpringBootTest
abstract class BaseFullStackTest {

    @Container
    static final DockerComposeContainer<?> COMPOSE =
            new DockerComposeContainer<>(
                    new File("src/test/resources/docker-compose-test.yml"))
                .withExposedService("mysql", 3306)
                .withExposedService("redis", 6379)
                .withExposedService("kafka", 9092)
                .withLocalCompose(true)
                .withReuse(true);

    @DynamicPropertySource
    static void configureProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url",
            () -> "jdbc:mysql://" + COMPOSE.getServiceHost("mysql", 3306)
                    + ":" + COMPOSE.getServicePort("mysql", 3306) + "/testdb");
        registry.add("spring.data.redis.host",
            () -> COMPOSE.getServiceHost("redis", 6379));
        registry.add("spring.data.redis.port",
            () -> COMPOSE.getServicePort("redis", 6379));
    }
}

5. 实战:完整的 API 集成测试

5.1 测试基类

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureMockMvc
@Testcontainers
@Slf4j
public abstract class BaseIntegrationTest {

    @Container
    static final MySQLContainer<?> MYSQL = new MySQLContainer<>("mysql:8.0.36")
            .withDatabaseName("order_test")
            .withUsername("test")
            .withPassword("test")
            .withReuse(true);

    @Autowired
    protected MockMvc mockMvc;

    @Autowired
    protected ObjectMapper objectMapper;

    @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);
    }

    /**
     * 发送 POST 请求
     */
    protected ResultActions post(String url, Object body) throws Exception {
        return mockMvc.perform(
            MockMvcRequestBuilders.post(url)
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(body))
        );
    }

    /**
     * 发送带 Token 的请求
     */
    protected ResultActions postWithToken(String url, Object body,
                                           String token) throws Exception {
        return mockMvc.perform(
            MockMvcRequestBuilders.post(url)
                .contentType(MediaType.APPLICATION_JSON)
                .header("Authorization", "Bearer " + token)
                .content(objectMapper.writeValueAsString(body))
        );
    }

    protected ResultActions getWithToken(String url, String token) throws Exception {
        return mockMvc.perform(
            MockMvcRequestBuilders.get(url)
                .header("Authorization", "Bearer " + token)
        );
    }

    /**
     * 辅助:登录获取 Token
     */
    protected String login(String username, String password) throws Exception {
        String response = post("/api/auth/login",
                new LoginRequest(username, password))
            .andExpect(status().isOk())
            .andReturn().getResponse().getContentAsString();

        return objectMapper.readTree(response)
                .get("data").get("accessToken").asText();
    }

    /**
     * 辅助:从 JSON 响应提取数据
     */
    protected <T> T extractData(String jsonResponse, Class<T> clazz) throws Exception {
        String dataJson = objectMapper.readTree(jsonResponse)
                .get("data").toString();
        return objectMapper.readValue(dataJson, clazz);
    }
}

5.2 订单服务集成测试

@DisplayName("订单服务集成测试")
class OrderIntegrationTest extends BaseIntegrationTest {

    private String userToken;
    private String adminToken;

    @BeforeEach
    void setUp() throws Exception {
        // 创建测试用户并登录
        post("/api/auth/register",
            new RegisterRequest("testuser", "P@ssw0rd!", "test@test.com"))
            .andExpect(status().isCreated());

        userToken = login("testuser", "P@ssw0rd!");
        adminToken = login("admin", "Admin@123!");
    }

    @Test
    @DisplayName("完整订单流程:创建 → 支付 → 发货 → 确认收货")
    void completeOrderFlow() throws Exception {
        // ─── Step 1: 创建订单 ───
        CreateOrderRequest createRequest = CreateOrderRequest.builder()
                .items(List.of(
                    OrderItem.of(1L, 2),  // 商品1, 数量2
                    OrderItem.of(2L, 1)   // 商品2, 数量1
                ))
                .addressId(1L)
                .couponCode("SAVE10")
                .build();

        String createResponse = postWithToken("/api/orders", createRequest, userToken)
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.data.status").value("PENDING_PAYMENT"))
                .andReturn().getResponse().getContentAsString();

        OrderVO order = extractData(createResponse, OrderVO.class);
        Long orderId = order.getId();

        // ─── Step 2: 支付订单 ───
        postWithToken("/api/orders/" + orderId + "/pay",
                new PayRequest("ALIPAY"), userToken)
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.data.status").value("PAID"));

        // ─── Step 3: 管理员发货 ───
        postWithToken("/api/orders/" + orderId + "/ship",
                new ShipRequest("SF1234567890"), adminToken)
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.data.status").value("SHIPPED"));

        // ─── Step 4: 确认收货 ───
        postWithToken("/api/orders/" + orderId + "/confirm",
                null, userToken)
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.data.status").value("COMPLETED"));
    }

    @Test
    @DisplayName("取消订单 → 只有待支付状态可以取消")
    void cancelOrderOnlyWhenPending() throws Exception {
        // 创建订单
        String response = postWithToken("/api/orders",
                buildCreateRequest(), userToken)
            .andExpect(status().isCreated())
            .andReturn().getResponse().getContentAsString();

        Long orderId = extractData(response, OrderVO.class).getId();

        // 可以取消(待支付状态)
        postWithToken("/api/orders/" + orderId + "/cancel", null, userToken)
                .andExpect(status().isOk());

        // 不能再取消(已取消状态)
        postWithToken("/api/orders/" + orderId + "/cancel", null, userToken)
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("$.message")
                        .value("只有待支付状态的订单可以取消"));
    }

    @Test
    @DisplayName("权限控制 → 普通用户不能发货")
    void normalUserCannotShip() throws Exception {
        String response = postWithToken("/api/orders",
                buildCreateRequest(), userToken)
            .andExpect(status().isCreated())
            .andReturn().getResponse().getContentAsString();

        Long orderId = extractData(response, OrderVO.class).getId();

        // 先支付
        postWithToken("/api/orders/" + orderId + "/pay",
                new PayRequest("ALIPAY"), userToken);

        // 普通用户尝试发货 → 应被拒绝
        postWithToken("/api/orders/" + orderId + "/ship",
                new ShipRequest("SF123"), userToken)
                .andExpect(status().isForbidden());
    }
}

6. 测试数据管理:@Sql 与 Flyway

6.1 使用 @Sql 注解

@SpringBootTest
@Sql(scripts = ["/sql/cleanup.sql", "/sql/test-data.sql"],
     executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD)
@Sql(scripts = "/sql/cleanup.sql",
     executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD)
class OrderServiceWithSqlTest {

    @Autowired
    private OrderService orderService;

    @Test
    @DisplayName("已有测试数据的场景")
    void withPreloadedData() {
        // sql/test-data.sql 中已插入:
        // INSERT INTO users VALUES (1, 'existing_user', ...);
        // INSERT INTO products VALUES (1, '商品A', 100.00, 50);

        Order order = orderService.createOrder(1L, List.of(
            new OrderItem(1L, 2)  // 商品A × 2
        ));

        assertEquals(new BigDecimal("200.00"), order.getTotalAmount());
    }
}

sql/cleanup.sql

SET FOREIGN_KEY_CHECKS = 0;
TRUNCATE TABLE order_items;
TRUNCATE TABLE orders;
TRUNCATE TABLE products;
TRUNCATE TABLE users;
SET FOREIGN_KEY_CHECKS = 1;

sql/test-data.sql

INSERT INTO users (id, username, password, email, status)
VALUES (1, 'existing_user', '$2a$10$...', 'user@test.com', 1);

INSERT INTO products (id, name, price, stock)
VALUES
    (1, '商品A', 100.00, 50),
    (2, '商品B', 200.00, 30),
    (3, '商品C', 50.00, 0);  -- 库存为 0 的用于测试库存不足

6.2 Flyway 与测试

# application-test.yml
spring:
  flyway:
    enabled: true
    locations: classpath:db/migration,classpath:db/testdata
    # db/migration → 生产迁移脚本
    # db/testdata → 仅测试用的数据脚本
src/test/resources/
├── db/
│   └── testdata/
│       └── R__test_data.sql    # R__ 前缀 = 可重复执行的迁移
-- R__test_data.sql(可重复执行,Flyway 会在内容变化时重新执行)
INSERT IGNORE INTO users (id, username, password, email, status)
VALUES
    (100, 'test_user',  '$2a$10$...', 'test@test.com',  1),
    (101, 'test_admin', '$2a$10$...', 'admin@test.com', 1);

INSERT IGNORE INTO user_roles (user_id, role_id)
VALUES (100, 1), (101, 2);

7. CI 中的集成测试:如何加速

7.1 完整的 CI 配置(GitHub Actions)

# .github/workflows/ci.yml
name: CI with Integration Tests

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  integration-test:
    runs-on: ubuntu-latest

    services:
      # 方式一:GitHub Actions Service Container(无需 Testcontainers)
      mysql:
        image: mysql:8.0.36
        env:
          MYSQL_ROOT_PASSWORD: root
          MYSQL_DATABASE: testdb
        ports:
          - 3306:3306
        options: >-
          --health-cmd="mysqladmin ping"
          --health-interval=10s
          --health-timeout=5s
          --health-retries=3

      redis:
        image: redis:7.2-alpine
        ports:
          - 6379:6379

    steps:
      - uses: actions/checkout@v4

      - name: Set up JDK 17
        uses: actions/setup-java@v4
        with:
          java-version: '17'
          distribution: 'temurin'
          cache: maven

      - name: Run tests
        run: mvn verify -Pintegration-test
        env:
          SPRING_DATASOURCE_URL: jdbc:mysql://localhost:3306/testdb
          SPRING_DATASOURCE_USERNAME: root
          SPRING_DATASOURCE_PASSWORD: root
          SPRING_DATA_REDIS_HOST: localhost

7.2 加速策略

// ─── 策略 1: 容器复用 ───
// testcontainers.properties
// testcontainers.reuse.enable=true
// 注意:CI 中需要确保容器在构建间不会残留,加一个清理步骤

// ─── 策略 2: Singleton Container Pattern ───
// 在抽象基类中定义 @Container static,所有测试类共享一个容器
public abstract class BaseIntegrationTest {
    @Container
    static final MySQLContainer<?> MYSQL = new MySQLContainer<>("mysql:8.0.36");

    // 所有集成测试继承此类 → 只启动一次 MySQL 容器
}

// ─── 策略 3: 按模块分组执行 ───
// 不需要每个测试都启动完整的 Spring Context
@DataJpaTest  // 只启动 JPA 相关 Bean
class OrderRepositoryTest { }

@WebMvcTest  // 只启动 Web 相关 Bean
class OrderControllerTest { }

@SpringBootTest  // 仅在必要时启动完整 Context
class OrderFullIntegrationTest { }

// ─── 策略 4: Maven 并行执行 ───
// pom.xml
<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-surefire-plugin</artifactId>
    <configuration>
        <parallel>classes</parallel>
        <threadCount>4</threadCount>
    </configuration>
</plugin>

7.3 测试加速汇总

┌─────────────────────────────────────────────────────────────┐
│              加速集成测试的 5 个策略                            │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  ① 容器复用(testcontainers.reuse.enable=true)                │
│     本地开发:容器不销毁,重启测试秒级完成                        │
│     CI:容器在构建步骤间复用(需要 Docker-in-Docker 支持)        │
│                                                             │
│  ② Singleton Container(单例容器)                             │
│     所有测试类共享一个容器实例,启动一次                          │
│                                                             │
│  ③ 分层测试                                               │
│     @DataJpaTest / @WebMvcTest / @SpringBootTest             │
│     按需加载,80% 的测试用更轻量的注解                           │
│                                                             │
│  ④ 数据库初始化优化                                         │
│     用 INIT SCRIPT 一次性初始化 vs 每个测试类 @Sql               │
│     Flyway R__ 前缀脚本 + 内置测试数据                          │
│                                                             │
│  ⑤ CI 缓存                                              │
│     缓存 Docker 镜像层                                        │
│     缓存 Maven 本地仓库                                       │
│                                                             │
└─────────────────────────────────────────────────────────────┘

总结

集成测试是保证软件质量的关键环节,Testcontainers 让它从"痛苦"变成了"愉悦":

  1. 用真实数据库,告别 H2 的"假绿灯"
  2. 容器化一切依赖,让测试环境与生产一致
  3. 分层测试,不同层次用不同粒度的测试注解
  4. 合理管理测试数据,@Sql 做简单场景,Flyway 做复杂数据
  5. 持续优化速度,容器复用 + 并行执行 + CI 缓存

最后提醒:集成测试不是越多越好,在关键路径上写集成测试(数据库查询、消息传递、外部 API),在业务逻辑上写单元测试 —— 两者配合,才是最高效的质量策略。

上次编辑于:
贡献者: zhengtianqi