Mọi chuyện bắt đầu bằng một tin nhắn Slack. Bản build báo đỏ. Bạn lướt qua các lỗi, cau mày, và chạy lại chính bài test đó trên laptop của mình. Kết quả: Xanh. Bạn thử lại job CI. Có lẽ chỉ là một sự cố nhất thời. Nhưng lỗi đó lại xuất hiện, lì lợm và lặp đi lặp lại trên server nhưng lại vô hình đối với bạn.

Một bài test trình duyệt thất bại trong CI nhưng lại vượt qua ở máy cục bộ không chỉ đơn thuần là một sự phiền toái. Nó tạo ra sự mất lòng tin. Các nhóm bắt đầu đổ lỗi cho vấn đề thời gian (timing). Họ đẩy lên những bản sửa lỗi tạm thời mà không bao giờ gỡ bỏ. Một setTimeout ở đây, một .wait(5000) ở kia. Bộ test chậm dần. Các lỗi cứ liên tục quay trở lại. Những bài test chập chờn (flaky tests) đó dần trở thành những thành phần cố định, và cuối cùng, mọi người bắt đầu coi một pipeline báo đỏ như một tiếng ồn nền không đáng kể.

Điều đó thật nguy hiểm. Bạn không muốn một bộ test luôn báo động giả.

CI Không Bị Hỏng; Nó Chỉ Khác Biệt

Môi trường CI không hề ngẫu nhiên. Chúng mang tính xác định (deterministic). Vấn đề là chúng mang tính xác định đối với một hệ thống không phải là MacBook hay máy trạm Linux của bạn. Thiết lập cục bộ của bạn che giấu những khác biệt mà một CI runner sạch sẽ sẽ lập tức phơi bày.

Hãy nghĩ về việc có bao nhiêu thành phần đang hoạt động khác biệt. Máy cục bộ của bạn có thể chạy một server phát triển với tính năng hot module reloading, trong khi CI xây dựng một production artifact với tree shaking và minification. Chỉ riêng điều đó thôi đã có thể loại bỏ các đường dẫn mã (code paths) hoặc thay đổi thứ tự thực thi. Các cây phụ thuộc (dependency trees) thay đổi. Một lockfile trông có vẻ giống hệt nhau nhưng có thể được giải quyết khác nhau nếu phiên bản trình quản lý gói (package manager) chỉ khác biệt một bản phát hành nhỏ. Các chuỗi mạng thay đổi. Wi-Fi văn phòng của bạn có thể kết nối tới một staging API chỉ trong một bước; trong khi CI runner có thể chạm tới một cluster khác đằng sau một bộ cân bằng tải (load balancer), gây ra độ trễ mà bạn không bao giờ thấy.

Bản thân các trình duyệt cũng hoạt động khác nhau giữa các môi trường. Chrome cục bộ của bạn mang theo các tiện ích mở rộng (extensions), thông tin đăng nhập đã lưu (cached credentials), bộ nhớ cục bộ (local storage) và một GPU có tăng tốc phần cứng. CI bắt đầu từ một profile trống trong mỗi lần chạy. Vòng đời trình duyệt khác nhau. Các đường dẫn render khác nhau. Các font chữ tồn tại trên hệ thống của bạn sẽ bị thay thế trong CI. Kích thước viewport và tỷ lệ điểm ảnh thiết bị (device pixel ratios) thay đổi, điều này có thể làm đảo lộn các điểm ngắt phản hồi (responsive breakpoints) hoặc thay đổi hành vi lazy-loading.

Những khoảng cách này là có thật. Chúng mang tính cơ học. Giả vờ rằng chúng là ngẫu nhiên không giúp chúng biến mất.

Môi trường Preview Đang Lừa Dối Bạn

Môi trường preview làm trầm trọng thêm vấn đề. Chúng hữu ích cho việc xem xét bởi con người, nhưng chúng không phải là production. Chúng thường trỏ đến api-staging thay vì host API thực tế. Các feature flag luôn trả về true cho mọi thử nghiệm, che giấu logic điều kiện mà production thực thi. Xác thực (authentication) có thể bỏ qua một bước hoặc chèn một token giả (mock token). Cookies có thể sử dụng các chính sách nới lỏng hơn. Tập dữ liệu có thể chỉ là một phần nhỏ, mười dòng thay vì mười nghìn dòng, nghĩa là logic phân trang (pagination), xếp hạng tìm kiếm hoặc ảo hóa (virtualization) không bao giờ được thực thi.

Nếu bài test của bạn vượt qua trên một URL preview nhưng thất bại trong production, hoặc ngược lại, thì lỗi không nằm ở bài test. Lỗi nằm ở môi trường.

Ghi Log Trước Khi Đoán

Khi một lỗi xuất hiện lần đầu, hãy cưỡng lại bản năng chỉnh sửa bài test rồi hy vọng nó sẽ chạy. Đừng đoán mò. Bạn cần đóng băng ngữ cảnh để có thể so sánh một lần chạy thành công với một lần chạy thất bại.

Hãy ghi log những đối tượng nghi vấn rõ ràng. Ghi lại URL của trang tại thời điểm xảy ra lỗi, build ID và commit SHA. Lưu ý các feature flag đang hoạt động. Ghi lại host API, phiên bản trình duyệt chính xác và kích thước viewport. Những chi tiết này sẽ biến một lỗi bí ẩn thành một điều kiện có thể tái lập.

Đừng chỉ dựa vào ảnh chụp màn hình. Hai trang có thể trông giống hệt nhau về mặt pixel nhưng lại đang chạy các đoạn JavaScript hoàn toàn khác nhau. Ảnh chụp màn hình sẽ không cho bạn biết rằng bundle của CI có bao gồm một polyfill bổ sung hoặc bundle cục bộ đã bỏ qua một chunk vì nó đã có sẵn trong bộ nhớ đệm trình duyệt của bạn.

Cũng hãy nhớ rằng việc mở DevTools sẽ làm thay đổi thời gian (timing). DevTools có thể trì hoãn việc thu gom rác (garbage collection), thay đổi mức độ ưu tiên mạng và vô hiệu hóa một số tối ưu hóa render nhất định. Một bài test vượt qua khi bạn đang kiểm tra DOM có thể thất bại ngay khi bạn đóng bảng điều khiển và chạy nó ở chế độ headless. Trình gỡ lỗi (debugger) là một công cụ hữu ích, nhưng nó không phải là một quan sát viên trung lập.

Tái Hiện Hiện Trường Vụ Án

Nếu bạn muốn tái lập lỗi một cách chính xác, bạn không thể chỉ chạy server phát triển cục bộ và hy vọng điều tốt đẹp nhất sẽ đến. Bạn cần phải mô phỏng chính xác các điều kiện của the_CI's_.

Build the exact artifact that CI produced. Download it if you must. Serve that artifact locally with a simple static file server, not with Vite or Webpack dev middleware. Use the same environment variables that CI injected. Match the browser version precisely. Run it in the same mode, headed or headless, because focus events, media queries, and autoplay policies still diverge between the two in subtle ways. If your CI uses a Docker container, run the same image locally. Remove your personal browser profile entirely.

When the local reproduction finally fails, you have a real debugging session. Until then, you are chasing shadows.

Stop Sleeping, Start Waiting

The most common response to a flaky browser test is to add delay. Wait five seconds. Wait ten. This is not a fix. It is a surrender. Arbitrary delays slow your suite, create false confidence, and still fail under load when the network hiccups.

Instead, wait for evidence of state. If a notification should appear after a form submission, do not wait for time to pass. Wait for a specific notification ID to exist in the DOM. If a counter should increment, wait for the text to change values. If a loading state blocks interaction, wait for the loading marker to disappear. If you are working with a WebSocket or server-sent events, wait for the network stream to produce a specific event.

Explicit waits turn your test from a guessing game into a contract. The test says: "I will proceed once the application confirms it is ready." That is far stronger than saying, "I will proceed once enough seconds have passed."

Hydration and the Disappearing Button

In modern React applications, hydration causes a specific class of failures that local dev servers often mask. The server sends HTML. React boots up in the browser and attaches event listeners. During that window, your test might click a button. React then replaces or restructures that DOM node during hydration. The element handle your test framework was holding now points to a detached node, and you get an error about interacting with a removed element.

The fix is not to write a more complex selector that digs deeper into the component tree. The fix is to look for readiness signals. Wait until a root element gains a hydrated attribute or a known data property. Wait for a skeleton loader to disappear. Wait for a client-side event handler to become active. Let the application announce that it is stable before you fire clicks.

Hidden Culprits: Dependencies and Third-Party Scripts

Sometimes the environment changes even though your application code did not. A transitive update in a small utility library, three levels deep in your node_modules, can alter browser behavior. It might change how promises resolve, how styles get injected, or how mocks intercept requests. When tests begin failing after a routine dependency update, record your package manager version and the lockfile checksum. You need to know whether you are looking at the same tree you were last week.

Third-party scripts are another frequent saboteur. Analytics trackers, payment SDKs, and chat widgets load asynchronously. They inject iframes, shift layout, or steal focus at moments your test does not expect. In CI, these scripts might load more slowly, or they might fail to load entirely because of network restrictions, causing your application to follow a different error-handling path. Log which third-party resources loaded and their HTTP status. If a payment iframe takes three seconds to mount in CI but loads instantly on your fast local connection, your "element not clickable" error suddenly has a clear cause.

And "element not clickable" is never a diagnosis. It is a symptom. Treat the cause.

Build an Evidence Kit

Every CI failure should be actionable. A stack trace alone is not enough. You need an evidence kit that lets another engineer, or yourself next month, reconstruct what happened.

Keep screenshots and video recordings from the failing run. Capture the full browser console output, not just errors but warnings too. Log network failures, including 404s, CORS rejections, and dropped connections. Preserve the build IDs and feature flags that were active. Take a DOM snapshot at the exact moment the assertion failed. A snapshot lets you inspect the HTML structure after the fact, rather