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 purchase

Ya 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.

84% de devs reportan flaky tests en su CI
5-10% de la suite promedio es flaky
3.7x más lento el ciclo de release
6 meses dura un 'temporal' sin ticket

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.

XPath frágil que se rompe con cualquier cambio en el DOM
Locator semántico que sobrevive a refactors de UI
Antes: la bomba de tiempo
// Esto se rompe si alguien agrega un div, cambia una clase, o reordena el layout
await page.click("div.container > div:nth-child(3) > button.btn-primary");
await page.locator("#react-select-2-option-1").click();
Después: el test que sobrevive
// Esto sobrevive a cualquier refactor de UI
await 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.

Regla de oro de selectores

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ó.

Antes: el 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();
});
Después: cada test es independiente
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.

Antes: la oración
await page.getByRole("button", { name: "Enviar" }).click();
await page.waitForTimeout(5000); // Rezando para que la API responda
await expect(page.getByText("Pedido confirmado")).toBeVisible();
Después: espera inteligente
await page.getByRole("button", { name: "Enviar" }).click();
// Espera por la respuesta real de la API
await 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();
waitForTimeout es una code smell

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.

Antes: la colisión
// 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");
});
Después: datos únicos por test
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.

Antes: acoplado al mundo exterior
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
});
Después: entorno controlado
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.

Cuándo mockear y cuándo no

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

Opción A: Bloqueas el deploy hasta arreglarlo
Opción B: Lo excluyes 'temporalmente' y deployeas

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

Identifica Etiqueta Excluye Registra Arregla

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.

playwright.config.ts — habilitar retries para detectar flaky
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.

Etiquetar un test flaky en Playwright
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.

.github/workflows/e2e.yml
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" || true

El || 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”
'Hay un test flaky en el checkout. Hay que revisarlo.'
'checkout.spec.ts:45 — race condition en submit. Sin este test, el flujo de compra no tiene cobertura E2E. Fecha límite: sprint 23.'

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 @flaky sin issue = deuda técnica invisible. Un @flaky con 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:

¿Los selectores son semánticos (getByRole, getByTestId) o frágiles (XPath, nth-child)?
¿El test crea su propio estado o depende de lo que hizo otro test?
¿Las esperas son por condición (waitForResponse, toBeVisible) o por tiempo (waitForTimeout)?
¿Los datos de prueba son únicos por test o compartidos?
¿Las dependencias externas están mockeadas o el test depende de su disponibilidad?

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ónEl test flakyEl test arquitectado
SelectoresXPath, IDs dinámicos, clases CSSgetByRole, getByTestId, getByLabel
EstadoDepende de otros testsCrea su propio estado via API
EsperaswaitForTimeout(5000)waitForResponse, toBeVisible()
DatosUsuario compartido entre testsEmail con timestamp, datos únicos
DependenciasAPI de terceros sin mockpage.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:

  1. Diagnostica: revisa tus tests que fallan intermitentemente contra las 5 causas
  2. Arregla: la mayoría se resuelven con mejores selectores y esperas inteligentes
  3. Lo que no puedas arreglar hoy: etiqueta, excluye, registra, y pon fecha
  4. 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.

La regla

Excluir un flaky test es válido. Excluirlo sin registrarlo es una trampa.