Son las 3pm. Release programado. GitHub Actions falla.
✘ [retry 1/2] › checkout.spec.ts:45 › should complete purchase✘ [retry 2/2] › checkout.spec.ts:45 › should complete purchaseYa sabes cuál test es. El mismo de siempre. El que ya falló tres veces esta semana. El que nadie arregló porque “no es urgente”.
Alguien dice: “Dale re-run que a la segunda pasa.”
Y pasa. Y todo el mundo sigue como si nada.
Hasta la próxima vez.
No tienes flaky tests
Voy a ser directa: si tu suite tiene tests que “a veces fallan”, el problema no es el test. El problema es cómo está construido.
Un flaky test es un test automatizado que hoy pasa, mañana falla, y pasado vuelve a pasar. Sin que nadie haya tocado nada. Es el test que el equipo ya aprendió a ignorar.
Pero no. No tienes flaky tests. Tienes tests mal arquitectados.
Y la diferencia importa. Porque si crees que el problema es “el test es flaky”, la solución es re-run. Si entiendes que el problema es arquitectónico, la solución es arreglarlo de raíz.
Entonces, te preguntas: ¿por qué este test falla aleatoriamente?
Te digo por qué.
5 razones por las que tus tests son flaky
Y ninguna es culpa de tu framework.
1. Selectores frágiles
Si tu selector es un XPath de 8 niveles o depende de un ID dinámico, no tienes un test. Tienes una bomba de tiempo.
// Esto se rompe si alguien agrega un div, cambia una clase, o reordena el layoutawait page.click("div.container > div:nth-child(3) > button.btn-primary");await page.locator("#react-select-2-option-1").click();// Esto sobrevive a cualquier refactor de UIawait page.getByRole("button", { name: "Confirmar compra" }).click();await page.getByTestId("country-select").click();await page.getByRole("option", { name: "Paraguay" }).click();La diferencia es brutal. El primer test se acopla a la estructura del DOM. El segundo se acopla a la intención del usuario. Si alguien cambia el CSS, el layout, o el orden de los elementos, el segundo sigue funcionando.
Usa locators en este orden de prioridad: getByRole > getByLabel > getByTestId > getByText. Si ninguno funciona, pide al dev que agregue un data-testid. No es un favor — es testabilidad de la aplicación.
2. Tests que dependen de otros tests
Si tu test de “editar perfil” necesita que “crear usuario” corra primero, no tienes automation. Tienes un dominó.
// Si 'crear usuario' falla, 'editar perfil' falla también.// Si cambias el orden, todo explota.// Si corres en paralelo, caos.
test("crear usuario", async ({ page }) => { await page.goto("/admin/users"); await page.getByRole("button", { name: "Nuevo usuario" }).click(); await page.getByLabel("Email").fill("test@example.com"); await page.getByRole("button", { name: "Guardar" }).click();});
test("editar perfil", async ({ page }) => { // Asume que el usuario de arriba ya existe await page.goto("/admin/users"); await page.getByText("test@example.com").click(); await page.getByLabel("Nombre").fill("Adriana"); await page.getByRole("button", { name: "Actualizar" }).click();});test("editar perfil de usuario", async ({ page, request }) => { // Cada test crea su propio estado via API — rápido y aislado const response = await request.post("/api/users", { data: { email: `test-${Date.now()}@example.com`, name: "Usuario temporal", }, }); const { id } = await response.json();
await page.goto(`/admin/users/${id}/edit`); await page.getByLabel("Nombre").fill("Adriana"); await page.getByRole("button", { name: "Actualizar" }).click(); await expect(page.getByText("Perfil actualizado")).toBeVisible();});Setup independiente. Teardown completo. Cada test crea su propio mundo y lo destruye al terminar. Así puedes correr cualquier test en cualquier orden, en paralelo, sin sorpresas.
3. Waits hardcodeados
await page.waitForTimeout(5000);Esto no es una espera. Es una oración. Estás rezando para que 5 segundos alcancen.
En tu máquina local tarda 800ms. En CI tarda 3 segundos. Un viernes a las 5pm con el servidor cargado tarda 7. Tu test falla “aleatoriamente”.
No es aleatorio. Es que 5 segundos a veces alcanza y a veces no.
await page.getByRole("button", { name: "Enviar" }).click();await page.waitForTimeout(5000); // Rezando para que la API respondaawait expect(page.getByText("Pedido confirmado")).toBeVisible();await page.getByRole("button", { name: "Enviar" }).click();
// Espera por la respuesta real de la APIawait page.waitForResponse( (response) => response.url().includes("/api/orders") && response.status() === 200,);
// Playwright auto-espera con reintentos internos (timeout configurable)await expect(page.getByText("Pedido confirmado")).toBeVisible();Si necesitas waitForTimeout en tu test, es una señal de que no entiendes qué
estás esperando. Identifica la condición real: ¿una respuesta HTTP? ¿un
elemento visible? ¿una URL? Espera por eso, no por tiempo.
Playwright ya tiene auto-waiting integrado. Cuando haces click(), espera a que el elemento sea visible, estable y habilitado. Cuando usas expect(...).toBeVisible(), reintenta internamente hasta que se cumpla o expire el timeout. No necesitas agregar esperas manuales en el 90% de los casos.
4. Estado compartido entre tests
Dos tests usan el mismo usuario. Uno modifica los datos. El otro falla “aleatoriamente”.
No es aleatorio. Es colisión de estado.
// Ambos tests usan el mismo usuario. Si corren en paralelo, caos.const TEST_USER = "admin@empresa.com";
test("cambiar nombre", async ({ page }) => { await loginAs(page, TEST_USER); await page.getByLabel("Nombre").fill("Nombre Nuevo"); await page.getByRole("button", { name: "Guardar" }).click();});
test("verificar datos del perfil", async ({ page }) => { await loginAs(page, TEST_USER); // ¿El nombre es 'Nombre Nuevo' o el original? // Depende de qué test corrió primero. En paralelo, no sabes. await expect(page.getByLabel("Nombre")).toHaveValue("Admin");});test("cambiar nombre de usuario", async ({ page, request }) => { // Cada test crea su propio usuario con datos únicos const uniqueEmail = `user-${Date.now()}@test.com`; await request.post("/api/users", { data: { email: uniqueEmail, name: "Original", password: "Test1234!" }, });
await loginAs(page, uniqueEmail); await page.getByLabel("Nombre").fill("Nombre Nuevo"); await page.getByRole("button", { name: "Guardar" }).click(); await expect(page.getByText("Datos actualizados")).toBeVisible();});Datos únicos por test. Email con timestamp. Registros fresh para cada ejecución. Cero dependencia de lo que hizo otro test antes.
5. Falta de aislamiento del entorno
Tu test depende de una API de terceros. La API tiene latencia variable. Tu test falla los viernes a las 5pm.
No es un flaky test. Es un test acoplado a algo que no controlas.
test("mostrar tipo de cambio", async ({ page }) => { await page.goto("/dashboard"); // Depende de que la API externa responda rápido y con datos válidos await expect(page.getByTestId("exchange-rate")).toBeVisible(); // Si la API tiene latencia, timeout, o cambia el formato → flaky});test("mostrar tipo de cambio", async ({ page }) => { // Intercepta la llamada a la API externa y devuelve datos controlados await page.route("**/api/exchange-rates", async (route) => { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ USD: 7350, EUR: 8200 }), }); });
await page.goto("/dashboard"); await expect(page.getByTestId("exchange-rate")).toContainText("7.350");});Controla el entorno, no lo esperes. Los mocks no son trampa — son la diferencia entre un test que valida tu código y un test que valida la disponibilidad de un servidor que no controlas.
Mockea dependencias externas (APIs de terceros, servicios de email, pasarelas de pago). No mockees tu propia API — esos son tests de integración y tienen que ir contra el backend real.
La verdad incómoda
El 90% de los flaky tests se arreglan sin tocar el framework. Se arreglan con mejor aislamiento, mejores selectores, esperas inteligentes, estado independiente y menos acoplamiento.
El problema nunca fue Playwright. Ni Cypress. Ni Selenium.
El problema es que escribimos tests como si fueran scripts descartables en vez de software que necesita arquitectura.
Un test bien diseñado no es flaky. Es predecible. Es repetible. Es confiable.
El flaky test está en tu pipeline. ¿Y ahora qué?
Ya sabes por qué fallan. Ahora hablemos de lo que pasa cuando uno de esos tests bloquea tu deploy un viernes a las 3pm.
Identificarlo era la parte fácil. Decidir qué hacer cuando bloquea tu release — eso es lo difícil.
La decisión
La mayoría elige B. Y luego “temporalmente” dura 6 meses.
Ninguna de las dos opciones es inherentemente correcta. La diferencia no está en cuál eliges. Está en si lo registras o lo escondes.
El protocolo: 5 pasos para manejar un flaky test
Paso 1: Identifica con datos, no con intuición
No basta con “ese test a veces falla”. Necesitas evidencia:
- Dos retries fallidos en el mismo run
- Historial de fallos en la última semana
- El test pasa en local pero falla en CI (o viceversa)
Si usas Playwright con retries, el reporte HTML ya clasifica los tests como passed, flaky (falló y luego pasó en retry), o failed. Esa clasificación es tu primer indicador.
import { defineConfig } from "@playwright/test";
export default defineConfig({ retries: 2, reporter: [["html", { open: "never" }]],});Con retries habilitados, Playwright marca automáticamente como “flaky” cualquier test que falle en el primer intento pero pase en un retry. El reporte HTML te da el historial completo.
Paso 2: Etiqueta con intención
No comentes el test. No lo borres. No lo ignores en silencio.
Etiquétalo de forma que el equipo sepa que existe, por qué está excluido, y dónde está el ticket.
test("@flaky should complete purchase", async ({ page }) => { // Anotación con referencia al issue — aparece en el reporte HTML test.info().annotations.push({ type: "flaky", description: "Race condition en el checkout — Issue #234", });
// ... el test sigue aquí});Alternativa: usar test.fixme() para bloquear la ejecución
Si prefieres que el test no se ejecute en absoluto hasta que se arregle:
test("should complete purchase", async ({ page }) => { test.fixme(true, "Flaky: race condition en checkout — Issue #234"); // El test no se ejecuta, pero queda visible en el reporte como "fixme"});La diferencia: @flaky con anotación sigue ejecutando el test (útil para monitorear si se estabiliza). test.fixme() lo detiene completamente.
Paso 3: Excluye del pipeline principal — no del repo
El test existe. Está etiquetado. Ahora necesitas que no bloquee tu deploy, pero que siga corriendo para que el equipo sepa su estado.
jobs: e2e-tests: steps: # Tests estables — estos bloquean el deploy - name: Run stable tests run: npx playwright test --grep-invert "@flaky"
# Tests flaky — corren en paralelo, sin bloquear - name: Run flaky tests (non-blocking) run: npx playwright test --grep "@flaky" || trueEl || true es intencional. El test corre y queda en el log. No bloquea el deploy. Pero el equipo sabe que existe. Si un día ves que el flaky test lleva dos semanas pasando consistentemente, es señal de que puedes quitarle la etiqueta y devolverlo al pipeline principal.
Paso 4: Abre el ticket. No es opcional.
No el ticket de “hay que revisarlo cuando haya tiempo”. Ese ticket nunca existe.
El ticket que funciona tiene:
- Link al test excluido (archivo + línea)
- Último log de fallo (el output real, no “a veces falla”)
- Impacto estimado (qué flujo queda sin cobertura)
- Fecha límite real — no “cuando se pueda”
Sin ticket con fecha, no tomaste una decisión de ingeniería. Moviste el problema a donde no se ve.
Paso 5: Arregla con prioridad, no con olvido
Un flaky test es deuda técnica. Y como toda deuda, acumula intereses.
Un
@flakysin issue = deuda técnica invisible. Un@flakycon issue, prioridad y fecha = decisión consciente.
Un flaky test ignorado no desaparece. Se convierte en deuda que nadie ve, hasta que alguien pregunta por qué esa feature lleva meses sin cobertura.
Checklist: antes de etiquetar un test como flaky
Antes de asumir que un test es flaky, verifica que no sea uno de los 5 problemas de arquitectura:
Si la respuesta a cualquiera de estas es “frágil / compartido / por tiempo / dependiente”, arregla eso primero. No es un flaky test — es un test que necesita arquitectura.
Antes y después: resumen ejecutivo
| Patrón | El test flaky | El test arquitectado |
|---|---|---|
| Selectores | XPath, IDs dinámicos, clases CSS | getByRole, getByTestId, getByLabel |
| Estado | Depende de otros tests | Crea su propio estado via API |
| Esperas | waitForTimeout(5000) | waitForResponse, toBeVisible() |
| Datos | Usuario compartido entre tests | Email con timestamp, datos únicos |
| Dependencias | API de terceros sin mock | page.route() con respuesta controlada |
| En CI | ”Dale re-run” | @flaky + issue + fecha + pipeline separado |
¿Y ahora qué?
Si llegaste hasta aquí, ya tienes todo lo que necesitas para dejar de convivir con flaky tests:
- Diagnostica: revisa tus tests que fallan intermitentemente contra las 5 causas
- Arregla: la mayoría se resuelven con mejores selectores y esperas inteligentes
- Lo que no puedas arreglar hoy: etiqueta, excluye, registra, y pon fecha
- Mide: si un test flaky lleva 2 semanas pasando, devuélvelo al pipeline principal
El problema nunca fue Playwright. Ni Cypress. Ni Selenium. Ni tu CI.
El problema es que escribimos tests como si fueran descartables.
Un test es software. Y el software necesita arquitectura.
Excluir un flaky test es válido. Excluirlo sin registrarlo es una trampa.