Todo empieza con un mensaje de Slack. La compilación está en rojo. Te desplazas por los fallos, frunces el ceño y vuelves a ejecutar la misma prueba en tu portátil. En verde. Reintentas el trabajo de CI. Quizás fue un error momentáneo. El fallo regresa, obstinado y repetible en el servidor, pero invisible para ti.

Una prueba de navegador que falla en CI pero pasa localmente es más que una molestia. Genera desconfianza. Los equipos empiezan a culpar al factor tiempo. Implementan arreglos temporales que nunca se eliminan. Un setTimeout aquí, un .wait(5000) allá. La suite se ralentiza. Los fallos siguen regresando. Esas pruebas inestables (flaky tests) se convierten en elementos permanentes y, con el tiempo, todo el mundo empieza a tratar un pipeline en rojo como ruido de fondo.

Eso es peligroso. No quieres una suite de pruebas que dé falsas alarmas.

El CI no está roto; simplemente es diferente

Los entornos de CI no son aleatorios. Son deterministas. El problema es que son deterministas respecto a un sistema que no es tu MacBook ni tu estación de trabajo Linux. Tu configuración local oculta diferencias que un ejecutor (runner) de CI limpio expone de inmediato.

Piensa en cuántas piezas móviles divergen. Tu máquina local podría ejecutar un servidor de desarrollo con hot module reloading, mientras que el CI construye un artefacto de producción con tree shaking y minificación. Eso por sí solo puede eliminar rutas de código o alterar el orden de ejecución. Los árboles de dependencias cambian. Un archivo de bloqueo (lockfile) que parece idéntico puede resolverse de forma distinta si la versión del gestor de paquetes varía por una sola versión menor. Las secuencias de red cambian. El Wi-Fi de tu oficina podría resolver una API de staging en un solo salto; el ejecutor de CI podría conectarse a un clúster diferente detrás de un equilibrador de carga, introduciendo una latencia que nunca ves.

Los propios navegadores se comportan de manera diferente según el entorno. Tu Chrome local tiene extensiones, credenciales en caché, almacenamiento local persistente y una GPU con aceleración de hardware. El CI comienza desde un perfil en blanco en cada ejecución. Los ciclos de vida del navegador divergen. Las rutas de renderizado divergen. Las fuentes que existen en tu sistema se sustituyen en el CI. El tamaño del viewport y las relaciones de píxeles de dispositivo varían, lo que puede alterar los puntos de interrupción (breakpoints) responsivos o cambiar el comportamiento de la carga diferida (lazy-loading).

Estas brechas son reales. Son mecánicas. Fingir que son aleatorias no hace que desaparezcan.

Los entornos de vista previa mienten

Los entornos de vista previa agravan el problema. Son útiles para la revisión humana, pero no son producción. A menudo apuntan a api-staging en lugar del host real de la API. Las feature flags se evalúan como verdaderas para cada experimento, ocultando la lógica condicional que se ejecuta en producción. La autenticación podría saltarse un paso o inyectar un token simulado (mock token). Las cookies podrían usar políticas relajadas. El conjunto de datos podría ser una muestra mínima, diez filas en lugar de diez mil, lo que significa que la lógica de paginación, el ranking de búsqueda o la virtualización nunca se ponen a prueba.

Si tu prueba pasa en una URL de vista previa pero falla en producción, o viceversa, el error no es la prueba. Es el entorno.

Registra antes de suponer

Cuando aparezca un fallo por primera vez, resiste el instinto de retocar la prueba y esperar que funcione. Deja de adivinar. Necesitas congelar el contexto para poder comparar una ejecución exitosa con una fallida.

Registra a los sospechosos obvios. Graba la URL de la página en el momento del fallo, el ID de la compilación y el SHA del commit. Anota las feature flags activas. Captura el host de la API, la versión exacta del navegador y el tamaño del viewport. Estos detalles convierten un fallo misterioso en una condición reproducible.

No confíes solo en las capturas de pantalla. Dos páginas pueden parecer idénticas a nivel de píxel mientras ejecutan JavaScript completamente diferente. Una captura de pantalla no te dirá que el paquete (bundle) de CI incluyó un polyfill adicional o que el paquete local omitió un fragmento (chunk) porque ya estaba en la caché de tu navegador.

Recuerda también que abrir las DevTools cambia los tiempos. Las DevTools pueden retrasar la recolección de basura (garbage collection), alterar la priorización de red y desactivar ciertas optimizaciones de renderizado. Una prueba que pasa mientras inspeccionas el DOM podría fallar en el momento en que cierras el panel y la ejecutas en modo headless. El depurador es una herramienta útil, pero no es un observador neutral.

Recrea la escena del crimen

Si quieres reproducir el fallo con precisión, no puedes simplemente ejecutar tu servidor de desarrollo local y esperar lo mejor. Necesitas replicar las condiciones exactas del 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