680 Ejecuciones, Cero Reintentos: Cómo Demostrar de Verdad que un Test Flaky Está Corregido

Erik Treviño avatar
Erik Treviño
Cover for 680 Ejecuciones, Cero Reintentos: Cómo Demostrar de Verdad que un Test Flaky Está Corregido

“El test flaky ya está corregido — pasó.”

No. Pasó una vez. Eso no es estar corregido.

Si la tasa de fallo original era del 0.5%, una sola ejecución exitosa te da un 99.5% de confianza. Suena alto hasta que tu CI se ejecuta 200 veces por semana y ves fallos “aleatorios” cada lunes por la mañana. Una ejecución exitosa no es evidencia. Es un volado que te tocó ganar.

Yo ejecuto cada corrección de test flaky 680 veces consecutivas antes de darla por terminada. Cero reintentos. Cero allowedFlakes. Si falla una vez en 680 ejecuciones, la corrección no es real.

Las Matemáticas que Te Harán Cambiar de Opinión

Este es un problema de probabilidad binomial. Si un test tiene una tasa de fallo real de p, la probabilidad de que pase n ejecuciones consecutivas es (1 − p)^n. La probabilidad de ver al menos un fallo es 1 − (1 − p)^n.

Para un test con una tasa de fallo del 0.5%:

Pases ConsecutivosP(todos pasan)P(al menos 1 fallo)Confianza de que el bug sigue ahí
199.50%0.50%Casi ninguna — no aprendiste nada
1095.11%4.89%Baja
5077.83%22.17%Moderada
10060.65%39.35%Empezando a llegar
20036.77%63.23%Volado
46010.00%90.00%90% de confianza en que la corrección es real
6803.31%96.69%~97% de confianza con tasa de fallo del 0.5%
10000.67%99.33%99%+ de confianza

También existe un atajo útil llamado la Regla del Tres: si observas cero fallos en n pruebas, el límite superior de la tasa de fallo con 95% de confianza es aproximadamente 3/n. Para n = 680, eso es 3/680 = 0.44%. Así que 680 pases con cero fallos significa que puedes tener un 95% de confianza en que la tasa de fallo real está por debajo del 0.44%.

¿Por qué 680 específicamente? Es un número práctico: 8 workers en paralelo × 85 iteraciones cada uno. Cabe en una sola ejecución de estrés, se completa en un tiempo razonable y empuja el límite superior de las tasas de fallo probables por debajo del umbral donde un pipeline de CI típico vería fallos semanales.

El punto no es que 680 sea un número mágico. El punto es que uno no es suficiente, diez no es suficiente, y necesitas hacer las cuentas para tu situación específica.

Por Qué la Mayoría de las “Correcciones” Fallan

Después de meses ejecutando este protocolo, surgieron tres patrones en las correcciones fallidas — tests que pasaron una o incluso cincuenta veces y luego volvieron a fallar.

Patrón 1: El Timeout Extendido

La no-corrección más común. Un test falla porque un elemento tarda 6 segundos en aparecer. La “corrección” cambia el timeout de 5 segundos a 15 segundos. Pasa hoy. Mañana, bajo la carga del CI con workers de test en paralelo compitiendo por recursos, ese elemento tarda 18 segundos.

// ❌ The non-fix: extending the timeout
await page.getByRole('button', { name: 'Submit' }).click();
await expect(page.getByText('Success')).toBeVisible({ timeout: 15000 });

// ✅ The real fix: wait for the actual condition, not an arbitrary timer
await page.getByRole('button', { name: 'Submit' }).click();
await page.waitForResponse(resp =>
  resp.url().includes('/api/submit') && resp.status() === 200
);
await expect(page.getByText('Success')).toBeVisible();

Un timeout no es una corrección — es una apuesta a que el sistema siempre será al menos así de rápido. Esa apuesta se pierde eventualmente.

Patrón 2: La Fuga de Estado

El Test B pasa cuando se ejecuta solo, falla cuando se ejecuta después del Test A. El Test A deja atrás una cookie, una entrada en local storage o un registro en la base de datos que cambia las condiciones iniciales del Test B. La “corrección” agrega un cleanup en beforeEach. Pero el cleanup en sí es frágil — limpia el estado conocido pero se salta el artefacto que solo se crea bajo condiciones específicas del Test A.

// ❌ Fragile cleanup: clearing known state
test.beforeEach(async ({ page }) => {
  await page.evaluate(() => localStorage.clear());
});

// ✅ Robust isolation: fresh context per test
// In playwright.config.ts — each test gets a pristine browser context
const config: PlaywrightTestConfig = {
  use: {
    // Every test starts with zero cookies, zero storage, zero history
    storageState: undefined,
    contextOptions: {
      ignoreHTTPSErrors: true,
    },
  },
  // Fully parallel — no shared state between workers
  fullyParallel: true,
};

Patrón 3: La Condición de Carrera

Un clic se dispara. Una petición de red comienza. El test hace una aserción sobre el resultado. Pero la aserción se ejecuta antes de que llegue la respuesta. Funciona en tu máquina local rápida. Falla el 0.5% de las veces en CI donde la contención de recursos agrega 200ms de latencia a cada salto de red.

// ❌ Race condition: assert before the data arrives
await page.getByRole('button', { name: 'Load Data' }).click();
await expect(page.getByTestId('results-count')).toHaveText('42');

// ✅ Wait for the network response, then assert on the rendered result
await page.getByRole('button', { name: 'Load Data' }).click();
await page.waitForResponse(resp =>
  resp.url().includes('/api/data') && resp.status() === 200
);
await expect(page.getByTestId('results-count')).toHaveText('42');

Deja de Decir “Flaky” — Empieza a Clasificar

La palabra “flaky” es un callejón sin salida en el diagnóstico. Detiene la investigación. Es el equivalente en testing a que un doctor diga “estás enfermo.” Técnicamente cierto. Completamente inútil.

Cada fallo de test tiene una causa raíz. Clasifícala:

Clase de Causa RaízSíntomaCategoría de Corrección
Defecto de timingPasa en local, falla en CI bajo cargaEsperar condiciones, no timers
Fuga de estadoFalla solo al ejecutarse después de tests específicosAislamiento — contexto limpio por test
Condición de carreraFalla intermitentemente en aserciones rápidasEsperar red/estado, luego hacer aserción
Contención de recursosFalla en paralelo, pasa en serialAislamiento de workers o locks de recursos
Deriva de entornoFalla en staging pero no en devFixtures conscientes del entorno

Una vez que nombras la clase, puedes buscarla. Eso es hardening predictivo.

Hardening Predictivo: Corrige Tests Antes de que Fallen

Este es el cambio metodológico que importa más que el protocolo de 680 ejecuciones en sí.

Después de corregir 3 tests flaky conocidos que compartían la misma causa raíz — condiciones de carrera entre manejadores de clics y respuestas de red — busqué en toda la suite el mismo patrón. Grep por sentencias de aserción que siguen inmediatamente a acciones de clic sin un waitForResponse o waitForLoadState intermedio.

# Find potential race conditions: assertions immediately after clicks
# with no waitForResponse in between
grep -n "\.click()" tests/**/*.spec.ts | while read line; do
  file=$(echo "$line" | cut -d: -f1)
  linenum=$(echo "$line" | cut -d: -f2)
  # Check if the next 3 lines contain a waitForResponse
  nextlines=$(sed -n "$((linenum+1)),$((linenum+3))p" "$file")
  if echo "$nextlines" | grep -q "expect\|toHave\|toBe" && \
     ! echo "$nextlines" | grep -q "waitFor"; then
    echo "POTENTIAL RACE: $file:$linenum"
  fi
done

Encontré 3 tests más con el mismo patrón de vulnerabilidad — y los endurecí antes de que fallaran en CI.

Esa es la diferencia entre testing reactivo (esperar a que se rompa y luego corregirlo) y hardening predictivo (clasificar el patrón de fallo y luego barrer toda la suite).

El Patrón FAST / STANDARD / EXTENDED

Reemplaza los números mágicos improvisados con constantes de polling estandarizadas. Cada llamada a waitFor en la suite referencia una constante con nombre, no una suposición:

// test-constants.ts
export const TIMEOUTS = {
  FAST: 2_000,       // Elements that should appear immediately after navigation
  STANDARD: 10_000,  // API responses and re-renders
  EXTENDED: 30_000,  // Complex operations, streaming, file uploads
  STRESS: 60_000,    // Only used in stress test configurations
} as const;

// In tests:
await expect(page.getByRole('heading')).toBeVisible({
  timeout: TIMEOUTS.FAST
});

await expect(page.getByTestId('search-results')).toHaveCount(10, {
  timeout: TIMEOUTS.STANDARD
});

Cuando un test necesita EXTENDED, eso es una señal. Significa que la operación es genuinamente lenta (aceptable) o que algo en la arquitectura está bloqueando (a investigar). Las constantes con nombre hacen estas señales visibles en el code review.

El Protocolo de Prueba de Estrés

Esta es la configuración exacta que uso para la validación de estrés:

// playwright.stress.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  workers: 8,
  repeatEach: 85,     // 8 × 85 = 680 total runs
  retries: 0,         // Zero tolerance — a single failure invalidates the fix
  timeout: 30_000,
  use: {
    trace: 'on-first-retry',  // Won't fire with 0 retries — that's the point
  },
  reporter: [
    ['list'],
    ['json', { outputFile: 'stress-results.json' }],
  ],
});
# Run stress validation against a specific test file
npx playwright test tests/checkout-flow.spec.ts --config=playwright.stress.config.ts

# Verify: all 680 must pass
cat stress-results.json | jq '.stats | {total: .expected, passed: .expected, failed: .unexpected}'

Si las 680 pasan: la corrección es real. A producción.

Si alguna falla: no solo veas la cantidad de fallos. Observa qué workers fallaron y en qué iteraciones dentro de esos workers. Fallos agrupados en iteraciones tardías sugieren agotamiento de recursos (fuga de memoria, agotamiento del pool de conexiones). Fallos dispersos aleatoriamente sugieren que la corrección está incompleta. Fallos solo en workers específicos sugieren un problema de aislamiento.

La Lección de Fondo

Los tests flaky no son una categoría de molestia. Son una oportunidad de diagnóstico. Cada test “flaky” te está diciendo algo específico sobre el comportamiento de tu sistema bajo condiciones para las que no fue diseñado. El test que falla intermitentemente en CI pero pasa en local está reportando información real: tu aplicación se comporta diferente bajo contención de recursos. Eso no es que el test sea poco confiable. Es que el test está siendo más honesto que tu entorno local.

El cambio de “corrige este test flaky” a “clasifica y elimina este patrón de fallo en toda la suite” es el cambio más impactante que he hecho en mi metodología de testing. Convierte un juego reactivo de whack-a-mole en una reducción sistemática de la superficie de fallos.

Deja de llamar a tus tests flaky. Empieza a clasificar tus fallos. Y deja de dar una corrección por terminada porque pasó una vez.

680 ejecuciones. Cero reintentos. Confianza estadística, no esperanza.