Hướng dẫn Testcontainers: Ngừng dùng H2 Database để làm Integration Test

Docker tutorial - IT technology blog
Docker tutorial - IT technology blog

Cảnh tượng quen thuộc: Test ở local chạy ngon nhưng lên CI lại ‘đỏ rực’

Nếu bạn từng viết Integration Test, chắc hẳn bạn đã gặp tình huống này: Test chạy pass 100% trên máy cá nhân dùng H2 Database, nhưng vừa deploy lên Production dùng PostgreSQL thật thì lỗi SQL syntax xuất hiện. Hoặc tệ hơn, bạn dùng chung một Database server cho cả team, dẫn đến việc data của bộ test này ‘đá’ data của bộ test kia, khiến kết quả sai lệch hoàn toàn.

Sau hơn 6 tháng áp dụng Testcontainers vào các dự án lớn, mình thấy đây là cách tốt nhất để đồng nhất môi trường. Thay vì cài cắm thủ công, Testcontainers tự động kích hoạt Docker container đúng phiên bản bạn cần. Khi test xong, nó tự dọn dẹp sạch sẽ. Quy trình này giúp mình giảm tỷ lệ flaky tests (test lúc chạy được lúc không) xuống gần như bằng 0%.

Tại sao Testcontainers lại ‘đáng đồng tiền bát gạo’ hơn Docker Compose?

Nhiều bạn sẽ thắc mắc: ‘Tại sao không dùng Docker Compose cho nhanh?’. Câu trả lời nằm ở khả năng kiểm soát động. Với Testcontainers, bạn quản lý vòng đời container ngay trong code Java hoặc Go.

Điểm cộng lớn nhất là cơ chế map port ngẫu nhiên. Nếu máy bạn đang chạy một Postgres ở port 5432, Testcontainers sẽ tự tìm một port trống khác (như 32768) để khởi tạo container mới. Điều này giúp tránh xung đột hoàn toàn. Ngoài ra, nó có Ryuk — một sidecar container chuyên đi ‘thu dọn chiến trường’. Ngay cả khi tiến trình test bị crash đột ngột, Ryuk vẫn đảm bảo các container test bị tiêu diệt, không làm rác tài nguyên máy.

Trong quá trình cấu hình Docker API, nếu gặp các file JSON phản hồi quá dài, mình thường dùng JSON Formatter để kiểm tra cấu hình nhanh hơn. Việc này tiết kiệm kha khá thời gian so với việc ngồi soi từng dòng code thô.

Triển khai thực tế: Tích hợp PostgreSQL và Redis

Dưới đây là cách mình setup cho một dự án Spring Boot. Các ngôn ngữ khác cũng có logic tương tự.

1. Thêm thư viện cần thiết

Bạn cần khai báo các dependency trong pom.xml. Hãy chú ý sử dụng đúng module cho loại database bạn dùng để tận dụng các hàm tối ưu có sẵn.

<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>postgresql</artifactId>
    <version>1.19.7</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>1.19.7</version>
    <scope>test</scope>
</dependency>

2. Cấu hình Container động

Đừng hardcode URL. Hãy để Testcontainers tự trả về thông tin kết nối sau khi container khởi chạy thành công. Mình thường tạo một class BaseIntegrationTest để dùng chung cho toàn bộ dự án.

@Testcontainers
public abstract class BaseIntegrationTest {

    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15-alpine")
            .withDatabaseName("test_db")
            .withUsername("user")
            .withPassword("pass");

    @Container
    static GenericContainer<?> redis = new GenericContainer<>(DockerImageName.parse("redis:7-alpine"))
            .withExposedPorts(6379);

    @DynamicPropertySource
    static void configureProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", postgres::getJdbcUrl);
        registry.add("spring.data.redis.host", redis::getHost);
        registry.add("spring.data.redis.port", () -> redis.getMappedPort(6379));
    }
}

Cơ chế @DynamicPropertySource là chìa khóa ở đây. Nó giúp ‘bơm’ chính xác port mà Docker vừa cấp phát vào ứng dụng Spring của bạn tại thời điểm runtime.

3. Viết Test Case

Lúc này, việc viết test không khác gì thao tác với database thật. Bạn có thể yên tâm rằng nếu code chạy pass ở đây, nó sẽ chạy đúng trên production.

class UserServiceTest extends BaseIntegrationTest {
    @Autowired
    private UserRepository userRepository;

    @Test
    void shouldCreateUserSuccessfully() {
        User user = new User("admin", "[email protected]");
        userRepository.save(user);
        
        assertNotNull(userRepository.findByUsername("admin"));
    }
}

3 bài học tối ưu để build không bị chậm

Dùng Testcontainers rất thích, nhưng nếu không cẩn thận, thời gian build CI của bạn sẽ tăng vọt. Đây là cách mình tối ưu:

  • Sử dụng Singleton Container: Đừng để mỗi class test khởi động một container mới. Hãy dùng một static container duy nhất cho toàn bộ test suite. Trong một dự án mình từng làm, việc này giúp giảm thời gian build từ 15 phút xuống còn 6 phút.
  • Ưu tiên Image Alpine: Luôn dùng các bản nhẹ như postgres:15-alpine thay vì bản full. Dung lượng image nhỏ giúp server CI pull về nhanh hơn, tiết kiệm băng thông và disk space.
  • Cấp đủ tài nguyên cho Docker: Nếu chạy cùng lúc Postgres, Redis và Kafka, hãy đảm bảo Docker Desktop được cấp ít nhất 4GB RAM. Thiếu RAM là nguyên nhân chính khiến container bị khởi động chậm hoặc crash giữa chừng.

Lời kết

Testcontainers không chỉ là một công cụ, nó là tư duy về việc làm chủ môi trường kiểm thử. Thay vì tốn thời gian than phiền về sự khác biệt giữa các môi trường, hãy dùng code để định nghĩa chúng. Nếu bạn đang gặp khó khăn trong việc setup port động hoặc kết nối trên Jenkins, hãy để lại câu hỏi phía dưới, mình sẽ cùng giải quyết.

Share: