It starts with a Slack message. The build is red. You scroll through the failures, frown, and rerun the same test on your laptop. Green. You retry the CI job. Maybe it was a blip. The failure comes back, stubborn and repeatable on the server but invisible to you.

A browser test that fails in CI yet passes locally is more than an annoyance. It breeds distrust. Teams begin to blame timing. They push temporary fixes that never leave. A setTimeout here, a .wait(5000) there. The suite slows down. The failures keep returning. Those flaky tests harden into permanent fixtures, and eventually everyone begins treating a red pipeline as background noise.

That is dangerous. You do not want a test suite that cries wolf.

CI Is Not Broken; It Is Just Different

CI environments are not random. They are deterministic. The problem is that they are deterministic about a system that is not your MacBook or your Linux workstation. Your local setup hides differences that a clean CI runner exposes immediately.

Think about how many moving parts diverge. Your local machine might run a development server with hot module reloading, while CI builds a production artifact with tree shaking and minification. That alone can strip code paths or alter execution order. Dependency trees shift. A lockfile that looks identical can resolve differently if the package manager version varies by a single minor release. Network sequences change. Your office Wi-Fi might resolve a staging API in one hop; the CI runner might hit a different cluster behind a load balancer, introducing latency you never see.

Browsers themselves behave differently across environments. Your local Chrome carries extensions, cached credentials, persistent local storage, and a GPU with hardware acceleration. CI starts from a blank profile on every run. Browser lifecycles diverge. Rendering paths diverge. Fonts that exist on your system get substituted in CI. Viewport sizing and device pixel ratios vary, which can flip responsive breakpoints or alter lazy-loading behavior.

These gaps are real. They are mechanical. Pretending they are random does not make them go away.

Preview Environments Lie

Preview environments compound the problem. They are useful for human review, but they are not production. They often point to api-staging instead of the real API host. Feature flags evaluate to true for every experiment, hiding conditional logic that production executes. Authentication might skip a step or inject a mock token. Cookies might use relaxed policies. The dataset could be a thin slice, ten rows instead of ten thousand, which means pagination, search ranking, or virtualization logic never gets exercised.

If your test passes against a preview URL but fails in production, or vice versa, the test is not the bug. The environment is.

Log Before You Guess

When a failure first appears, resist the instinct to tweak the test and hope. Stop guessing. You need to freeze the context so you can compare a passing run against a failing one.

Log the obvious suspects. Record the page URL at the moment of failure, the build ID, and the commit SHA. Note the active feature flags. Capture the API host, the exact browser version, and the viewport size. These details turn a mysterious failure into a reproducible condition.

Do not rely only on screenshots. Two pages can look pixel-identical while running completely different JavaScript. A screenshot will not tell you that the CI bundle included an extra polyfill or that the local bundle skipped a chunk because it was already in your browser cache.

Also remember that opening DevTools changes timing. DevTools can defer garbage collection, alter network prioritization, and disable certain rendering optimizations. A test that passes while you are inspecting the DOM might fail the moment you close the panel and run it headless. The debugger is a useful tool, but it is not a neutral observer.

Recreate the Crime Scene

If you want to reproduce the failure accurately, you cannot simply run your local development server and hope for the best. You need to replicate the_CI's_ exact conditions.

Bina artifak yang tepat seperti yang dihasilkan oleh CI. Muat turun jika perlu. Jalankan artifak tersebut secara tempatan dengan pelayan fail statik yang ringkas, bukan dengan middleware pembangunan Vite atau Webpack. Gunakan pemboleh ubah persekitaran yang sama yang disuntik oleh CI. Padankan versi pelayar dengan tepat. Jalankannya dalam mod yang sama, headed atau headless, kerana acara fokus, pertanyaan media, dan polisi main automatik masih berbeza antara keduanya dalam cara yang halus. Jika CI anda menggunakan kontena Docker, jalankan imej yang sama secara tempatan. Buang profil pelayar peribadi anda sepenuhnya.

Apabila replikasi tempatan akhirnya gagal, anda mempunyai sesi penyahpepijatan yang sebenar. Sehingga itu, anda hanya mengejar bayang-bayang.

Berhenti Tidur, Mula Menunggu

Respons yang paling biasa terhadap ujian pelayar yang tidak stabil (flaky) adalah dengan menambah lengah (delay). Tunggu lima saat. Tunggu sepuluh. Ini bukan penyelesaian. Ini adalah penyerahan kalah. Lengah yang sewenang-wenangnya melambatkan suite anda, mewujudkan keyakinan palsu, dan masih gagal di bawah bebanan apabila rangkaian mengalami gangguan.

Sebaliknya, tunggu bukti keadaan (state). Jika satu pemberitahuan sepatutnya muncul selepas penghantaran borang, jangan tunggu masa berlalu. Tunggu sehingga ID pemberitahuan tertentu wujud dalam DOM. Jika satu penghitung sepatutnya bertambah, tunggu sehingga teks berubah nilai. Jika keadaan pemuatan (loading state) menghalang interaksi, tunggu sehingga penanda pemuatan hilang. Jika anda bekerja dengan WebSocket atau acara yang dihantar oleh pelayan (server-sent events), tunggu sehingga aliran rangkaian menghasilkan acara tertentu.

Tunggu secara eksplisit menukarkan ujian anda daripada permainan teka-teki kepada satu kontrak. Ujian itu berkata: "Saya akan meneruskan sebaik sahaja aplikasi mengesahkan ia sudah sedia." Itu jauh lebih kuat daripada berkata, "Saya akan meneruskan selepas cukup beberapa saat telah berlalu."

Hidrasi dan Butang yang Hilang

Dalam aplikasi React moden, hidrasi menyebabkan kelas kegagalan tertentu yang sering disembunyikan oleh pelayan pembangunan tempatan. Pelayan menghantar HTML. React bermula dalam pelayar dan memasang pendengar acara (event listeners). Dalam tempoh itu, ujian anda mungkin mengklik satu butang. React kemudian menggantikan atau menyusun semula nod DOM tersebut semasa hidrasi. Pemegang elemen (element handle) yang dipegang oleh rangka kerja ujian anda kini merujuk kepada nod yang telah terputus, dan anda mendapat ralat tentang interaksi dengan elemen yang telah dibuang.

Penyelesaiannya bukanlah dengan menulis penyelidik (selector) yang lebih kompleks yang menggali lebih dalam ke dalam pokok komponen. Penyelesaiannya adalah dengan mencari isyarat kesediaan. Tunggu sehingga elemen akar mendapat atribut hidrasi atau sifat data yang diketahui. Tunggu sehingga pemuat rangka (skeleton loader) hilang. Tunggu sehingga pengendali acara sebelah klien menjadi aktif. Biarkan aplikasi mengumumkan bahawa ia sudah stabil sebelum anda melakukan klik.

Punca Tersembunyi: Kebergantungan dan Skrip Pihak Ketiga

Kadangkala persekitaran berubah walaupun kod aplikasi anda tidak berubah. Kemas kini transitif dalam perpustakaan utiliti kecil, tiga tahap di dalam node_modules anda, boleh mengubah tingkah laku pelayar. Ia mungkin mengubah cara janji (promises) diselesaikan, cara gaya (styles) dimasukkan, atau cara mok (mocks) memintas permintaan. Apabila ujian mula gagal selepas kemas kini kebergantungan rutin, rekodkan versi pengurus pakej dan checksum fail kunci (lockfile) anda. Anda perlu tahu sama ada anda melihat pokok yang sama seperti minggu lepas.

Skrip pihak ketiga adalah sabotaj yang kerap berlaku. Penjejak analitik, SDK pembayaran, dan widget sembang dimuatkan secara asinkronus. Ia memasukkan iframe, mengalihkan susun atur, atau mencuri fokus pada saat yang tidak dijangka oleh ujian anda. Dalam CI, skrip ini mungkin dimuatkan dengan lebih perlahan, atau ia mungkin gagal dimuatkan sepenuhnya disebabkan oleh sekatan rangkaian, menyebabkan aplikasi anda mengikut laluan pengendalian ralat yang berbeza. Logkan sumber pihak ketiga yang dimuatkan dan status HTTP mereka. Jika iframe pembayaran mengambil masa tiga saat untuk dipasang dalam CI tetapi dimuatkan serta-merta pada sambungan tempatan anda yang pantas, ralat "elemen tidak boleh diklik" anda tiba-tiba mempunyai punca yang jelas.

Dan "elemen tidak boleh diklik" bukanlah satu diagnosis. Ia adalah simptom. Rawat puncanya.

Bina Kit Bukti

Setiap kegagalan CI haruslah boleh diambil tindakan. Jejak timbunan (stack trace) sahaja tidak mencukupi. Anda memerlukan kit bukti yang membolehkan jurutera lain, atau diri anda sendiri pada bulan hadapan, membina semula apa yang telah berlaku.

Simpan tangkapan skrin dan rakaman video daripada larian yang gagal. Tangkap keseluruhan output konsol pelayar, bukan sahaja ralat tetapi amaran juga. Logkan kegagalan rangkaian, termasuk 404, penolakan CORS, dan sambungan yang terputus. Kekalkan ID binaan (build IDs) dan bendera ciri (feature flags) yang aktif. Ambil imbasan (snapshot) DOM pada saat tepat pengesahan (assertion) gagal. Imbasan membolehkan anda memeriksa struktur HTML selepas kejadian, berbanding