0

Flaky Tests Are Not Bad Luck: A Practical Playbook for Hunting Inexplicable Failures

Trong các team mình từng làm, câu mình nghe nhiều nhất có lẽ là: "CI đỏ à? Re-run đi, chắc flaky thôi." Lần đầu nghe thì thấy hợp lý. Đến lần thứ 50 thì cả team đã quen với việc bấm nút Re-run mà không ai hỏi tại sao nữa. Gần đây trên Hacker News có bài The Normalization of Inexplicable Failures nói đúng hiện tượng này: khi lỗi không giải thích được xảy ra đủ nhiều lần, ta bắt đầu coi nó là chuyện bình thường. Ngành hàng không vũ trụ gọi đó là normalization of deviance. Tàu Challenger nổ cũng có một phần vì lý do này. Code của chúng ta không giết ai, nhưng một flaky test bị bỏ qua hôm nay rất có thể là race condition làm hỏng dữ liệu production ngày mai. Bài này mình chia sẻ quy trình thực tế để săn và xử lý những lỗi kiểu này.

Vì sao "re-run cho xanh" là một món nợ nguy hiểm

Flaky test gây ra ba vấn đề, và cái nào cũng tệ hơn mình tưởng lúc đầu:

  1. Mất niềm tin vào CI. Khi dev đã quen với việc CI đỏ vô cớ, họ sẽ re-run cả khi CI đỏ vì bug thật. Test suite lúc đó chỉ còn là thủ tục.
    1. Che giấu bug thật. Theo kinh nghiệm của mình, khoảng 1/3 số flaky test mình từng điều tra đến cùng là bug thật trong production code: race condition, thiếu await, connection pool bị cạn. Không phải test viết sai.
    1. Tốn tiền và thời gian. Một pipeline 15 phút bị re-run 3 lần mỗi ngày, nhân với 10 dev, là mất cả ngày công mỗi tuần.

Nguyên tắc mình áp dụng: không có lỗi nào là ngẫu nhiên, chỉ có lỗi mà ta chưa tìm ra điều kiện tái hiện. Máy tính là deterministic. Cái "ngẫu nhiên" chỉ là những biến số mà ta chưa kiểm soát: thời gian, thứ tự chạy, tài nguyên, mạng.

Bước 1: Biến lỗi ngẫu nhiên thành lỗi tái hiện được

Việc đầu tiên là ép lỗi xuất hiện thường xuyên hơn. Cách hiệu quả nhất là chạy test hàng trăm lần, xáo trộn thứ tự và giới hạn tài nguyên để giống môi trường CI:

# Python: pytest 8.x + pytest-repeat + pytest-randomly
pip install pytest-repeat==0.9.3 pytest-randomly==3.15.0

# Chạy 1 test 200 lần, dừng ngay khi fail
pytest tests/test_checkout.py -k test_apply_coupon --count=200 -x

# pytest-randomly tự xáo thứ tự và in seed ở đầu output.
# Khi fail, dùng lại đúng seed đó để tái hiện
pytest --randomly-seed=1234567 tests/

# JS: Jest 29.2+ và Vitest 2.x đều hỗ trợ shuffle
npx jest --randomize --seed=42 src/cart
npx vitest run --sequence.shuffle --sequence.seed=42

# Go: chạy 500 lần kèm race detector
go test -run TestWorkerPool -count=500 -race ./internal/queue/

# Mô phỏng runner CI yếu: chỉ cho nửa CPU
docker run --rm --cpus=0.5 -v $PWD:/app -w /app node:22 npx vitest run

Mẹo nhỏ: nếu test chỉ fail trên CI mà không fail ở local, 80% khả năng là do tài nguyên (CPU chậm hơn nên timing khác) hoặc thứ tự chạy (CI chạy song song với pytest-xdist hay Jest workers). Lệnh docker --cpus=0.5 ở trên đã giúp mình tái hiện được rất nhiều lỗi "chỉ xảy ra trên CI".

Bước 2: Phân loại theo 5 thủ phạm quen thuộc

Sau khi tái hiện được, mình phân loại nguyên nhân. Gần như mọi flaky test mình gặp đều rơi vào một trong năm nhóm sau:

graph TD
    A[Flaky Test] --> B[Thời gian: now, timezone, timeout]
        A --> C[Thứ tự: shared state giữa các test]
            A --> D[Concurrency: race condition, thiếu await]
                A --> E[Tài nguyên ngoài: network, DB, port]
                    A --> F[Randomness: random, UUID, dict order]
                        D --> G[Thường là bug thật trong production]
                            E --> H[Dùng emulator hoặc container cố định]
                            ```
                            
                            Nhóm **thời gian** là phổ biến nhất và cũng dễ sửa nhất. Ví dụ điển hình:
                            
                            ```python
                            # test_token.py (Python 3.12, freezegun 1.5)
                            from datetime import datetime, timedelta, timezone
                            from freezegun import freeze_time
                            
                            def is_expired(created_at: datetime, now: datetime | None = None) -> bool:
                                now = now or datetime.now(timezone.utc)
                                    return now - created_at > timedelta(hours=24)
                                    
                                    # FLAKY: chỉ còn 1 giây dư. Nếu runner bị GC pause hoặc CI chậm
                                    # hơn 1 giây giữa hai lần gọi now() thì test fail
                                    def test_token_not_expired_flaky():
                                        created = datetime.now(timezone.utc) - timedelta(hours=23, minutes=59, seconds=59)
                                            assert not is_expired(created)
                                            
                                            # ỔN ĐỊNH: inject clock, test hoàn toàn deterministic
                                            def test_token_not_expired():
                                                now = datetime(2026, 9, 28, 0, 0, tzinfo=timezone.utc)
                                                    created = now - timedelta(hours=23, minutes=59, seconds=59)
                                                        assert not is_expired(created, now=now)
                                                        
                                                        # Khi không sửa được signature (code legacy), đóng băng thời gian
                                                        @freeze_time('2026-09-28 00:00:00')
                                                        def test_token_expired_frozen():
                                                            created = datetime(2026, 9, 26, tzinfo=timezone.utc)
                                                                assert is_expired(created)
                                                                ```
                                                                
                                                                Bài học: **mọi hàm gọi `now()`, `random()` hay `uuid4()` bên trong đều là một nguồn nondeterminism ẩn.** Truyền chúng vào như dependency và test sẽ dễ hơn hẳn. Code production cũng sạch hơn.
                                                                
                                                                Với nhóm **concurrency**, đừng vội thêm `sleep(2)` vào test. Đó là cách che bug, không phải sửa bug. Nếu test cần sleep mới pass thì production code gần như chắc chắn đang có race condition. Hãy dùng `-race` trong Go, hoặc bật rule ESLint `@typescript-eslint/no-floating-promises` để bắt các promise bị quên `await` trong JS/TS.
                                                                
                                                                ## Bước 3: Đo lường và cách ly thay vì phớt lờ
                                                                
                                                                Không phải flaky test nào cũng sửa được ngay. Nhưng thay vì để nó làm đỏ CI một cách ngẫu nhiên, hãy **đo** và **cách ly** nó một cách có kiểm soát. Hầu hết test runner đều xuất được JUnit XML (`pytest --junitxml`, `jest-junit`, `vitest --reporter=junit`). Mình lưu report của mỗi lần CI chạy vào artifact, rồi dùng script nhỏ này để tính flake rate:
                                                                
                                                                ```js
                                                                // flake-report.mjs (Node 22, fast-xml-parser 4.x)
                                                                // Chạy: node flake-report.mjs reports/*.xml
                                                                import { readFileSync } from 'node:fs';
                                                                import { XMLParser } from 'fast-xml-parser';
                                                                
                                                                const parser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: '' });
                                                                const stats = new Map();
                                                                
                                                                for (const file of process.argv.slice(2)) {
                                                                  const xml = parser.parse(readFileSync(file, 'utf8'));
                                                                    const suites = [].concat(xml.testsuites?.testsuite ?? xml.testsuite ?? []);
                                                                      for (const suite of suites) {
                                                                          for (const tc of [].concat(suite.testcase ?? [])) {
                                                                                if ('skipped' in tc) continue;
                                                                                      const id = `${tc.classname}::${tc.name}`;
                                                                                            const s = stats.get(id) ?? { pass: 0, fail: 0 };
                                                                                                  if ('failure' in tc || 'error' in tc) s.fail++;
                                                                                                        else s.pass++;
                                                                                                              stats.set(id, s);
                                                                                                                  }
                                                                                                                    }
                                                                                                                    }
                                                                                                                    
                                                                                                                    // Flaky = cùng một test, cùng commit, lúc pass lúc fail
                                                                                                                    const flaky = [...stats]
                                                                                                                      .filter(([, s]) => s.pass > 0 && s.fail > 0)
                                                                                                                        .map(([id, s]) => ({ id, runs: s.pass + s.fail, failRate: (s.fail / (s.pass + s.fail)).toFixed(2) }))
                                                                                                                          .sort((a, b) => b.failRate - a.failRate);
                                                                                                                          
                                                                                                                          console.table(flaky);
                                                                                                                          ```
                                                                                                                          
                                                                                                                          Quy trình xử lý mà team mình đang dùng:
                                                                                                                          
                                                                                                                          ```mermaid
                                                                                                                          flowchart LR
                                                                                                                              A[CI fail] --> B{Re-run cùng commit có pass?}
                                                                                                                                  B -- Không --> C[Bug thật: sửa ngay]
                                                                                                                                      B -- Có --> D[Gắn nhãn flaky + tạo ticket có owner]
                                                                                                                                          D --> E[Quarantine: chạy riêng, không block merge]
                                                                                                                                              E --> F{Sửa xong trong 2 tuần?}
                                                                                                                                                  F -- Có --> G[Đưa về suite chính]
                                                                                                                                                      F -- Không --> H[Xóa test hoặc viết lại]
                                                                                                                                                      ```
                                                                                                                                                      
                                                                                                                                                      Điểm quan trọng nhất là **quarantine phải có hạn chót và có owner**. Một thư mục `tests/quarantine/` mà không ai chịu trách nhiệm thì chỉ là một nghĩa địa. Với pytest, mình dùng marker `@pytest.mark.quarantine` rồi chạy `pytest -m 'not quarantine'` ở pipeline chính, còn `pytest -m quarantine` chạy ở một job riêng không block merge. Playwright có sẵn `--repeat-each=20` và annotation `test.fixme()` cho mục đích tương tự.
                                                                                                                                                      
                                                                                                                                                      ## Kết luận
                                                                                                                                                      
                                                                                                                                                      Lỗi không giải thích được không đáng sợ. Cái đáng sợ là khi cả team đã quen với nó. Một vài việc bạn có thể làm ngay tuần này:
                                                                                                                                                      
                                                                                                                                                      - **Cấm câu "chắc flaky thôi" khi chưa điều tra.** Mỗi lần re-run phải đi kèm một ticket.
                                                                                                                                                      - **Cài `pytest-randomly` hoặc bật `--randomize` (Jest) / `--sequence.shuffle` (Vitest)** ở CI để lộ ra các test phụ thuộc thứ tự từ sớm.
                                                                                                                                                      - **Inject clock và random seed** thay vì gọi `now()` hay `random()` trực tiếp trong business logic.
                                                                                                                                                      - **Không bao giờ sửa flaky test bằng `sleep()`.** Hãy tìm ra điều kiện mà code thực sự đang chờ.
                                                                                                                                                      - **Lưu JUnit XML từ mọi lần CI chạy** và theo dõi flake rate hằng tuần. Không đo được thì không cải thiện được.
                                                                                                                                                      - **Quarantine có deadline và có owner.** Quá hạn thì xóa hoặc viết lại, đừng để test nằm đó mãi.
                                                                                                                                                      
                                                                                                                                                      Lần tới CI đỏ mà không rõ lý do, trước khi bấm Re-run, hãy tự hỏi: *"Nếu lỗi này xảy ra trên production thì mình có biết tại sao không?"* Nếu câu trả lời là không, thì đó chính là việc cần làm tiếp theo.

All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí