Tại sao Unit Test là chưa đủ: Làm chủ Testcontainers để kiểm thử tích hợp thực tế

DevOps tutorial - IT technology blog
DevOps tutorial - IT technology blog

Cái giá đắt của các phím tắt ‘In-Memory’

Unit test của bạn đều vượt qua (màu xanh), nhưng log trên môi trường production lại đang ‘báo động đỏ’. Tất cả chúng ta đều đã từng thấy điều này: cơ sở dữ liệu ‘in-memory’ H2 dùng để kiểm thử chấp nhận một truy vấn mà thực tế instance PostgreSQL trên production lại từ chối thẳng thừng.

Tôi từng mất bốn giờ để gỡ lỗi một sự cố sập hệ thống trên production do một window function đặc thù của Postgres — vốn hoạt động hoàn hảo trong môi trường giả lập (mock) nhưng lại thất bại trong thực tế. Dựa dẫm vào H2 để kiểm thử giống như việc tập luyện cho một cuộc marathon trên máy chạy bộ — nó tiện lợi, nhưng không giúp bạn sẵn sàng cho địa hình thực tế.

Duy trì một database kiểm thử dùng chung cũng đau đớn không kém. Khi hai lập trình viên cùng kích hoạt bản build CI/CD đồng thời, họ thường ghi đè dữ liệu của nhau, gây ra các bài test chập chờn (flaky tests) thất bại mà không rõ nguyên nhân. Testcontainers giải quyết vấn đề này bằng cách tận dụng Docker để khởi tạo các phụ thuộc (dependencies) cô lập và có thể xóa bỏ ngay lập tức.

So sánh các phương pháp Integration Testing

Để hiểu tại sao hạ tầng được quản lý bằng mã nguồn (programmatic infrastructure) đang chiếm ưu thế, hãy cùng xem cách chúng ta xử lý các phụ thuộc bên ngoài trong quá khứ:

1. Mocking và Cơ sở dữ liệu In-Memory

Đây là phương pháp ‘nhanh nhưng ảo’. Bạn sử dụng Mockito để giả lập database hoặc chạy một instance H2 trong RAM. Mặc dù các bài test hoàn thành trong vài phần nghìn giây, chúng lại không thể phát hiện lỗi cấu hình, sai lệch schema, hoặc các logic đặc thù của database như trigger và stored procedure. Nó mang lại một cảm giác an toàn giả tạo.

2. Thiết lập Docker Compose lỏng lẻo

Nhiều đội ngũ duy trì tệp docker-compose.yml riêng cho việc kiểm thử. Điều này yêu cầu lập trình viên phải chạy docker-compose up thủ công trước khi bắt đầu bộ test. Trong môi trường CI/CD, đây là một cơn ác mộng. Bạn phải viết các script tùy chỉnh để kiểm tra xem container đã ‘khỏe mạnh’ (healthy) chưa trước khi bắt đầu test và xử lý việc dọn dẹp nếu bản build bị sập giữa chừng.

3. Testcontainers: Hạ tầng dưới dạng mã nguồn (Infrastructure as Code)

Testcontainers đảo ngược tình thế bằng cách quản lý vòng đời của container trực tiếp bên trong mã kiểm thử của bạn. Khi bộ test bắt đầu, thư viện sẽ giao tiếp với Docker API để tải các image cần thiết và khởi động các dịch vụ. Sau khi bài test kết thúc, nó sẽ xóa sạch mọi thứ. Nó tự động hóa việc ánh xạ cổng (port mapping), kiểm tra sức khỏe và dọn dẹp tài nguyên mà không cần bất kỳ sự can thiệp thủ công nào.

Sự đánh đổi: Có đáng không?

Không có công cụ nào là viên đạn bạc. Mặc dù Testcontainers là lựa chọn mặc định của tôi cho các dịch vụ hiện đại, bạn cần tính đến chi phí tài nguyên.

Những lợi ích

  • Cô lập hoàn toàn: Mỗi lần chạy test sẽ có một database sạch nguyên bản. Bạn sẽ không bao giờ phải chịu cảnh ‘dữ liệu bẩn’ gây ra những thất bại ngẫu nhiên nữa.
  • Sự tương đồng với Production: Bạn đang kiểm thử trên đúng phiên bản — ví dụ: PostgreSQL 15.4 — mà bạn sử dụng trên production.
  • Tiếp nhận nhân sự mượt mà: Một lập trình viên mới chỉ cần cài đặt Docker. Họ không cần phải làm theo một tệp README 10 bước để cấu hình database cục bộ.
  • Ánh xạ cổng động: Nó ánh xạ các cổng nội bộ (như 5432) sang các cổng ngẫu nhiên ở dải cao trên máy chủ. Điều này ngăn chặn lỗi ‘Address already in use’ đáng sợ trong quá trình chạy build song song.

Những thách thức

  • Độ trễ khi khởi động: Việc tải một Docker image 200MB và chờ engine khởi tạo sẽ thêm khoảng 10–20 giây vào lần chạy test đầu tiên của bạn.
  • Tiêu tốn tài nguyên: Việc chạy toàn bộ các container Postgres, Redis và Kafka có thể dễ dàng ngốn từ 2GB đến 4GB RAM.
  • Độ phức tạp của CI/CD: Các build runner của bạn phải hỗ trợ Docker. Điều này có thể yêu cầu cấu hình ‘Docker-in-Docker’ (DinD) hoặc gắn (mount) Docker socket.

Thiết lập khuyến nghị cho các dự án hiện đại

Chúng ta sẽ sử dụng stack Java và Spring Boot cho ví dụ này, nhưng logic cốt lõi vẫn tương tự dù bạn sử dụng Python, Go hay Node.js. Trước tiên, hãy thêm các dependency cần thiết vào pom.xml của bạn:

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

Triển khai: Bài Integration Test đầu tiên của bạn

Thiết lập điều này một cách chính xác là kỹ năng nền tảng cho bất kỳ kỹ sư backend nào. Một khi bạn trải nghiệm độ tin cậy của các bài integration test thực tế, việc mocking tầng dữ liệu sẽ tạo cảm giác như một bước lùi.

1. Định nghĩa môi trường Container

Chúng ta bắt đầu bằng cách khai báo container PostgreSQL. Tôi khuyên bạn nên sử dụng các tag -alpine để giữ kích thước image nhỏ và thời gian tải xuống nhanh.

@Testcontainers
@SpringBootTest
class MyIntegrationTest {

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

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

    @Test
    void connectionEstablished() {
        assertThat(postgres.isCreated()).isTrue();
        assertThat(postgres.isRunning()).isTrue();
    }
}

2. Vai trò của DynamicPropertySource

Annotation @DynamicPropertySource đóng vai trò rất quan trọng. Bởi vì Testcontainers gán một cổng ngẫu nhiên cho database, ứng dụng của bạn không có cách nào biết trước JDBC URL. Phương thức này lấy URL động tại thời điểm runtime và đưa nó vào môi trường Spring trước khi context được tải.

3. Tích hợp CI/CD: GitHub Actions

Chạy các bài test này trong pipeline đơn giản đến kinh ngạc vì các runner hiện đại như ubuntu-latest đã được cài đặt sẵn Docker. Dưới đây là tệp .github/workflows/test.yml đã được tinh gọn:

name: Java CI
on: [push]
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v3
    - name: Set up JDK 17
      uses: actions/setup-java@v3
      with:
        java-version: '17'
        distribution: 'temurin'
        cache: maven
    - name: Run Tests
      run: mvn test
      env:
        DOCKER_HOST: unix:///var/run/docker.sock

4. Mẹo nâng cao: Singleton Container để tăng tốc

Nếu bạn có 50 lớp kiểm thử và mỗi lớp lại khởi tạo container riêng, bản build CI của bạn sẽ kéo dài vô tận. Một chiến lược tốt hơn là pattern Singleton Container. Bằng cách sử dụng một lớp cơ sở trừu tượng (abstract base class), bạn có thể khởi động container một lần và chia sẻ nó cho toàn bộ bộ test. Theo kinh nghiệm của tôi, điều này có thể giảm thời gian build tới 70%.

public abstract class BaseIntegrationTest {
    static final PostgreSQLContainer<?> postgres;

    static {
        postgres = new PostgreSQLContainer<>("postgres:15-alpine");
        postgres.start();
    }

    @DynamicPropertySource
    static void configureProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", postgres::getJdbcUrl);
    }
}

Lời kết

Testcontainers xóa bỏ khoảng cách giữa môi trường phát triển và production. Nó loại bỏ lời bào chữa “nó hoạt động trên máy của tôi” bằng cách đảm bảo rằng mọi lập trình viên và CI runner đều hoạt động trên cùng một hạ tầng. Mặc dù hình phạt 15 giây khởi động là có thật, nhưng sự tự tin mà bạn có được trong các bản phát hành là hoàn toàn xứng đáng. Hãy bắt đầu bằng cách di chuyển các bài test database quan trọng nhất của bạn — phiên bản tương lai của chính bạn sẽ cảm ơn bạn trong lần triển khai production tiếp theo.

Share: