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.
Bouw exact hetzelfde artefact dat de CI heeft geproduceerd. Download het als het moet. Serveer dat artefact lokaal met een eenvoudige static file server, niet met Vite of Webpack dev middleware. Gebruik dezelfde omgevingsvariabelen die de CI heeft geïnjecteerd. Zorg dat de browserversie exact overeenkomt. Draai het in dezelfde modus, headed of headless, omdat focus-events, media queries en autoplay-policies nog steeds op subtiele wijze tussen beide verschillen. Als je CI een Docker-container gebruikt, draai dan dezelfde image lokaal. Verwijder je persoonlijke browserprofiel volledig.
Wanneer de lokale reproductie eindelijk faalt, heb je een echte debugsessie. Tot die tijd ben je schaduwen aan het najagen.
Stop met slapen, begin met wachten
De meest voorkomende reactie op een flaky browsertest is het toevoegen van vertraging. Wacht vijf seconden. Wacht tien. Dit is geen oplossing. Het is een overgave. Willekeurige vertragingen vertragen je suite, creëren een vals gevoel van veiligheid en falen nog steeds onder belasting wanneer het netwerk hapert.
Wacht in plaats daarvan op bewijs van de status. Als er een melding moet verschijnen na het verzenden van een formulier, wacht dan niet tot de tijd is verstreken. Wacht tot een specifieke notificatie-ID in de DOM bestaat. Als een teller moet toenemen, wacht dan tot de tekst van waarde verandert. Als een laadstatus interactie blokkeert, wacht dan tot de laadindicator verdwijnt. Als je werkt met een WebSocket of server-sent events, wacht dan tot de netwerkstroom een specifiek event produceert.
Expliciete wachttijden veranderen je test van een gokspel in een contract. De test zegt: "Ik ga verder zodra de applicatie bevestigt dat hij klaar is." Dat is veel sterker dan zeggen: "Ik ga verder zodra er genoeg seconden zijn verstreken."
Hydratatie en de verdwijnende knop
In moderne React-applicaties veroorzaakt hydratatie een specifieke klasse aan fouten die lokale dev-servers vaak maskeren. De server stuurt HTML. React start op in de browser en koppelt event listeners. Tijdens dat venster kan je test op een knop klikken. React vervangt of herstructureert die DOM-node vervolgens tijdens de hydratatie. De element-handle die je testframework vasthield, wijst nu naar een losgekoppelde (detached) node, en je krijgt een foutmelding over het interageren met een verwijderd element.
De oplossing is niet om een complexere selector te schrijven die dieper in de componentboom graaft. De oplossing is om te zoeken naar signalen van gereedheid. Wacht tot een root-element een gehydrateerd attribuut of een bekende data-property krijgt. Wacht tot een skeleton loader verdwijnt. Wacht tot een client-side event handler actief wordt. Laat de applicatie aankondigen dat hij stabiel is voordat je clicks uitvoert.
Verborgen boosdoeners: dependencies en scripts van derden
Soms verandert de omgeving, zelfs als je applicatiecode dat niet heeft gedaan. Een transitieve update in een kleine utility-library, drie niveaus diep in je node_modules, kan het gedrag van de browser veranderen. Het kan veranderen hoe promises worden opgelost, hoe stijlen worden geïnjecteerd of hoe mocks verzoeken onderscheppen. Wanneer tests beginnen te falen na een routine-update van dependencies, noteer dan je package manager-versie en de checksum van het lockfile. Je moet weten of je naar dezelfde boom kijkt als vorige week.
Scripts van derden zijn een andere frequente saboteur. Analytics-trackers, betalings-SDK's en chat-widgets laden asynchroon. Ze injecteren iframes, verschuiven de layout of stelen de focus op momenten die je test niet verwacht. In CI laden deze scripts mogelijk langzamer, of ze laden helemaal niet vanwege netwerkbeperkingen, waardoor je applicatie een ander pad voor foutafhandeling volgt. Log welke resources van derden zijn geladen en hun HTTP-status. Als een betalings-iframe er in CI drie seconden over doet om te mounten, maar direct laadt op je snelle lokale verbinding, heeft je "element not clickable"-fout plotseling een duidelijke oorzaak.
En "element not clickable" is nooit een diagnose. Het is een symptoom. Pak de oorzaak aan.
