Integration test với Testcontainers trong Spring Boot 3

1. Tổng quan

Integration test chạy trên H2 thường cho kết quả “xanh” trên máy bạn nhưng vẫn lỗi trên production chạy PostgreSQL. Lý do là cú pháp SQL, kiểu dữ liệu và hành vi transaction của hai hệ quản trị khác nhau. Testcontainers giải quyết vấn đề này bằng cách khởi động một database thật trong Docker container ngay khi test chạy.

Trong bài này, bạn sẽ cấu hình Testcontainers cho một ứng dụng Spring Boot 3.x và dùng @ServiceConnection để nối container vào Spring. Sau đó bạn sẽ chia sẻ một container cho nhiều test class và nắm được những lỗi hay gặp.

2. Chuẩn bị

2.1. Dependency

Bạn cần Docker đang chạy trên máy, hoặc một Docker daemon tương thích. Sau đó thêm các dependency sau vào 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 đã quản lý version của Testcontainers, nên bạn không cần khai báo <version>. Artifact spring-boot-testcontainers có từ Spring Boot 3.1 và cung cấp annotation @ServiceConnection.

2.2. Mã nguồn cần test

Ta dùng một entity Order đơn giản:

@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 có một native query dùng ILIKE của PostgreSQL:

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

Native query là loại code dễ “chạy được trên H2 nhưng hỏng trên Postgres”, hoặc ngược lại. Đây chính là chỗ bạn nên test trên database thật.

3. Test repository với @ServiceConnection

3.1. Khai báo container

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

Từng thành phần làm việc sau:

  • @Testcontainers bật JUnit 5 extension. Extension này tìm các field có @Container và quản lý vòng đời của chúng.
  • @Container trên field static: container khởi động một lần trước mọi test trong class và dừng sau test cuối cùng. Nếu field không phải static, container sẽ được tạo lại cho từng test method.
  • @ServiceConnection: Spring Boot đọc host, port ngẫu nhiên, username và password từ container, rồi tạo bean JdbcConnectionDetails. Nhờ đó bạn không phải tự cấu hình spring.datasource.url.
  • @AutoConfigureTestDatabase(replace = NONE): mặc định @DataJpaTest có thể thay DataSource bằng database nhúng. Từ Spring Boot 3.4, framework tự nhận ra DataSource đến từ @ServiceConnection. Tuy vậy, khai báo tường minh giúp code chạy đúng trên mọi bản 3.x.
  • ddl-auto=create-drop: với database không nhúng, Spring Boot mặc định không tự tạo schema. Trong dự án thật, bạn nên dùng Flyway hoặc Liquibase để test chạy đúng migration của production.

Khi chạy test, log sẽ có dạng:

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...

Lần đầu chạy sẽ chậm hơn vì Docker phải pull image. Những lần sau thường chỉ mất vài giây.

3.2. Cách cũ: @DynamicPropertySource

Trước Spring Boot 3.1, hoặc với các container chưa được @ServiceConnection hỗ trợ, bạn đẩy property thủ công:

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

Cách này vẫn hoạt động. Tuy nhiên, với PostgreSQL, MySQL, Redis, Kafka, MongoDB và nhiều dịch vụ phổ biến khác, @ServiceConnection ngắn gọn hơn và ít lỗi gõ sai tên property hơn.

4. Test toàn bộ ứng dụng với @SpringBootTest

Repository test chỉ nạp tầng JPA. Để kiểm tra luồng HTTP → service → database, bạn cần nạp toàn bộ context. Giả sử có controller sau:

@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. Chia sẻ container bằng @TestConfiguration

Thay vì khai báo container trong từng test class, bạn đặt nó thành một bean:

@TestConfiguration(proxyBeanMethods = false)
public class TestcontainersConfig {

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

Ở đây container do Spring quản lý chứ không phải JUnit. Spring khởi động container trước khi tạo DataSource và dừng nó khi context bị đóng. Spring TestContext cache context giữa các test class có cùng cấu hình, nên các class đó dùng chung một container.

4.2. Viết test

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

Một vài điểm cần chú ý:

  • RANDOM_PORT khởi động server thật. TestRestTemplate tự trỏ tới port đó.
  • Test này không có @Transactional, vì request HTTP chạy trên thread khác với test. Dữ liệu được commit thật vào Postgres, nên bạn phải dọn dẹp trong @BeforeEach.
  • isEqualByComparingTo so sánh BigDecimal theo giá trị. Cách này tránh lỗi 250000 khác 250000.00 do scale khác nhau.
  • Lớp Order cần được Jackson deserialize. Nếu bạn gặp lỗi deserialize, hãy kiểm tra constructor mặc định và getter, hoặc dùng một record DTO riêng cho response.

4.3. Dùng lại cấu hình khi chạy ứng dụng ở local

TestcontainersConfig còn có một công dụng ít người biết: chạy ứng dụng ở local mà không cần cài Postgres. Tạo class sau trong src/test/java:

public class TestOrderApplication {

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

Sau đó chạy lệnh:

./mvnw spring-boot:test-run

Ứng dụng khởi động cùng một Postgres container. Container này dừng khi bạn tắt ứng dụng.

5. Lỗi hay gặp

5.1. Static @Container trong nhiều class dùng chung context

Đây là lỗi khó chịu nhất. Giả sử bạn có một abstract base class chứa @Container static cùng @ServiceConnection, và nhiều test class kế thừa nó:

OrderControllerIT  -> container khởi động ở port 55001, context được cache
CustomerControllerIT -> JUnit dừng container cũ, khởi động container mới ở port 55017
                    -> Spring dùng lại context cache, vẫn trỏ tới 55001
                    -> Connection refused / HikariPool timeout

JUnit dừng container sau mỗi class, nhưng Spring vẫn giữ context cũ trong cache. Bạn có hai cách sửa:

  1. Dùng bean container như ở mục 4.1. Khi đó vòng đời container gắn với context. Đây là cách được khuyến nghị.
  2. Dùng singleton container: bỏ @Container và @Testcontainers, rồi khởi động thủ công trong khối static. Ryuk sẽ dọn container khi JVM kết thúc.
public abstract class AbstractIntegrationTest {

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

    static {
        POSTGRES.start();
    }
}

5.2. Dùng tag latest

Lưu ý: Luôn ghim version image (postgres:16-alpine), và tốt nhất là trùng với version trên production. Tag latest khiến test có thể đổi hành vi bất ngờ khi image mới được phát hành.

5.3. Dữ liệu rò rỉ giữa các test

@DataJpaTest mặc định rollback sau mỗi test, nhưng @SpringBootTest với RANDOM_PORT thì không. Khi dùng chung container, dữ liệu từ class trước vẫn còn trong database. Hãy dọn dữ liệu trong @BeforeEach, hoặc dùng @Sql để chạy script TRUNCATE. Đừng viết test phụ thuộc vào thứ tự chạy.

5.4. Môi trường CI không có Docker

Nếu Docker không chạy, test sẽ báo lỗi:

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

Bạn cần đảm bảo runner CI có Docker. Nếu muốn bỏ qua test khi thiếu Docker thay vì báo fail, dùng @Testcontainers(disabledWithoutDocker = true). Hãy cân nhắc kỹ trước khi bật tùy chọn này, vì test bị skip im lặng rất dễ bị bỏ quên.

5.5. Tăng tốc ở local bằng reuse

Để giữ container sống giữa các lần chạy test ở local, bạn bật reuse trong file ~/.testcontainers.properties:

testcontainers.reuse.enable=true

Sau đó gọi .withReuse(true) khi tạo container. Không nên bật tùy chọn này trên CI, vì container sẽ không được dọn tự động.

6. Khi nào không nên dùng Testcontainers

Testcontainers giúp test đáng tin hơn, nhưng mỗi test sẽ chậm hơn và cần Docker. Một số trường hợp không nên dùng:

  • Logic thuần như tính giá, validate hay mapping: viết unit test với mock hoặc object thật là đủ. Unit test chạy nhanh hơn hàng trăm lần.
  • Mỗi test class một cấu hình context khác nhau: mỗi cấu hình tạo ra một context mới. Nếu container gắn với context, bạn sẽ có nhiều container chạy song song. Hãy gom các test về một vài cấu hình chung.
  • Dịch vụ bên thứ ba qua HTTP: thường nên dùng WireMock thay vì cố chạy dịch vụ thật trong container.

Một tỷ lệ hợp lý là: đa số là unit test, còn integration test với Testcontainers tập trung vào những chỗ chạm tới hạ tầng, như repository, native query, migration và message broker.

7. Kết luận

Trong bài này, bạn đã dùng Testcontainers để chạy integration test trên PostgreSQL thật. @ServiceConnection giúp bỏ hoàn toàn cấu hình datasource thủ công. Đặt container thành bean trong @TestConfiguration vừa giúp chia sẻ một container cho nhiều test class, vừa cho phép chạy ứng dụng ở local bằng spring-boot:test-run.

Những điều cần nhớ:

  • Ghim version image cho khớp với production.
  • Không trộn static @Container với context cache của Spring.
  • Tự dọn dữ liệu khi test không chạy trong transaction.
  • Chỉ dùng Testcontainers ở những chỗ thực sự cần database thật.
spring-boottestcontainersintegration-testjunit5postgresql