在 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 从容器读取主机、随机端口、用户名和密码,然后创建JdbcConnectionDetailsbean。这样您就不必手动配置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 仍然在缓存中保持旧的上下文。您有两种解决方法:
- 使用 bean 容器,如第 4.1 部分所示。在这种情况下,容器的生命周期与上下文绑定。这是推荐的方法。
- 使用单例容器:移除
@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。