Flaky Test trong CI/CD: quarantine, retry budget và cách lấy lại niềm tin vào test suite

Không ít team nghĩ flaky test chỉ là một phiền toái nhỏ: build đỏ thì bấm re-run, pass thì merge tiếp. Nhưng đó chính là lúc test suite bắt đầu mất giá trị. Khi developer không còn tin một lần fail là tín hiệu thật, CI không còn là hàng rào chất lượng nữa mà trở thành tiếng ồn vận hành.

Ở mức độ nghiêm trọng hơn, flaky test làm hỏng cả tốc độ delivery lẫn văn hóa kỹ thuật:

  • pull request bị giữ lại vì failure không tái hiện ổn định;
  • team thêm retry khắp nơi để “cho xanh pipeline” thay vì xử lý nguyên nhân;
  • dashboard pass rate nhìn vẫn đẹp nhưng signal ngày càng bẩn;
  • người mới vào team học được thói quen nguy hiểm: build fail trước đã, hiểu sau.

Martin Fowler gọi đây là non-deterministic tests: test có lúc pass, có lúc fail mà không có thay đổi rõ ràng ở code, test hay environment. Một khi loại test này lan rộng, cả deployment pipeline bị bào mòn vì mọi failure đều bị nghi ngờ.

Bài này đi theo góc nhìn production engineering cho test suite: flaky test là gì dưới lăng kính hệ thống, vì sao retry bừa thường làm mọi thứ tệ hơn, khi nào nên quarantine, cách đặt retry budget, cách phân nhóm nguyên nhân, instrument dữ liệu để truy ra nguồn gốc, và cách phục hồi niềm tin vào CI mà không phải “đập đi viết lại”.

Flaky test không chỉ là bug của test, mà là bug của tín hiệu

Một test fail ổn định là tín hiệu tốt. Nó khiến team dừng đúng chỗ và sửa đúng thứ. Flaky test phá hỏng điều đó vì cùng một input gần như giống nhau nhưng output không ổn định.

Hậu quả thực tế không nằm ở chỗ test thi thoảng đỏ. Hậu quả nằm ở việc team không còn biết:

  • failure nào đáng chặn merge;
  • failure nào chỉ là noise;
  • có regression thật hay chỉ là timing issue;
  • chi phí chờ pipeline có còn xứng đáng với giá trị mà nó tạo ra.

Về bản chất, flaky test biến CI từ quality gate thành probabilistic gate. Merge hay không merge không còn phụ thuộc hoàn toàn vào chất lượng code, mà phụ thuộc một phần vào việc hạ tầng, timing hoặc state có “đẹp trời” hay không.

Đó là lý do nên xem flaky test như một bài toán reliability của hệ thống kiểm thử, không phải chuyện khó chịu cục bộ của vài case automation.

Dấu hiệu team đang normalize flakiness mà không nhận ra

Một số tín hiệu rất quen thuộc:

  • build fail, người review comment ngay: “rerun thử đi”;
  • một test chỉ fail ở CI nhưng local rất khó tái hiện;
  • suite có retry mặc định cao cho toàn bộ test;
  • cùng một test fail ở step khác nhau qua mỗi lần chạy;
  • report không tách bạch first-run pass, retry pass, final fail;
  • team có danh sách test “đỏ quen rồi”, nhưng không ai sở hữu fix;
  • thời gian pipeline tăng dần vì test bị run lại nhiều vòng.

Nếu những pattern này tồn tại hơn vài tuần, vấn đề không còn ở một test riêng lẻ nữa. Vấn đề là team đã chấp nhận flaky test debt như một phần bình thường của delivery.

Vì sao “thêm retry là xong” thường là quyết định đắt đỏ

Retry không phải lúc nào cũng sai. Với dependency thực sự bất ổn trong môi trường test, retry có thể giúp giảm false negative tạm thời. Nhưng retry bừa trên toàn suite thường gây ba tác hại lớn.

1. Che mất tín hiệu nguyên nhân gốc

Một test fail lần đầu rồi pass lần hai không có nghĩa là hệ thống ổn. Nó chỉ có nghĩa là lỗi không tái hiện trong cửa sổ thứ hai. Nếu CI chỉ report trạng thái cuối là pass, team sẽ mất dấu failure ban đầu.

2. Kéo dài pipeline và tăng cost vô hình

Retry nhân thời gian chạy, chi phí compute, thời gian chờ merge và contention lên môi trường test. Với suite lớn, mỗi 1–2% flaky rate cũng đủ tạo thêm hàng giờ chờ mỗi ngày ở quy mô cả team.

3. Dạy team hành vi sai

Khi văn hóa mặc định là “đỏ thì rerun”, developer sẽ tối ưu để vượt cổng thay vì làm sạch cổng. Dần dần, ai cũng biết pipeline không đáng tin nhưng vẫn phải đi qua nó.

Retry nên là công cụ quan sát có kiểm soát, không phải thuốc giảm đau dùng vô hạn.

Retry budget: khái niệm đơn giản nhưng cực hữu ích

Nếu production có error budget, test suite cũng nên có tư duy tương tự: cho phép một mức retry hữu hạn, có đo lường, có cảnh báo, và coi phần retry tiêu thụ là nợ reliability.

Retry budget có thể hiểu là ngân sách chấp nhận được cho số lần retry trong một giai đoạn, ví dụ:

  • không quá 2% test run trong ngày cần retry;
  • không quá 0.5% suite-level rerun trên nhánh main;
  • không test nào được retry quá 1 lần ở lane blocking;
  • nếu một test consume vượt budget 3 ngày liên tiếp thì tự động chuyển sang quarantine queue.

Điểm hay của retry budget là nó đổi câu hỏi từ:

  • “có nên cho retry không?”

thành:

  • “ta sẵn sàng trả bao nhiêu chi phí reliability cho retry, và khi nào phải dừng?”

Điều này ép team nhìn retry như một trade-off có số liệu, thay vì default vô thức.

Quarantine không phải đầu hàng, mà là cô lập nhiễm độc để bảo vệ pipeline

Martin Fowler đề xuất quarantine như một bước đầu để giảm tác hại của test không ổn định. Ý tưởng rất thực dụng:

  • test flaky không nên tiếp tục phá lane chính;
  • nhưng cũng không được “xóa khỏi radar”;
  • nó cần bị cô lập, gắn owner, và có deadline sửa.

Quarantine nên có những thuộc tính gì

Một cơ chế quarantine tốt thường gồm:

  • tag hoặc metadata rõ ràng (@quarantined, flaky=true);
  • tách khỏi lane blocking merge;
  • vẫn chạy ở lane quan sát riêng để thu thập dữ liệu;
  • hiển thị owner/team chịu trách nhiệm;
  • có lý do quarantine và ngày hết hạn;
  • có tiêu chí để quay lại lane chính.

Khi nào nên quarantine

  • test fail không ổn định nhưng ảnh hưởng nhiều PR;
  • đã xác nhận false negative đáng kể;
  • chưa fix kịp ngay nhưng không muốn làm hỏng toàn pipeline;
  • cần gom evidence thêm để root-cause.

Khi nào không nên quarantine

  • test đang phát hiện bug thật nhưng team không muốn sửa;
  • test fail ổn định do logic product đổi;
  • flaky rate còn chưa được đo mà đã tag hàng loạt để “pipeline xanh”.

Quarantine là biện pháp containment, không phải nghĩa trang cho test khó chịu.

Phân loại nguyên nhân flaky test: đừng gộp tất cả vào “CI không ổn định”

Rất nhiều team nói “CI flake” như một nhãn mơ hồ. Trên thực tế, flaky test thường rơi vào vài nhóm nguyên nhân khá lặp lại.

1. Thiếu isolation giữa các test

Đây là nhóm kinh điển nhất.

Biểu hiện:

  • test pass khi chạy riêng nhưng fail khi chạy cả suite;
  • thứ tự chạy ảnh hưởng kết quả;
  • shared DB row, shared user, shared queue, shared browser state;
  • test trước để lại data khiến test sau đọc nhầm state.

Playwright nhấn mạnh rằng mỗi test nên độc lập với storage, cookies, session và data của test khác. Nếu suite dựa vào state được “để lại sẵn”, failure ngẫu nhiên gần như chắc chắn sẽ xuất hiện khi chạy song song hoặc khi môi trường thay đổi.

Cách xử lý:

  • cấp test data riêng cho từng test hoặc từng worker;
  • reset state có kiểm soát;
  • tránh phụ thuộc vào thứ tự chạy;
  • không tái sử dụng account/global fixture theo cách gây rò state;
  • cô lập external side effect bằng mock hoặc sandbox resource riêng.

2. Asynchronous behavior và timing assumption sai

Đây là nguyên nhân đặc biệt phổ biến ở UI/E2E test.

Biểu hiện:

  • test dùng sleep, waitForTimeout, hoặc polling tùy tiện;
  • assert chạy trước khi UI render xong;
  • network response chưa hoàn tất nhưng test đã click tiếp;
  • animation, debounce, background job hoặc eventual consistency chưa settle.

Playwright khuyến khích dùng locator và assertion có auto-wait thay vì sleep cứng, vì sleep là một giả định timing mỏng manh: đủ ở máy local nhưng thiếu ở CI khi CPU chậm hơn hoặc network khác đi.

Cách xử lý:

  • chờ theo điều kiện nghiệp vụ, không chờ theo thời gian đoán mò;
  • assert user-visible state thay vì DOM implementation detail;
  • nếu có background job/eventual consistency, expose tín hiệu readiness phù hợp cho test;
  • trace lại xem failure xảy ra trước hay sau state transition nào.

3. Remote dependency và môi trường ngoài tầm kiểm soát

Một số suite vẫn gọi thật vào third-party API, email sandbox, captcha, analytics script, feature flag vendor, hoặc môi trường staging có nhiều team dùng chung.

Biểu hiện:

  • test fail theo giờ cao điểm;
  • fail chỉ khi mạng chậm hoặc service ngoài rate limit;
  • dữ liệu staging bị team khác thay đổi;
  • cookie banner, script ngoài, CDN asset gây khác biệt hành vi.

Playwright cũng khuyên chỉ test thứ mình kiểm soát. Nếu mục tiêu là validate flow nội bộ, external dependency nên được mock hoặc ổn định hóa ở boundary phù hợp.

Cách xử lý:

  • mock dependency ngoài cho lane regression chính;
  • tách lane contract/integration riêng nếu cần gọi thật;
  • dùng dữ liệu staging bất biến hơn;
  • kiểm soát seed data, browser version, OS image, timezone.

4. Time, clock, timezone và data hết hạn

Time-based flake rất độc vì nhìn bề ngoài như lỗi random.

Biểu hiện:

  • test chỉ fail cuối tháng, đầu ngày, hoặc lúc đổi múi giờ;
  • JWT/token hết hạn ngay trong lúc suite chạy;
  • code dùng Date.now() trực tiếp khiến assert phụ thuộc milli-second;
  • cron/background cleanup xóa dữ liệu giữa lúc test đang chờ.

Cách xử lý:

  • freeze clock hoặc inject time provider ở layer phù hợp;
  • tránh assert vào timestamp tuyệt đối nếu không cần;
  • tạo test data có expiry đủ xa;
  • ghi lại timezone/clock skew trong metadata của run.

5. Resource leak và contention ở hạ tầng CI

Một suite local xanh nhưng CI đỏ thất thường đôi khi không phải lỗi logic, mà là do tài nguyên.

Biểu hiện:

  • fail tăng mạnh khi chạy song song;
  • port collision, DB connection exhaustion, file descriptor leak;
  • browser crash khi worker nhiều;
  • test timeout ở branch có nhiều job concurrent.

Đây là nơi flaky test chạm trực tiếp vào production engineering: CI cũng là một distributed system thu nhỏ. Nếu worker tranh tài nguyên, pipeline sẽ sinh behavior không quyết định dù code không đổi.

Cách xử lý:

  • giới hạn parallelism theo loại suite;
  • đo CPU, memory, DB connection, queue depth trong lúc test chạy;
  • tìm test làm rò browser/page/process/socket;
  • tách suite nặng ra lane riêng thay vì ép chung một budget timeout.

Một mô hình xử lý flaky test thực dụng cho team đang bận

Không phải team nào cũng có thời gian làm chương trình quality lớn ngay lập tức. Một workflow đủ thực dụng có thể là:

Bước 1: phân biệt first-run fail với retry-pass

Report bắt buộc phải tách:

  • pass lần đầu;
  • fail lần đầu nhưng pass sau retry;
  • fail cuối cùng.

Nếu chỉ nhìn trạng thái cuối, team mù hoàn toàn trước flaky rate thật.

Bước 2: lập bảng xếp hạng test tệ nhất

Mỗi ngày hoặc mỗi tuần, tính top test theo:

  • số lần retry;
  • tỉ lệ fail-first-run;
  • tổng phút chờ do test đó gây ra;
  • số PR bị ảnh hưởng.

Đừng bắt đầu bằng việc “fix cả suite”. Hãy sửa 10 test tệ nhất trước.

Bước 3: đặt policy quarantine rõ ràng

Ví dụ:

  • test vượt 3% fail-first-run trong 7 ngày => đủ điều kiện quarantine;
  • quarantine tối đa 14 ngày trừ khi có approval đặc biệt;
  • mọi test quarantine phải có owner;
  • merge vào lane chính chỉ khi flake rate xuống dưới ngưỡng đã định.

Bước 4: dùng retry có phân tầng

  • lane blocking trên main: retry tối đa 1 lần;
  • lane nightly hoặc observational: có thể cho nhiều retry hơn để thu thập evidence;
  • smoke critical path: ưu tiên không retry hoặc retry rất hạn chế để giữ tín hiệu sắc.

Bước 5: bắt buộc artifact để debug

Khi retry xảy ra, phải giữ lại trace, screenshot, video, console log, network log, timing metadata hoặc test fixture snapshot. Nếu không, retry chỉ làm failure biến mất mà không để lại chứng cứ.

Với Playwright, nên dùng retry như một sensor chứ không phải tấm thảm che bụi

Playwright có một số thực hành rất hợp để giảm flaky test:

  • dùng locator phản ánh thứ user thực sự thấy;
  • tận dụng auto-wait và web-first assertions;
  • cô lập test bằng browser context/state riêng;
  • tránh waitForTimeout nếu có thể chờ theo condition;
  • bật trace ở lần retry đầu (on-first-retry) để giữ evidence.

Điểm quan trọng là: các built-in này giúp giảm flake nếu test design đúng. Chúng không cứu được suite đang dựa vào state rò rỉ, data staging bẩn, hay timing assumption sai từ gốc.

Flaky test observability: cần đo những gì

Một dashboard tử tế cho test reliability nên có ít nhất các tín hiệu sau:

  • first-run pass rate;
  • retry rate;
  • quarantine count;
  • top flaky tests theo 7 ngày và 30 ngày;
  • mean time to fix của test quarantine;
  • pipeline minutes lost vì retry/rerun;
  • flake rate theo suite, theo owner, theo branch, theo environment image;
  • correlation với thay đổi browser version, dependency version, base image, runner type.

Nếu muốn đi sâu hơn, có thể gắn taxonomy cho failure:

  • selector/timing;
  • data isolation;
  • network/dependency;
  • infra/resource;
  • clock/timezone;
  • unknown.

Chỉ cần phân loại 70–80% failure đã đủ giúp team biết nên đầu tư vào đâu trước.

Đừng sửa flaky test chỉ ở symptom layer

Một sai lầm phổ biến là sửa symptom:

  • tăng timeout;
  • thêm retry;
  • thêm sleep;
  • giảm parallelism toàn cục.

Những việc này đôi khi cần như biện pháp tạm thời, nhưng nếu dừng ở đó thì test suite sẽ ngày càng chậm và signal ngày càng mờ.

Nên luôn tự hỏi:

  • test đang đợi state nào, và state đó có được expose rõ chưa?
  • test có đang đọc shared state không?
  • dependency ngoài có thực sự cần trong lane này không?
  • môi trường CI có đang thiếu tài nguyên hay không?
  • failure có pattern theo giờ, worker, browser, dataset hay branch nào không?

Flakiness thường là triệu chứng của thiết kế test hoặc thiết kế hệ thống chưa rõ ràng. Sửa đúng thường kéo theo test nhanh hơn, ít brittle hơn, và đôi khi làm cả sản phẩm dễ quan sát hơn.

Một policy tối thiểu mà nhiều team có thể áp dụng ngay

Nếu team chưa có gì, có thể bắt đầu bằng policy nhỏ nhưng đủ sắc:

  1. Không merge chỉ dựa vào rerun xanh nếu test đó đã fail-first-run.
  2. Mọi retry-pass đều được log riêng.
  3. Test vượt ngưỡng flake phải có owner trong 24 giờ.
  4. Quarantine có TTL, hết hạn mà chưa sửa thì escalate.
  5. Không thêm sleep/retry vào test mà không ghi lý do trong PR.
  6. Mỗi tuần review top flaky tests như review incident nhỏ.
  7. Success metric không chỉ là pipeline xanh, mà là pipeline xanh với first-run pass rate cao.

Policy như vậy đủ để chuyển văn hóa từ “né fail” sang “làm sạch tín hiệu”.

Liên hệ giữa flaky test và engineering maturity

Một team xử lý flaky test tốt thường có vài đặc điểm chung:

  • xem CI là product nội bộ cần reliability;
  • có ownership cho test health;
  • chấp nhận quarantine như containment tạm thời, không biến nó thành bãi rác;
  • dùng dữ liệu để tranh luận, không dùng cảm giác kiểu “suite này flaky lắm chắc do CI thôi”;
  • hiểu rằng tốc độ delivery bền vững phụ thuộc vào độ tin cậy của feedback loop.

Nói ngắn gọn: flaky test không chỉ là vấn đề QA. Nó là bài toán feedback quality cho toàn bộ engineering organization.

Kết luận

Flaky test nguy hiểm không phải vì nó làm vài build đỏ. Nó nguy hiểm vì nó làm con người ngừng tin vào build đỏ. Một khi điều đó xảy ra, CI/CD vẫn còn chạy, nhưng cơ chế học hỏi và phòng lỗi của team đã suy yếu rất nhiều.

Cách xử lý đúng không phải là cấm retry tuyệt đối, cũng không phải retry vô hạn để pipeline đẹp mặt. Cách tốt hơn là:

  • đo first-run signal rõ ràng;
  • giới hạn retry bằng budget;
  • quarantine có owner và thời hạn;
  • debug dựa trên artifact;
  • sửa nguyên nhân gốc theo đúng nhóm vấn đề: isolation, async timing, dependency, time, resource.

Khi làm được vậy, team không chỉ giảm noise. Team lấy lại thứ quan trọng hơn: niềm tin rằng khi test suite cảnh báo, nó đang nói điều đáng nghe.