Testcontainers — Test với database thật, bỏ mock
Hồi làm service đơn hàng, mình khá tự tin vì bộ test pass hết, coverage 85%, CI xanh đều. Rồi deploy lên staging, câu query đầu tiên chạy đã nổ: column "created_at" does not exist. Migration trên máy mình thiếu một cột, nhưng mock database trả về dữ liệu mình tự bịa nên test vẫn cười tươi.
Từ đó mình bỏ thói quen mock tầng database.
Mock database cho vui thì được, tin thì không
Vấn đề của mock không nằm ở chỗ nó dở, mà ở chỗ nó trả lời đúng cái mình nghĩ database sẽ trả lời. Còn database thật thì:
- Kiểu dữ liệu khác:
TIMESTAMPTZso vớiTIMESTAMP,NUMERICso với float - Ràng buộc khác: unique, foreign key,
NOT NULL— mock hiếm khi mô phỏng đủ - Hành vi giao dịch khác: rollback, deadlock, mức cô lập
- Câu SQL mình viết có thể sai cú pháp mà test vẫn xanh
Nói ngắn: mock kiểm tra được logic, không kiểm tra được câu query.
Testcontainers làm gì
Testcontainers khởi động container Docker ngay trong lúc chạy test, và vòng đời container do test quản lý: test bắt đầu thì container lên, test kết thúc thì container bị dọn (nhờ một container nhỏ tên Ryuk làm nhiệm vụ dọn rác, kể cả khi test crash). Không cần ai đó dựng sẵn database trên CI, không sợ dev quên tắt máy làm rác container.
Code thật với Go
//go:build integration
func setupDB(t *testing.T) *sql.DB {
ctx := context.Background()
pg, err := postgres.Run(ctx, "postgres:16-alpine",
postgres.WithDatabase("shop"),
postgres.WithUsername("test"),
postgres.WithPassword("test"),
testcontainers.WithWaitStrategy(
wait.ForLog("database system is ready to accept connections").
WithOccurrence(2).WithStartupTimeout(30*time.Second)),
)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = pg.Terminate(ctx) })
dsn, err := pg.ConnectionString(ctx, "sslmode=disable")
if err != nil {
t.Fatal(err)
}
db, err := sql.Open("postgres", dsn)
if err != nil {
t.Fatal(err)
}
if err := migrate(db); err != nil { // chạy migration thật
t.Fatal(err)
}
return db
}
Điểm mấu chốt: migrate(db) chạy file migration thật của dự án. Migration sai thì test đỏ ngay trên máy dev, chứ không đợi tới staging.
Mấy cái bẫy mình từng dính
Ảnh không pin tag. Dùng postgres:latest thì hôm nay test xanh, tháng sau Docker Hub đổi bản, test đỏ mà chẳng ai sửa code. Pin postgres:16-alpine.
Khởi động lại container cho từng test. Mỗi lần lên container mất 3–8 giây, hai chục test là mất cả phút. Mình gom lại: dùng TestMain chạy container một lần cho cả package, mỗi test chỉ mở kết nối mới.
Dữ liệu rò rỉ giữa các test. Test A tạo user, test B đếm user thấy sai. Cách gọn nhất là bọc mỗi test trong một giao dịch rồi rollback cuối test; nặng hơn thì TRUNCATE trước mỗi test.
Tưởng test chậm là do container. Thực ra lần đầu chạy phải pull image. Sau khi image nằm trong cache thì thời gian chủ yếu nằm ở chỗ migration — đúng thứ mình cần đo.
Bật vào luôn bộ test mặc định. Đừng. Dev ngồi cà phê chờ Docker khởi động rồi bực, cuối cùng xoá hết test. Mình dùng build tag //go:build integration để go test ./... vẫn nhanh như thường, ai cần thì chạy go test -tags=integration ./....
Dùng ở đâu cho đáng
Không phải thay unit test. Testcontainers đắt hơn về thời gian nên mình chỉ nhắm vào chỗ mock không với tới: tầng repository, câu query phức tạp, migration, hành vi giao dịch, và mấy chỗ dính ràng buộc unique. Mấy hàm tính toán thuần thì unit test vẫn rẻ và nhanh hơn nhiều.
Sau khi đổi, bộ test chạy chậm hơn khoảng hai phút, nhưng đổi lại mình không còn phải mở staging lên ngồi đoán lỗi. Riêng chuyện đó đã đáng.
Bạn có đang mock database trong test không, hay đã chuyển sang container thật? Nếu đang phân vân thì thử một tầng repository trước — chỗ dễ lộ bug nhất.
📋 Phụ lục thuật ngữ
| Thuật ngữ | Giải thích ngắn |
|---|---|
| Testcontainers | Thư viện khởi động container Docker trong lúc chạy test, dùng cho database, message queue, cache... |
| Integration test | Test nhiều thành phần chạy chung với hạ tầng thật, thay vì test từng hàm riêng lẻ |
| Ryuk | Container phụ của Testcontainers chuyên dọn container rác, kể cả khi tiến trình test bị kill |
| Wait strategy | Điều kiện để biết container đã sẵn sàng nhận kết nối, ví dụ chờ log "ready to accept connections" |
| Build tag | Chú thích //go:build để loại một file test ra khỏi lần chạy mặc định |
| Truncate | Xoá sạch dữ liệu trong bảng, dùng để cô lập dữ liệu giữa các test |
| Migration | Bước thay đổi cấu trúc database, chạy theo phiên bản |