Tutto inizia con un messaggio su Slack. La build è rossa. Scorri l'elenco dei fallimenti, aggrottando la fronte, e riesegui lo stesso test sul tuo laptop. Verde. Riprovi il job della CI. Forse è stato un errore momentaneo. Il fallimento ritorna, ostinato e ripetibile sul server, ma invisibile per te.
Un test del browser che fallisce nella CI ma passa in locale è più di un semplice fastidio. Genera sfiducia. I team iniziano a dare la colpa al timing. Introducono correzioni temporanee che non vengono mai rimosse. Un setTimeout qui, un .wait(5000) lì. La suite rallenta. I fallimenti continuano a tornare. Quei test instabili si trasformano in elementi fissi e permanenti, e alla fine tutti iniziano a considerare una pipeline rossa come rumore di fondo.
Questo è pericoloso. Non vuoi una suite di test che dia falsi allarmi.
La CI non è rotta; è solo diversa
Gli ambienti di CI non sono casuali. Sono deterministici. Il problema è che sono deterministici rispetto a un sistema che non è il tuo MacBook o la tua workstation Linux. La tua configurazione locale nasconde differenze che un runner CI pulito espone immediatamente.
Pensa a quante parti mobili divergono. La tua macchina locale potrebbe eseguire un server di sviluppo con hot module reloading, mentre la CI costruisce un artefatto di produzione con tree shaking e minificazione. Questo da solo può eliminare percorsi di codice o alterare l'ordine di esecuzione. Gli alberi delle dipendenze cambiano. Un lockfile che sembra identico può risolversi in modo diverso se la versione del package manager varia anche solo di una release minore. Le sequenze di rete cambiano. Il Wi-Fi dell'ufficio potrebbe risolvere un'API di staging in un unico passaggio; il runner della CI potrebbe colpire un cluster diverso dietro un load balancer, introducendo una latenza che non vedrai mai.
Anche i browser si comportano diversamente tra gli ambienti. Il tuo Chrome locale ha estensioni, credenziali memorizzate, local storage persistente e una GPU con accelerazione hardware. La CI parte da un profilo vuoto a ogni esecuzione. I cicli di vita del browser divergono. I percorsi di rendering divergono. I font presenti sul tuo sistema vengono sostituiti nella CI. Le dimensioni del viewport e i rapporti dei pixel dei dispositivi variano, il che può invertire i breakpoint responsivi o alterare il comportamento del lazy-loading.
Queste lacune sono reali. Sono meccaniche. Fingere che siano casuali non le farà sparire.
Gli ambienti di anteprima mentono
Gli ambienti di anteprima aggravano il problema. Sono utili per la revisione umana, ma non sono la produzione. Spesso puntano a api-staging invece dell'host API reale. I feature flag vengono valutati come true per ogni esperimento, nascondendo la logica condizionale che la produzione esegue. L'autenticazione potrebbe saltare un passaggio o iniettare un token mock. I cookie potrebbero utilizzare policy meno restrittive. Il dataset potrebbe essere una piccola porzione, dieci righe invece di diecimila, il che significa che la logica di paginazione, di ranking della ricerca o di virtualizzazione non viene mai testata.
Se il tuo test passa contro un URL di anteprima ma fallisce in produzione, o viceversa, il bug non è il test. È l'ambiente.
Logga prima di tirare a indovinare
Quando appare un fallimento per la prima volta, resisti all'istinto di modificare il test sperando che funzioni. Smetti di tirare a indovinare. Devi congelare il contesto in modo da poter confrontare un'esecuzione riuscita con una fallita.
Logga i sospetti ovvi. Registra l'URL della pagina al momento del fallimento, l'ID della build e il commit SHA. Nota i feature flag attivi. Cattura l'host dell'API, la versione esatta del browser e la dimensione del viewport. Questi dettagli trasformano un fallimento misterioso in una condizione riproducibile.
Non affidarti solo agli screenshot. Due pagine possono sembrare identiche a livello di pixel pur eseguendo JavaScript completamente diversi. Uno screenshot non ti dirà che il bundle della CI includeva un polyfill extra o che il bundle locale ha saltato un chunk perché era già nella cache del tuo browser.
Ricorda anche che aprire i DevTools cambia il timing. I DevTools possono posticipare la garbage collection, alterare la priorità di rete e disabilitare certe ottimizzazioni di rendering. Un test che passa mentre stai ispezionando il DOM potrebbe fallire nel momento in cui chiudi il pannello ed esegui il test in modalità headless. Il debugger è uno strumento utile, ma non è un osservatore neutrale.
Ricrea la scena del crimine
Se vuoi riprodurre il fallimento in modo accurato, non puoi semplicemente avviare il tuo server di sviluppo locale e sperare nel meglio. Devi replicare le condizioni esatte della_CI_.
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
