在 Spring Boot 3 中使用 Testcontainers 进行集成测试

1. 概述

在 H2 上运行的集成测试通常在您的本机上显示为“绿色”通过,但在运行 PostgreSQL 的生产环境中仍会出现故障。原因是两个数据库管理系统的 SQL 语法、数据类型和事务行为不同。Testcontainers 通过在测试运行时在 Docker 容器中启动真实的数据库来解决这个问题。

在本文中,您将为 Spring Boot 3.x 应用程序配置 Testcontainers,并使用 @ServiceConnection 将容器连接到 Spring。之后,您将在多个测试类之间共享一个容器,并了解常见的错误。

2. 准备

2.1. 依赖项

您需要在机器上运行 Docker,或一个兼容的 Docker daemon。然后将以下依赖项添加到 pom.xml:

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.postgresql</groupId>
        <artifactId>postgresql</artifactId>
        <scope>runtime</scope>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-testcontainers</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>junit-jupiter</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>postgresql</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

Spring Boot parent 已经管理了 Testcontainers 的版本,所以您不需要声明 <version>。Artifact spring-boot-testcontainers 从 Spring Boot 3.1 开始提供,并提供 @ServiceConnection annotation。

2.2. 要测试的源代码

我们使用一个简单的 Order entity:

@Entity
@Table(name = "orders")
public class Order {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String customerEmail;

    private BigDecimal total;

    @Enumerated(EnumType.STRING)
    private OrderStatus status;

    protected Order() {
    }

    public Order(String customerEmail, BigDecimal total, OrderStatus status) {
        this.customerEmail = customerEmail;
        this.total = total;
        this.status = status;
    }

    public Long getId() { return id; }
    public String getCustomerEmail() { return customerEmail; }
    public BigDecimal getTotal() { return total; }
    public OrderStatus getStatus() { return status; }
}
public enum OrderStatus { NEW, PAID, CANCELLED }

Repository 具有一个使用 PostgreSQL 的 ILIKE 的本地查询:

public interface OrderRepository extends JpaRepository<Order, Long> {

    @Query(value = "select * from orders where customer_email ilike :pattern",
           nativeQuery = true)
    List<Order> searchByEmail(@Param("pattern") String pattern);

    List<Order> findByStatus(OrderStatus status);
}

本地查询是容易“在 H2 上运行但在 Postgres 上损坏”的代码类型,或反之亦然。这正是您应该在真实数据库上测试的地方。

3. 使用 @ServiceConnection 测试 repository

3.1. 声明容器

@DataJpaTest(properties = "spring.jpa.hibernate.ddl-auto=create-drop")
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Testcontainers
class OrderRepositoryTest {

    @Container
    @ServiceConnection
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");

    @Autowired
    private OrderRepository orderRepository;

    @Test
    void givenOrders_whenSearchByEmailIgnoringCase_thenReturnMatches() {
        orderRepository.save(new Order("An.Nguyen@example.com", new BigDecimal("150000"), OrderStatus.NEW));
        orderRepository.save(new Order("binh@example.com", new BigDecimal("90000"), OrderStatus.PAID));

        List<Order> result = orderRepository.searchByEmail("%an.nguyen%");

        assertThat(result)
            .extracting(Order::getCustomerEmail)
            .containsExactly("An.Nguyen@example.com");
    }
}

每个组件的工作方式如下:

  • @Testcontainers 启用 JUnit 5 extension。Extension 查找具有 @Container 的字段并管理其生命周期。
  • @Container 在 static 字段上:容器在类中的所有测试之前启动一次,并在最后一个测试之后停止。如果字段不是 static,容器将为每个测试方法重新创建。
  • @ServiceConnection:Spring Boot 从容器读取主机、随机端口、用户名和密码,然后创建 JdbcConnectionDetails bean。这样您就不必手动配置 spring.datasource.url。
  • @AutoConfigureTestDatabase(replace = NONE):默认情况下 @DataJpaTest 可能会用嵌入式数据库替换 DataSource。从 Spring Boot 3.4 开始,框架会自动识别来自 @ServiceConnection 的 DataSource。不过,明确声明有助于代码在所有 3.x 版本上正确运行。
  • ddl-auto=create-drop:对于非嵌入式数据库,Spring Boot 默认不会自动创建 schema。在实际项目中,您应该使用 Flyway 或 Liquibase,以确保测试正确运行生产环境的迁移。

运行测试时,日志将如下所示:

Creating container for image: postgres:16-alpine
Container postgres:16-alpine is starting: 3f9c1a...
Container postgres:16-alpine started in PT2.1S
HikariPool-1 - Added connection org.postgresql.jdbc.PgConnection@6a1b...

第一次运行会较慢,因为 Docker 需要拉取镜像。之后通常只需要几秒钟。

3.2. 旧方法:@DynamicPropertySource

在 Spring Boot 3.1 之前,或对于尚未受 @ServiceConnection 支持的容器,您可以手动推送属性:

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

这种方法仍然有效。但是,对于 PostgreSQL、MySQL、Redis、Kafka、MongoDB 和许多其他流行服务,@ServiceConnection 更简洁,也更不容易出现属性名称拼写错误。

4. 使用 @SpringBootTest 测试整个应用程序

Repository 测试只加载 JPA 层。要检查 HTTP → service → database 的流程,您需要加载整个上下文。假设有以下控制器:

@RestController
@RequestMapping("/orders")
public class OrderController {

    private final OrderRepository orderRepository;

    public OrderController(OrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }

    public record CreateOrderRequest(String customerEmail, BigDecimal total) {}

    @PostMapping
    public ResponseEntity<Order> create(@RequestBody CreateOrderRequest request) {
        Order saved = orderRepository.save(
            new Order(request.customerEmail(), request.total(), OrderStatus.NEW));
        return ResponseEntity.status(HttpStatus.CREATED).body(saved);
    }

    @GetMapping("/{id}")
    public ResponseEntity<Order> get(@PathVariable Long id) {
        return ResponseEntity.of(orderRepository.findById(id));
    }
}

4.1. 使用 @TestConfiguration 共享容器

与其在每个测试类中声明容器,不如将其设置为一个 bean:

@TestConfiguration(proxyBeanMethods = false)
public class TestcontainersConfig {

    @Bean
    @ServiceConnection
    PostgreSQLContainer<?> postgresContainer() {
        return new PostgreSQLContainer<>("postgres:16-alpine");
    }
}

这里容器由 Spring 管理,而不是 JUnit。Spring 在创建 DataSource 之前启动容器,并在上下文关闭时停止它。Spring TestContext 在具有相同配置的测试类之间缓存上下文,因此这些类共享一个容器。

4.2. 编写测试

@SpringBootTest(
    webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
    properties = "spring.jpa.hibernate.ddl-auto=create-drop")
@Import(TestcontainersConfig.class)
class OrderControllerIT {

    @Autowired
    private TestRestTemplate restTemplate;

    @Autowired
    private OrderRepository orderRepository;

    @BeforeEach
    void cleanDatabase() {
        orderRepository.deleteAll();
    }

    @Test
    void whenCreateOrder_thenItIsPersistedAndReadable() {
        var request = new OrderController.CreateOrderRequest(
            "chi@example.com", new BigDecimal("250000"));

        ResponseEntity<Order> created =
            restTemplate.postForEntity("/orders", request, Order.class);

        assertThat(created.getStatusCode()).isEqualTo(HttpStatus.CREATED);
        Long id = created.getBody().getId();

        Order fetched = restTemplate.getForObject("/orders/" + id, Order.class);

        assertThat(fetched.getCustomerEmail()).isEqualTo("chi@example.com");
        assertThat(fetched.getTotal()).isEqualByComparingTo("250000");
        assertThat(fetched.getStatus()).isEqualTo(OrderStatus.NEW);
        assertThat(orderRepository.findByStatus(OrderStatus.NEW)).hasSize(1);
    }
}

需要注意的几点:

  • RANDOM_PORT 启动真实服务器。TestRestTemplate 会自动指向该端口。
  • 此测试没有 @Transactional,因为 HTTP 请求运行在与测试不同的线程上。数据实际提交到 Postgres,因此您必须在 @BeforeEach 中进行清理。
  • isEqualByComparingTo 按值比较 BigDecimal。这种方式避免了 250000 和 250000.00 因 scale 不同而被认为不相等的问题。
  • Order 类需要由 Jackson 反序列化。如果遇到反序列化错误,请检查默认构造函数和 getter,或使用单独的 record DTO 作为响应。

4.3. 在本地运行应用程序时重用配置

TestcontainersConfig 还有一个鲜为人知的用途:在本地运行应用程序而无需安装 Postgres。在 src/test/java 中创建以下类:

public class TestOrderApplication {

    public static void main(String[] args) {
        SpringApplication.from(OrderApplication::main)
            .with(TestcontainersConfig.class)
            .run(args);
    }
}

然后运行命令:

./mvnw spring-boot:test-run

应用程序与一个 Postgres 容器一起启动。当您关闭应用程序时,容器会停止。

5. 常见错误

5.1. 在多个共享上下文的类中使用静态 @Container

这是最令人讨厌的错误。假设您有一个抽象基类,其中包含 @Container static 和 @ServiceConnection,以及许多继承自它的测试类:

OrderControllerIT  -> 容器在端口 55001 启动,上下文被缓存
CustomerControllerIT -> JUnit 停止旧容器,在端口 55017 启动新容器
                    -> Spring 重用缓存的上下文,仍然指向 55001
                    -> Connection refused / HikariPool timeout

JUnit 在每个类之后停止容器,但 Spring 仍然在缓存中保持旧的上下文。您有两种解决方法:

  1. 使用 bean 容器,如第 4.1 部分所示。在这种情况下,容器的生命周期与上下文绑定。这是推荐的方法。
  2. 使用单例容器:移除 @Container 和 @Testcontainers,然后在静态块中手动启动。Ryuk 将在 JVM 终止时清理容器。
public abstract class AbstractIntegrationTest {

    @ServiceConnection
    static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16-alpine");

    static {
        POSTGRES.start();
    }
}

5.2. 使用 latest 标签

注意: 始终固定镜像版本(postgres:16-alpine),最好与生产环境的版本一致。latest 标签可能导致测试在发布新镜像时出现意外行为变化。

5.3. 测试之间的数据泄漏

@DataJpaTest 默认在每个测试后回滚,但带有 RANDOM_PORT 的 @SpringBootTest 则不会。使用共享容器时,前一个类的数据仍然存在于数据库中。在 @BeforeEach 中清理数据,或使用 @Sql 运行 TRUNCATE 脚本。不要编写依赖于运行顺序的测试。

5.4. CI 环境中没有 Docker

如果 Docker 没有运行,测试会报错:

java.lang.IllegalStateException: Could not find a valid Docker environment.

您需要确保 CI runner 拥有 Docker。如果想在缺少 Docker 时跳过测试而不是报错,可以使用 @Testcontainers(disabledWithoutDocker = true)。在启用此选项之前要仔细考虑,因为被静默跳过的测试很容易被忽忘。

5.5. 通过重用在本地加快速度

要在本地测试运行之间保持容器活动,请在 ~/.testcontainers.properties 文件中启用重用:

testcontainers.reuse.enable=true

然后在创建容器时调用 .withReuse(true)。不应在 CI 上启用此选项,因为容器不会自动清理。

6. 何时不应使用 Testcontainers

Testcontainers 使测试更加可靠,但每个测试会更慢,并且需要 Docker。一些不应使用的情况:

  • 纯逻辑,如计价、验证或映射:用 mock 或真实对象编写单元测试就足够了。单元测试的运行速度快数百倍。
  • 每个测试类有不同的上下文配置:每个配置会创建一个新的上下文。如果容器与上下文绑定,您会有多个容器并行运行。应该将测试整合到几个通用配置中。
  • 通过 HTTP 的第三方服务:通常应该使用 WireMock 而不是尝试在容器中运行真实服务。

一个合理的比例是:大多数是单元测试,而使用 Testcontainers 的集成测试应专注于涉及基础设施的地方,如 repository、本地查询、迁移和消息代理。

7. 结论

在本文中,您使用 Testcontainers 在真实 PostgreSQL 上运行集成测试。@ServiceConnection 完全消除了手动数据源配置。在 @TestConfiguration 中将容器设置为 bean 既有助于在多个测试类之间共享一个容器,也允许使用 spring-boot:test-run 在本地运行应用程序。

需要记住的事情:

  • 固定镜像版本以匹配生产环境。
  • 不要将静态 @Container 与 Spring 的上下文缓存混合。
  • 当测试不在事务中运行时,自己清理数据。
  • 只在真正需要真实数据库的地方使用 Testcontainers。
spring-boottestcontainersintegration-testjunit5postgresql