Bỏ qua để đến nội dung
Knowledge Hub

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

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.

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.

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:

Test dùng sleep và test tiêm clock cùng kiểm tra TTL 1 giây
Test dùng sleep và test tiêm clock cùng kiểm tra TTL 1 giâyBước 1/4: 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.thứ tự thực thiexpiry = now+1snow = 1000Test dùng sleepđồng hồ + scheduler thậtMáy CItải thay đổi mỗi lầnTest tiêm clockthời gian do test đặt
Bước 1/4

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.

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…

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:

  1. Đ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.
  2. 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.
  3. 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.
  4. 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.
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

Chạy demo clock:

Terminal window
npx --prefix examples ts-node examples/src/flaky-tests/clock-demo.ts

Dò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 đủ.

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.
  1. Mọi nguồn thời gian, random, ID đi qua một dependency tiêm được.
  2. 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.
  3. Không có sleep cố định để chờ; chỉ poll có deadline hoặc callback.
  4. CI chạy thử xáo trộn thứ tự định kỳ và in seed khi fail.
  5. Theo dõi flake rate theo test; có dashboard hoặc báo cáo hàng tuần.
  6. Có suite quarantine với owner và hạn sửa cho từng test.
  7. Mỗi lần fail lưu log, seed, timezone, parallelism làm artifact.

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?