Testcontainers — Hết cảnh mock database khi viết integration test

Phong Hy

Hồi mới làm backend, mình từng bị một vụ ám ảnh: unit test chạy xanh lè trên máy, mà lên production thì chết sấp mặt. Nguyên nhân? Mình mock cái database. Service giả trả dữ liệu đẹp như mơ, còn PostgreSQL thật thì có constraint, có transaction, có cái kiểu dữ liệu mà mock không bao giờ mô phỏng nổi. Từ đó mình ngộ ra một chuyện: mock thì nhanh, nhưng integration test mới là thứ cứu mạng.

Testcontainers là gì?

Testcontainers là thư viện cho phép test khởi động container Docker thật — database, message queue, cache — ngay trong lúc chạy test. Test xong là container tự dọn. Không cần cài PostgreSQL local, không cần database riêng cho CI, không cần mock.

// ví dụ với testcontainers-go
func TestUserRepo(t *testing.T) {
    ctx := context.Background()

    pg, err := postgres.RunContainer(ctx,
        postgres.WithImage("postgres:16-alpine"),
        postgres.WithDatabase("testdb"),
        postgres.WithUsername("test"),
        postgres.WithPassword("test"),
    )
    if err != nil {
        t.Fatal(err)
    }
    defer pg.Terminate(ctx)

    url, _ := pg.ConnectionString(ctx, "sslmode=disable")
    db, _ := sql.Open("pgx", url)
    // ... test repo thật với PostgreSQL thật
}

Chỉ vài dòng là có một PostgreSQL 16 thật sự chạy trên máy, port tự động cấp, test xong xoá sạch. Cái hay là nó giống production tới mức tối đa — cùng version image, cùng engine, cùng hành vi.

Vì sao mock không đủ?

Mock có chỗ đứng của nó — unit test logic thuần thì nhanh và ổn định. Nhưng có mấy thứ mock không bao giờ dạy bạn:

  • Constraint của database: unique, foreign key, check — mock không có.
  • Transaction & isolation level: deadlock, race khi ghi đồng thời — mock không có.
  • SQL dialect thật: jsonb, FOR UPDATE SKIP LOCKED, full-text search — mock không có.

Mình từng viết một cái repo dùng INSERT ... ON CONFLICT DO UPDATE — mock trả về thành công hết, lên production mới lòi ra chuyện conflict. Testcontainers bắt được lỗi đó ngay từ lúc viết code.

Lập trình viên đang code test Ảnh: ThisIsEngineering — Pexels

Kinh nghiệm thực chiến

Chạy được rồi, nhưng có mấy cái bẫy mình dính và muốn cảnh báo sớm:

1. Khởi động container chậm — đừng tạo mới mỗi test. Pull image lần đầu mất cả phút. Giải pháp: dùng singleton container — một container dùng chung cho cả suite, mỗi test tự tạo database/schema riêng (hoặc truncate giữa các test).

// testmain.go — khởi động 1 lần, dùng cho tất cả
func TestMain(m *testing.M) {
    pg, _ := postgres.RunContainer(context.Background(), ...)
    os.Exit(m.Run())
}

2. Chạy song song phải cẩn thận. Container dùng chung mà test chạy t.Parallel() thì dễ đụng dữ liệu. Hoặc tạo một container cho mỗi package test, hoặc cô lập bằng schema — đừng để hai test ghi chung một bảng.

3. Image version phải khớp production. Đừng test trên postgres:16 mà production chạy 14 — khác nhau thiệt đó, nhất là mấy chuyện liên quan tới query plan và index.

4. CI cần Docker. GitHub Actions có sẵn, GitLab runner cần đảm bảo dind (docker in docker) hoặc runner chạy docker socket. Kiểm tra cái này trước, không thì CI đỏ mà không hiểu vì sao.

Khi nào thì không cần?

Testcontainers không phải viên đạn bạc. Nếu project bạn chỉ đơn giản CRUD, ít logic, thì mock database + unit test có thể đủ. Còn nếu bạn làm chuyện tiền bạc, đặt chỗ, inventory, bất cứ thứ gì mà sai là mất tiền — đầu tư vô integration test với Testcontainers là xứng đáng từng đồng.

Code và công cụ lập trình trên bàn làm việc Ảnh: Markus Spiske — Pexels

Mình cũng từng viết về observability với OTel hồi tuần trước — test mà không đo được thì cũng như không. Giờ có thêm lớp test chạy trên thứ gần giống production, backend của mình mới thật sự yên tâm khi deploy.

Nói chung: nếu bạn còn đang mock database và thỉnh thoảng thấy bug "chỉ xuất hiện ở production" — thử Testcontainers một lần đi. Rồi tự nhiên thấy đêm ngủ ngon hơn hẳn.

📋 Phụ lục thuật ngữ

  • Integration test — kiểm thử nhiều thành phần chạy cùng nhau (app + database + queue), khác unit test chỉ test một hàm đơn lẻ
  • Testcontainers — thư viện khởi động container Docker thật trong vòng đời test, tự dọn sau khi test xong
  • Mock — đối tượng giả lập hành vi của dependency thật, nhanh nhưng không mô phỏng được toàn bộ hành vi
  • Singleton container — một container dùng chung cho cả test suite thay vì tạo mới mỗi test, giúp test nhanh hơn nhiều
  • dind (docker-in-docker) — cách chạy Docker bên trong container, thường cần cho CI runner