Flaky test: cùng một commit, lúc xanh lúc đỏ Cơ bản
Thời gian đọc ước tính: 8 phút
Vấn đề
Phần tiêu đề “Vấn đề”CI đỏ ở test “token hết hạn sau một giây”. Chạy lại thì xanh, code không đổi. Đội thêm retry ba lần cho pipeline bớt đỏ. Một tháng sau test đỏ đúng lúc có bug thật ở xử lý expiry, nhưng mọi người bỏ qua vì “test này hay flaky”. Thiệt hại lớn nhất không phải thời gian chạy lại, mà là tín hiệu CI mất giá trị.
Flaky test là test lúc pass lúc fail mà không có thay đổi có chủ ý ở code, input hay kỳ vọng. Chạy lại thấy xanh không chứng minh code đúng; nó chỉ cho thấy điều kiện gây lỗi chưa lặp lại.
Phân loại nguyên nhân
Phần tiêu đề “Phân loại nguyên nhân”Martin Fowler nêu năm nhóm gốc: thiếu cô lập, hành vi bất đồng bộ, dịch vụ ngoài, thời gian, rò rỉ tài nguyên. Dưới đây là bảng thực dụng hơn:
| Nguyên nhân | Dấu hiệu / cách phát hiện | Cách sửa |
|---|---|---|
Đồng hồ thật (Date.now, timezone) |
Fail gần ranh giới giây/ngày; chỉ fail ở CI khác timezone | Tiêm Clock hoặc dùng fake timers; đo khoảng thời gian bằng đồng hồ đơn điệu (performance.now()), không dùng Date.now() |
| Phụ thuộc thứ tự | Pass khi chạy riêng, fail khi chạy cả suite | Xáo trộn thứ tự để lộ ra (ví dụ Jest --randomize --seed, xáo thứ tự test trong từng file và in seed); mỗi test tự dựng dữ liệu |
| State dùng chung (ID cố định, DB chung, biến global) | Chỉ fail khi chạy song song hoặc đổi parallelism | Fixture riêng theo test, schema/DB cô lập, ID sinh ngẫu nhiên |
Chờ bất đồng bộ bằng sleep |
Fail khi CI tải cao | Poll điều kiện cụ thể có deadline, hoặc callback/event nếu có |
| Mạng, dịch vụ ngoài | Fail theo giờ, theo vùng; lỗi kết nối/timeout | Stub/sandbox ở test thường xuyên, giữ ít contract test có kiểm soát |
| Rò rỉ tài nguyên (port, file, connection, timer) | Test sau fail vì “address in use”, hết connection | Đóng trong finally/hook teardown, port 0, kiểm tra handle còn mở |
Thứ tự không đảm bảo (query thiếu ORDER BY, iteration Map/Set) |
Kết quả đúng nhưng sai thứ tự, ngẫu nhiên | Sort tường minh trong code hoặc trong assertion |
Không đoán: thu bằng chứng (commit, seed, version runtime, timezone, parallelism, log) rồi mới chọn hàng của bảng. Tăng timeout từ 1 lên 10 giây khi nguyên nhân là state dùng chung chỉ làm CI chậm hơn.
Sleep và clock tiêm vào
Phần tiêu đề “Sleep và clock tiêm vào”Test TTL ví dụ: tạo token hết hạn sau 1000 ms rồi kiểm tra hết hạn. Hai cách viết có độ tin cậy rất khác nhau:
Cả hai test cùng kiểm tra TTL 1 giây. Test trên dùng đồng hồ thật; test dưới đặt now = 1000 do chính nó điều khiển.
Cách thứ hai là dependency injection ở mức tối giản: logic nhận một Clock có now(). Production truyền Date.now(); test truyền clock giả và tự đặt giá trị. Ba điểm cần kiểm tra là trước hạn, đúng hạn, sau hạn, không cần ngủ giây nào.
type Clock = { now(): number };const expiresAt = (c: Clock, ttlMs: number) => c.now() + ttlMs;const isExpired = (c: Clock, expiry: number) => c.now() >= expiry;Node test runner cũng có sẵn mock.timers: bật mock.timers.enable({ apis: ['Date'], now: 100 }) rồi mock.timers.tick(200) để tiến thời gian. Lưu ý API này không hỗ trợ import dạng destructuring như import { setTimeout } from 'node:timers', và khi mock cả Date lẫn setTimeout thì chúng dùng chung một đồng hồ giả.
Fake clock chỉ kiểm tra logic expiry. Việc Redis TTL hay setTimeout thật chạy đúng vẫn cần một integration test riêng, với dung sai có lý do chứ không assert sát mili-giây.
Flaky test hay bug product?
Phần tiêu đề “Flaky test hay bug product?”Không phải lỗi chập chờn nào cũng do test. Race trong product, query thiếu ORDER BY, timeout quá thấp hay rò rỉ tài nguyên đều làm test dao động đúng vì sản phẩm dao động.
Đang tải diagram…
Quarantine và chính sách flake
Phần tiêu đề “Quarantine và chính sách flake”Fowler khuyên đưa test không ổn định vào khu cách ly và sửa nhanh, để chúng không làm mất tín hiệu của pipeline chính. Một chính sách tối thiểu:
- Đo flake rate: tỷ lệ lần chạy cùng một commit mà kết quả đổi (đỏ rồi xanh khi rerun) trên tổng số lần chạy, theo từng test.
- Quarantine có điều kiện: vượt ngưỡng đội đặt ra thì chuyển sang suite riêng, vẫn chạy nhưng không chặn merge.
- Mỗi test quarantine có owner và hạn sửa (ví dụ vài ngày làm việc); quá hạn thì xoá hoặc viết lại, không để quá hạn vô thời hạn.
- Không retry mù trong pipeline chính. Nếu buộc phải retry, ghi lại mỗi lần retry như một sự kiện flake để vẫn đo được.
Khi nào dùng / không dùng
Phần tiêu đề “Khi nào dùng / không dùng”| Kỹ thuật | Dùng khi | Không dùng khi |
|---|---|---|
| Fake clock / fake timers | Logic phụ thuộc thời gian: TTL, expiry, backoff, scheduler | Cần chứng minh timer/TTL của hệ thống ngoài (Redis) chạy đúng |
| Poll có deadline | Contract thực sự là eventual (email, queue, projection) | Contract hứa phản hồi đồng bộ: hãy sửa product |
| Quarantine | Test gây nhiễu pipeline và cần thời gian điều tra | Dùng như chỗ chôn test vĩnh viễn |
| Retry test | Tạm thời, có đo, có hạn | Để giấu race thật |
Ví dụ thực tế
Phần tiêu đề “Ví dụ thực tế”Chạy demo clock:
npx --prefix examples ts-node examples/src/flaky-tests/clock-demo.tsDòng real clock exact timestamp varies đổi mỗi lần vì dùng Date.now(). Hai dòng còn lại ổn định: before boundary: false và at boundary: true. Đừng copy timestamp từ output vào expected value, vì đó chính là cách biến test thành phụ thuộc thời điểm hiện tại.
Để kiểm chứng một test đã sửa, chạy lặp nhiều lần, cả riêng lẻ lẫn toàn suite song song, và theo dõi flake rate trước/sau. Một lần xanh không đủ.
Trade-offs
Phần tiêu đề “Trade-offs”Kiểm soát dependency
- Fake clock và dữ liệu riêng làm test nhanh, lặp lại được, rõ ranh giới.
- Log/seed khi fail giúp điều tra thay vì chỉ rerun.
- CI đáng tin hơn: mỗi lần đỏ là một tín hiệu hành động được.
Điều cần lưu ý
- Mock quá nhiều có thể bỏ sót lỗi tích hợp thật.
- Fixture cô lập và môi trường integration tăng chi phí vận hành.
- Poll có deadline vẫn nhạy với môi trường nếu deadline không dựa trên contract.
Sai lầm thường gặp
Phần tiêu đề “Sai lầm thường gặp”Checklist production
Phần tiêu đề “Checklist production”- Mọi nguồn thời gian, random, ID đi qua một dependency tiêm được.
- Mỗi test tự tạo và dọn dữ liệu; không dùng ID cố định hay DB chung giữa các test.
- Không có
sleepcố định để chờ; chỉ poll có deadline hoặc callback. - CI chạy thử xáo trộn thứ tự định kỳ và in seed khi fail.
- Theo dõi flake rate theo test; có dashboard hoặc báo cáo hàng tuần.
- Có suite quarantine với owner và hạn sửa cho từng test.
- Mỗi lần fail lưu log, seed, timezone, parallelism làm artifact.
Tóm tắt
Phần tiêu đề “Tóm tắt”Tự kiểm tra: (1) Vì sao sleep(1000) không phải cách tốt để test TTL một giây? (2) Test chỉ fail khi chạy song song gợi ý nguồn nào? (3) Khi nào polling đúng contract và khi nào nó che bug? (4) Vì sao dùng performance.now() thay Date.now() để đo duration?