Backend

99 Ngày Spring — Ngày 55: Testcontainers

SSite Admin
20 tháng 09, 2026 8 phút đọc 9 lượt xem
99 Ngày Spring — Ngày 55: Testcontainers

Ngày 53 ta nêu vấn đề: H2 không phải Postgres, và "xanh trên máy, chết trên production" là loại lỗi giết chết niềm tin vào bộ test. Hôm nay là lời giải đầy đủ — Testcontainers dựng phụ thuộc thật trong Docker, tự động vứt đi sau khi chạy. Và bài này cũng khép lại năm ngày kiểm thử bằng một bức tranh: tầng nào test cái gì, và nguyên tắc cuối cùng đáng nhớ hơn mọi annotation.

Sketchnote Ngày 55: Testcontainers — ServiceConnection thay cả khối DynamicPropertySource, một container cho cả bộ test, reuse chỉ dùng ở máy dev, ghim phiên bản image, và kim tự tháp năm ngày kiểm thử

Phụ thuộc thật, dựng lên rồi vứt đi

Trước Testcontainers, một đội có ba lựa chọn và cả ba đều dở: H2 thay Postgres, một DB dùng chung cho cả đội, hay mock cả tầng repository. Testcontainers cho lựa chọn thứ tư — chạy đúng thứ bạn dùng trên production, trong một container dựng lên lúc test và biến mất sau đó.

// TESTCONTAINERS — chạy phụ thuộc THẬT trong Docker, dựng lên và vứt đi tự động.

// Ngày 53 đã nêu vấn đề; hôm nay là lời giải đầy đủ.
// Trước Testcontainers, một đội có ba lựa chọn và cả ba đều dở:
//   ① H2 thay Postgres        → nhanh, nhưng KHÔNG phải thứ chạy production (Ngày 53)
//   ② Một DB dùng chung cho cả đội → test giẫm chân nhau, ai đó xóa dữ liệu là cả đội đứng
//   ③ Mock cả tầng repository  → không kiểm tra được câu SQL nào cả

@Testcontainers
@SpringBootTest
class DatHangIntegrationTest {

    @Container
    @ServiceConnection                      // ⭐ Spring Boot 3.1+ — điểm bước ngoặt
    static PostgreSQLContainer<?> db =      //    tự nối url/user/password vào DataSource
        new PostgreSQLContainer<>("postgres:16-alpine");
}

// ⭐ VÌ SAO @ServiceConnection LÀ BƯỚC NGOẶT: trước nó bạn phải viết như này —
@DynamicPropertySource
static void props(DynamicPropertyRegistry r) {
    r.add("spring.datasource.url", db::getJdbcUrl);
    r.add("spring.datasource.username", db::getUsername);
    r.add("spring.datasource.password", db::getPassword);
}
// Sáu dòng cho mỗi loại phụ thuộc, và mỗi service lại chép lại một lần.
// Giờ một annotation là xong, và nó hỗ trợ sẵn Postgres, MySQL, MongoDB,
// Redis, Kafka, RabbitMQ, Elasticsearch...

// ❗ static — NHẮC LẠI vì đây là lỗi tốn thời gian nhất:
//    static → container dựng MỘT lần cho cả class test
//    không static → dựng lại cho TỪNG test, chậm gấp hàng chục lần
// ❗ VÀ container ĐƯỢC TỰ ĐỘNG DỌN: Testcontainers chạy một container phụ tên
//    Ryuk, chuyên đi dọn xác container sau khi JVM tắt — kể cả khi test crash.
  • @ServiceConnection của Spring Boot 3.1 là điểm bước ngoặt: nó thay cả khối @DynamicPropertySource sáu dòng mà mỗi service phải chép lại một lần.

  • Hỗ trợ sẵn Postgres, MySQL, MongoDB, Redis, Kafka, RabbitMQ, Elasticsearch — không chỉ database.

  • Nhớ static: container dựng một lần cho cả class; bỏ nó là dựng lại cho từng test, chậm gấp hàng chục lần.

  • Container tự được dọn nhờ một container phụ tên Ryuk — kể cả khi test crash giữa chừng.

Tốc độ — lý do duy nhất người ta ngại

Phản đối thường gặp là "chậm quá". Đúng, nếu bạn để mỗi class test dựng một container riêng. Bốn kỹ thuật dưới đây đưa nó về mức chấp nhận được — và kỹ thuật đầu tiên đi cùng đúng bài học cache context của hôm qua.

// TỐC ĐỘ — lý do duy nhất khiến người ta ngại Testcontainers, và cách xử lý.

// ① MỘT CONTAINER CHO CẢ BỘ TEST — đây là cải thiện lớn nhất.
//    Mặc định mỗi class test dựng container riêng. Gom lại bằng lớp cha (Ngày 54):
@Testcontainers
abstract class BaseIntegrationTest {
    @Container @ServiceConnection
    static PostgreSQLContainer<?> db = new PostgreSQLContainer<>("postgres:16-alpine");
}
// Ba mươi class kế thừa → MỘT container, và cũng là MỘT context Spring.
// ⭐ Hai thứ này đi cùng nhau: cùng cấu hình thì Spring dùng lại cache (Ngày 54).

// ② REUSE — giữ container sống giữa các lần chạy, cực hợp lúc phát triển:
// ~/.testcontainers.properties
testcontainers.reuse.enable=true
new PostgreSQLContainer<>("postgres:16-alpine").withReuse(true);
// Lần đầu 5 giây, những lần sau ~0 giây vì container vẫn đang chạy.
// ❗ ĐỪNG bật reuse trên CI: mỗi lần chạy phải sạch, không được mang theo
//    dữ liệu của lần trước. Reuse là công cụ cho MÁY LẬP TRÌNH VIÊN.

// ③ CHỌN IMAGE NHẸ và GHIM PHIÊN BẢN:
new PostgreSQLContainer<>("postgres:16-alpine")   // ✅ alpine nhỏ hơn nhiều
new PostgreSQLContainer<>("postgres:latest")      // ❌ latest đổi lúc nào không hay
// ❗ Dùng latest nghĩa là một sáng đẹp trời CI đỏ vì Postgres lên phiên bản mới
//    mà không ai trong đội làm gì cả. Luôn ghim số phiên bản.

// ④ DỌN DỮ LIỆU GIỮA CÁC TEST — vì @Transactional KHÔNG rollback được
//    thao tác của server trong test tích hợp (Ngày 54):
@AfterEach
void donDep() { jdbc.execute("TRUNCATE don_hang, ton_kho RESTART IDENTITY CASCADE"); }
// ⭐ TRUNCATE nhanh hơn DELETE rất nhiều, và RESTART IDENTITY đưa id về 1
//    để test không phụ thuộc vào giá trị id ngẫu nhiên còn sót lại.
  • Một lớp cha chung cho cả bộ test → một container và cũng là một context Spring; hai thứ này đi cùng nhau vì cùng cấu hình thì Spring dùng lại cache (Ngày 54).

  • reuse giữ container sống giữa các lần chạy — chỉ dùng ở máy lập trình viên, tuyệt đối không bật trên CI vì mỗi lần chạy phải sạch.

  • Luôn ghim số phiên bản image: dùng latest nghĩa là một sáng đẹp trời CI đỏ vì Postgres lên phiên bản mà không ai làm gì cả.

  • TRUNCATE ... RESTART IDENTITY để dọn giữa các test — nhanh hơn DELETE nhiều, và đưa id về 1 để test không phụ thuộc giá trị còn sót.

Chạy trong CI, và bức tranh năm ngày

Trên GitHub Actions thì không cần cài gì thêm — runner đã có Docker, và ./mvnw verify là đủ. Nhưng có bốn thứ hay làm CI đỏ trong khi máy bạn xanh, và biết trước thì đỡ mất một buổi chiều.

// CHẠY TRONG CI — và bức tranh lớn khép lại năm ngày kiểm thử.

// GitHub Actions: KHÔNG cần cài gì thêm, runner đã có sẵn Docker.
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with: { java-version: '21', distribution: 'temurin', cache: 'maven' }
      - run: ./mvnw verify              # ⭐ chỉ vậy thôi. Testcontainers tự lo phần còn lại.

// ❗ BỐN THỨ HAY LÀM CI ĐỎ MÀ MÁY BẠN XANH:
// ① Docker-in-Docker: GitLab CI cần dịch vụ dind, hoặc mount docker.sock.
// ② Máy build không có Docker → Testcontainers ném lỗi ngay khi khởi động.
// ③ Kéo image chậm hoặc bị rate-limit của Docker Hub → dùng registry nội bộ và cache.
// ④ Container chưa sẵn sàng: Testcontainers CÓ chờ sẵn, nhưng dịch vụ khởi động chậm
//    thì phải nói rõ điều kiện chờ:
new GenericContainer<>("my-service:1.0")
    .waitingFor(Wait.forHttp("/health").forStatusCode(200))
    .withStartupTimeout(Duration.ofMinutes(2));

// ⭐ NĂM NGÀY KIỂM THỬ, GÓI LẠI THÀNH MỘT BỨC TRANH:
//   Ngày 51 — unit test service:  vài mili-giây · logic nghiệp vụ  · RẤT NHIỀU
//   Ngày 52 — @WebMvcTest:        vài trăm ms   · định tuyến, JSON  · nhiều
//   Ngày 53 — @DataJpaTest:       vài trăm ms   · truy vấn, ràng buộc · vừa
//   Ngày 54 — @SpringBootTest:    vài giây      · cả luồng nghiệp vụ  · ÍT
//   Ngày 55 — Testcontainers:     làm cho ba tầng dưới chạy trên PHỤ THUỘC THẬT

// ⭐ NGUYÊN TẮC CUỐI, đáng nhớ hơn mọi annotation trong năm ngày qua:
//    Test PHẢI CHẠY NHANH, nếu không sẽ không ai chạy.
//    Test PHẢI ĐÁNG TIN, nếu không sẽ không ai tin.
//    Một bộ test chậm hoặc hay đỏ vô cớ thì tệ hơn không có test — vì nó tiêu tốn
//    thời gian của cả đội mà không mua lại được sự yên tâm nào.
  • GitLab CI cần Docker-in-Docker hoặc mount docker.sock; máy build không có Docker thì Testcontainers ném lỗi ngay lúc khởi động.

  • Rate-limit của Docker Hub là nguyên nhân đỏ CI phổ biến mà ít ai nghĩ tới — dùng registry nội bộ và cache image.

  • Dịch vụ khởi động chậm thì phải nói rõ điều kiện chờ bằng Wait.forHttp thay vì hy vọng.

  • Nguyên tắc cuối: test phải nhanh nếu không sẽ không ai chạy, và phải đáng tin nếu không sẽ không ai tin — một bộ test chậm hoặc hay đỏ vô cớ còn tệ hơn không có.

Bài tập nhỏ

  • Đổi một @DataJpaTest từ H2 sang Testcontainers Postgres và đo thời gian chạy.

  • Bỏ static khỏi container rồi chạy năm test — xem thời gian tăng bao nhiêu.

  • Bật reuse ở máy bạn và so lần chạy đầu với lần thứ hai.

  • Gom hai class test vào một lớp cha có container chung và đếm số container được dựng.

  • Viết @AfterEach dùng TRUNCATE ... RESTART IDENTITY rồi kiểm tra test không còn phụ thuộc thứ tự.

Kết lại

Bốn ý gói lại hôm nay: Testcontainers cho bạn phụ thuộc thật thay vì thứ gần giống, và @ServiceConnection biến cấu hình sáu dòng thành một annotation; một container cùng một context cho cả bộ test là cải thiện tốc độ lớn nhất, và nó đi cùng bài học cache của Ngày 54; reuse chỉ dành cho máy lập trình viênluôn ghim phiên bản image, nếu không CI sẽ đỏ vào một ngày chẳng ai đụng gì; và nguyên tắc khép lại cả năm ngày: test phải nhanh nếu không ai chạy, phải đáng tin nếu không ai tin. Ngày 56 mở giai đoạn mới: Spring Boot Actuator — quan sát ứng dụng đang chạy. Hẹn gặp lại!

S

Site Admin

Engineer and writer. Building things with TypeScript and distributed systems.

Bình luận (0)

Bạn cần đăng nhập bằng Google để bình luận.

Hãy là người bình luận đầu tiên.

Bài viết liên quan

99 Ngày Java — Ngày 55: Intermediate operations

Phân biệt map và flatMap bằng kiểu trả về, distinct im lặng không lọc gì nếu thiếu equals, sorted phá vỡ tính lười, peek không được đảm bảo chạy — và vì sao skip không phải cách phân trang dữ liệu từ database.

20 thg 9, 20268 phút6
99 Ngày Spring — Ngày 54: @SpringBootTest

Khi nào test tích hợp mới xứng đáng, vì sao RANDOM_PORT là lựa chọn đúng, cache context quyết định bộ test chạy hai phút hay bốn mươi phút — và vì sao @WithMockUser hoàn toàn vô nghĩa ở đây.

19 thg 9, 20268 phút6
99 Ngày Java — Ngày 54: Stream API nhập môn

Dây chuyền ba phần và luật không có phép kết thúc thì không chạy gì, tính lười cho ngắt sớm và dữ liệu vô hạn, vì sao lọc trước sắp sau nhanh hơn hàng chục lần — và khi nào vòng lặp vẫn thắng.

19 thg 9, 20268 phút6