Flaky Tests — Khi test chạy được lúc thì không, chữa cách nào?
Bạn có bao giờ chạy test lần đầu thì fail, chạy lại thì pass, đẩy lên CI thì fail lần nữa, rồi re-run lại thì xanh? Đó chính là flaky test — con quái vật âm thầm nhất trong CI/CD. Đáng sợ nhất là nó không lỗi logic, mà là lỗi timing, trạng thái, và thứ tự.
Vì sao bài test "lật kèo"?
Flaky test thường đến từ 4 nhóm:
- Race condition — Go test chạy nhiều goroutine song song, ghi vào cùng một nơi. Máy nhanh thì pass, máy chậm thì fail.
- Phụ thuộc thời gian — sleep 50ms để chờ một việc xong. Máy nọ nhanh quá, máy kia chậm quá.
- Dùng dữ liệu chung — test dùng chung database/Redis/test user, chạy song song là đá chân nhau.
- Đến từ bên ngoài — gọi API thật, network chập chờn, timezone khác nhau, locale khác nhau.
Nguyên tắc vàng: test phải deterministic
Một bài test tốt, chạy 100 lần ở 100 máy, phải trả về cùng một kết quả. Nếu không, nó không đáng tin — và một CI không đáng tin sẽ khiến team bắt đầu bỏ qua test đỏ, rồi bug lọt ra production.
Cách chữa dứt điểm
1. Đừng dùng time.Sleep để chờ — hãy poll
func waitForData(t *testing.T, fn func() bool) {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
if fn() {
return
}
time.Sleep(10 * time.Millisecond) // mới có đây là chờ tối thiểu
}
t.Fatal("timeout: điều kiện không xảy ra")
}
Thay vì time.Sleep(100 * time.Millisecond) ước chừng, bạn đợi cho đến khi điều kiện đúng (hoặc timeout). Máy chậm đến đâu cũng pass, chỉ fail khi thật sự lỗi — đó mới là tín hiệu thật.
2. Cách ly database giữa các test
Nếu test dùng chung database, chạy song song là tự khai. Mỗi test nên dùng transaction tự rollback hoặc database riêng (Docker Testcontainers rất tiện):
func TestUserRepo(t *testing.T) {
ctx := context.Background()
tx, _ := testDB.BeginTx(ctx, nil)
defer tx.Rollback(ctx) // mọi thay đổi đều bị huỷ sau test
// ... viết test trên tx, cô lập hoàn toàn
}
3. Tách riêng test thật sự cần network
Gọi API thật trong unit test = mời flaky về nhà. Dùng interface + fake/stub cho tầng đơn vị, để test integration chạy riêng (và cho phép retry nếu lỗi network chứ không phải lỗi logic).
4. Xem CI log ở mức độ đủ dài
Ảnh fail có khi chỉ vì giờ địa phương lệch hay env var bị bỏ quên. Ghi rõ thời gian bắt đầu/kết thúc từng test, t.Parallel() có bật không, seed ngẫu nhiên (rand.Seed) có bị cố định không.
Kinh nghiệm thực tế
Trong một dự án Go của em, tụi em từng có 3 bài test fail hơn 30% số lần chạy. Nguyên nhân: tất cả test chạy chung một Redis localhost:6379, và dùng chung key counter:user. Hai test chạy song song là cộng dồn sai số. Sửa bằng cách: prefix key theo tên test + chạy go test -race trong CI. Sau một tuần, tỉ lệ fail về gần 0.
Mẹo nhỏ: chạy go test -count=20 để ép test chạy 20 lần liên tiếp lúc dev. Nếu nó fail chập chờn, bạn đã tái hiện được flaky test — bước đầu tiên của việc sửa nó là đừng sửa logic, hãy tìm tính không-deterministic.
Kết luận
Flaky test không phải bug "khó chịu" — nó là nợ kỹ thuật đang ăn mòn niềm tin của cả team. Đầu tư một chút vào deterministic tests: bỏ sleep, cô lập dữ liệu, tách mạng. Chữa xong, bạn sẽ thấy CI xanh bền, và đêm không còn phải canh re-run.