El costo visible de un test inestable es el tiempo que se pierde volviendo a ejecutar CI. El costo real es el momento en que tu equipo deja de tratar los builds en rojo como algo que vale la pena investigar, porque una vez que algunos fallos son "probablemente inestables", los bugs reales reciben el mismo tratamiento. Esta guía cubre las cinco causas raíz de los fallos intermitentes en Playwright: timing asíncrono, contaminación entre tests, estado compartido, dependencias de red e inestabilidad de selectores, con los pasos de diagnóstico y las correcciones concretas para cada uno.
El costo real de la inestabilidad
El costo obvio es el tiempo: desarrolladores volviendo a ejecutar pipelines, testers investigando fallos que resultan ser nada, ingenieros pasando una tarde del viernes bisecando un test que "empezó a comportarse mal solo." Ese tiempo se acumula rápido. Una estimación conservadora para un test persistentemente inestable en un equipo activo es de 30 a 60 minutos de investigación por semana.
El costo oculto es peor. Cuando los fallos no son confiables, cada fallo se vuelve sospechoso. Los bugs reales se descartan. El instinto de actuar ante un build en rojo (que es exactamente lo que CI está diseñado para generar) se erosiona. Con el tiempo tu suite de tests es verde al mergear, rojo en main, y nadie pestañea.
También hay un costo psicológico. Los tests inestables hacen que la automatización de tests parezca poco confiable y frágil. Los ingenieros junior del equipo empiezan a creer que la automatización es inherentemente poco confiable, lo que forma cómo escriben tests en el futuro.
La corrección empieza con una mirada clara a las causas raíz, no recurriendo a --retries.
Race conditions y timing asíncrono: la causa número uno
La gran mayoría de los tests inestables en Playwright vienen de problemas de timing. El test intenta hacer clic en un botón antes de que el botón esté listo, o aserta sobre texto antes de que la solicitud de red que lo popula haya terminado. En una máquina rápida funciona. En un runner de CI lento no.
El instinto es agregar un sleep:
// La corrección incorrecta: sigue siendo inestable, solo más lento
await page.waitForTimeout(3000);
await page.getByRole('button', { name: 'Guardar' }).click();Esto hace el test tres segundos más lento y sigue fallando en un día malo de CI. Cambiaste un problema por dos.
Playwright hace auto-wait para la mayoría de las cosas automáticamente. Cuando llamas a locator.click(), Playwright espera a que el elemento sea visible, estable y no esté cubierto antes de actuar. El test solo se vuelve inestable cuando cortocircuitas ese comportamiento o cuando estás esperando algo que Playwright no sabe, como que termine una animación o desaparezca un spinner.
La corrección correcta: espera la condición específica que debe ser verdadera antes de actuar.
// Esperar a que desaparezca un spinner de carga antes de interactuar
await page.getByTestId('loading-spinner').waitFor({ state: 'hidden' });
await page.getByRole('button', { name: 'Guardar' }).click();
// Esperar una respuesta de red que puebla la página antes de asertar
await page.waitForResponse(
(resp) => resp.url().includes('/api/products') && resp.status() === 200
);
await expect(page.getByRole('table')).toBeVisible();
// Esperar a que un botón se habilite después de la validación del formulario
const saveButton = page.getByRole('button', { name: 'Guardar' });
await expect(saveButton).toBeEnabled();
await saveButton.click();Cada uno de estos espera una condición real en lugar de adivinar una duración. Playwright consulta la condición con un timeout configurable (30 segundos por defecto), así que el test es confiable y tan rápido como la app lo permite.
waitForTimeout más de una vez por semana, trátalo como una señal de alerta. Cada instancia es un test que será inestable bajo carga. Reemplaza cada uno con una espera basada en condición.Orden de tests y estado compartido
Los tests que pasan solos pero fallan cuando corre el suite completo casi siempre están dejando estado atrás. Un test crea un registro, el siguiente tropieza con él. Un test establece una cookie, el siguiente se comporta diferente por eso. Un test cambia la configuración de un usuario, y todos los tests posteriores para ese usuario están ahora en un estado inesperado.
// Este test deja un "Test Item" en la base de datos cada vez que corre
test('agregar ítem al inventario', async ({ page }) => {
await page.goto('/inventory');
await page.getByRole('button', { name: 'Agregar ítem' }).click();
await page.getByLabel('Nombre').fill('Test Item');
await page.getByRole('button', { name: 'Guardar' }).click();
await expect(page.getByText('Test Item')).toBeVisible();
// No se limpia nada
});
// Este test ahora falla si el anterior corrió primero: encuentra 2 ítems cuando esperaba 1
test('el inventario muestra un ítem', async ({ page }) => {
await page.goto('/inventory');
await expect(page.getByRole('row')).toHaveCount(2); // 1 fila de datos + 1 encabezado
});La corrección es el aislamiento. Cada test debe configurar su propio estado y limpiarlo después. Los fixtures de Playwright son la herramienta correcta para esto: ejecutan el setup antes de cada test y el teardown después, aunque el test falle.
import { test as base } from '@playwright/test';
type TestFixtures = {
testItem: { id: string; name: string };
};
const test = base.extend<TestFixtures>({
testItem: async ({ request }, use) => {
// Crear el ítem antes del test
const response = await request.post('/api/inventory', {
data: { name: `Test Item ${Date.now()}` },
});
const item = await response.json();
await use(item); // ejecutar el test
// Limpiar después, aunque el test haya fallado
await request.delete(`/api/inventory/${item.id}`);
},
});
test('el ítem del inventario muestra la página de detalle', async ({ page, testItem }) => {
await page.goto(`/inventory/${testItem.id}`);
await expect(page.getByRole('heading', { name: testItem.name })).toBeVisible();
});Los fixtures garantizan el teardown. Los bloques afterEach no corren si un test se cuelga durante el setup. Los fixtures sí. Esa es la diferencia entre aislamiento que funciona la mayoría de las veces y aislamiento que funciona siempre.
Inestabilidad dependiente del entorno
Algunos tests funcionan perfectamente en tu MacBook y fallan en cada otra ejecución en GitHub Actions. La diferencia de entorno es la que trabaja. Causas comunes:
Zona horaria.new Date() devuelve valores diferentes dependiendo de dónde corra el test. Un test que aserta sobre una cadena de fecha formateada fallará en CI si el runner está en UTC y tu máquina local está en UTC+3.
// Inestable: depende de la zona horaria local de la máquina
const today = new Date().toLocaleDateString('es-AR');
await expect(page.getByTestId('report-date')).toHaveText(today);
// Estable: fijar el locale y la zona horaria explícitamente
const today = new Date().toLocaleDateString('es-AR', { timeZone: 'UTC' });
await expect(page.getByTestId('report-date')).toHaveText(today);// Arriesgado en paralelo: dos workers pueden generar el mismo ID en el mismo milisegundo
const id = Date.now();
// Mejor: combinar timestamp con índice del worker
const id = `${Date.now()}-${workerInfo.workerIndex}`;playwright.config.ts y no dependas de breakpoints responsivos a menos que estés probándolos explícitamente.
// playwright.config.ts
export default defineConfig({
use: {
viewport: { width: 1280, height: 720 },
},
});Inestabilidad de selectores
Los nombres de clases dinámicos y los IDs generados son una trampa. Frameworks como Tailwind y CSS Modules generan nombres de clase que incluyen hashes de contenido. Las apps compiladas a veces generan IDs de elementos basados en el orden de build. Un selector que funcionó ayer se rompe después de una actualización de dependencia.
// Frágil: este nombre de clase es generado y cambiará
await page.locator('.tw-btn-primary-3af82').click();
// Frágil: ID generado, sin significado e inestable
await page.locator('#ember-423').click();
// También frágil: nth-child depende del orden
await page.locator('ul > li:nth-child(3)').click();Los locators semánticos de Playwright vinculan el selector a lo que hace el elemento en lugar de cómo está estilizado o estructurado. Son estables ante refactors porque reflejan la semántica orientada al usuario, no los detalles de implementación.
// Estable: role + nombre accesible
await page.getByRole('button', { name: 'Enviar' }).click();
// Estable: texto de la etiqueta
await page.getByLabel('Correo electrónico').fill('usuario@example.com');
// Estable: test ID (agregá data-testid al elemento si es necesario)
await page.getByTestId('submit-button').click();
// Estable: texto visible
await page.getByText('Pedido confirmado').waitFor();data-testid son un contrato deliberado entre el test y la aplicación. Sobreviven a refactors de CSS, cambios de layout y actualizaciones de framework. Si tu app no los tiene todavía, empieza a agregarlos a los elementos interactivos de alto valor.Cuando tienes que escribir un selector CSS o XPath, acótalo bien y ancla a un elemento padre estable:
// Acotado a una sección nombrada, no a toda la página
const orderSummary = page.getByTestId('order-summary');
await expect(orderSummary.getByRole('cell', { name: 'Total' })).toBeVisible();Tests dependientes de la red
Los tests que golpean servicios externos reales son intrínsecamente inestables. Una API de terceros puede ser lenta, estar limitada por rate o temporalmente no disponible. Un test que llama a la API real de Stripe en CI no está testeando tu código. Está testeando si Stripe está disponible.
El patrón a reconocer: cualquier test que hace una llamada HTTP real a algo fuera de tu control es un test inestable esperando a ocurrir.
Para APIs externas, mockea a nivel de red:
test('el checkout se completa con confirmación de pago', async ({ page }) => {
// Interceptar la llamada a la API de Stripe y devolver una respuesta controlada
await page.route('**/api/stripe/charge', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
id: 'ch_test_123',
status: 'succeeded',
amount: 4999,
}),
});
});
await page.goto('/checkout');
await page.getByLabel('Número de tarjeta').fill('4242 4242 4242 4242');
await page.getByRole('button', { name: 'Pagar ahora' }).click();
await expect(page.getByText('Pago confirmado')).toBeVisible();
});Para endpoints internos lentos, usa page.waitForResponse con un timeout generoso en lugar de esperar que la respuesta llegue dentro del timeout de acción por defecto:
test('el reporte grande se genera correctamente', async ({ page }) => {
await page.goto('/reports');
await page.getByRole('button', { name: 'Generar reporte' }).click();
// Esperar hasta 60 segundos para este endpoint lento específico
await page.waitForResponse(
(resp) => resp.url().includes('/api/reports/generate') && resp.status() === 200,
{ timeout: 60_000 }
);
await expect(page.getByRole('link', { name: 'Descargar reporte' })).toBeVisible();
});La trampa de los reintentos
Playwright soporta reintentos automáticos y son genuinamente útiles, pero también son la herramienta más frecuentemente mal usada en el toolkit de tests inestables.
// playwright.config.ts
export default defineConfig({
retries: process.env.CI ? 2 : 0,
});Esta configuración es razonable como última línea de defensa contra inestabilidad genuina de infraestructura: un hiccup momentáneo de red en CI, un runner de CI que ocasionalmente falla al iniciar un navegador. No es una corrección para tests que tienen problemas reales.
El problema con los reintentos: un test que pasa al tercer intento sigue consumiendo el tiempo de los dos primeros fallos. Con retries: 2, un suite que tarda 10 minutos en ejecuciones limpias puede tardar 25-30 minutos cuando múltiples tests son inestables. Ocultaste los fallos mientras el pipeline empeoró.
Los reintentos son aceptables cuando la inestabilidad es demostrablemente de infraestructura (runners de CI, fallos de inicio del navegador, timeouts de red a tus propios servicios en el mismo datacenter), cuando investigaste y confirmaste que no hay problema en el código del test, y cuando los tratas como algo temporal mientras avanza una corrección más profunda.
Los reintentos son dañinos cuando son la primera respuesta ante un nuevo test inestable, cuando tapan problemas de timing o aislamiento en el código del test, o cuando el número de reintentos está aumentando con el tiempo a medida que el suite empeora.
// Mal: ocultando un problema real de timing
export default defineConfig({
retries: 5, // El test seguía fallando, así que agregamos más reintentos
});
// Bien: reintentos como red de seguridad con un límite bajo y fijo
export default defineConfig({
retries: process.env.CI ? 1 : 0,
// Los problemas reales de timing y aislamiento se corrigen en el código del test
});retries con el tiempo en lugar de disminuirlo, tu problema de tests inestables está empeorando, no mejorando. El contador de reintentos actuales es una métrica de salud. Debe tender hacia cero.Investigación sistemática: cómo diagnosticar un test inestable
Cuando un test empieza a fallar intermitentemente, trabaja en esta secuencia en lugar de adivinar y ajustar.
Paso 1: Reprodúcelo de forma determinística. Ejecuta el test 20 veces seguidas:npx playwright test tests/checkout.spec.ts --repeat-each=20Cuenta los fallos. Un test que falla 1 de 20 veces es levemente inestable. Un test que falla 15 de 20 tiene un problema real. Esto también te dice cuánto vale la pena el esfuerzo de la corrección: una tasa de fallo del 5% en un suite que corre 50 veces al día sigue golpeándote 2-3 veces por día.
Paso 2: Aíslalo. Ejecuta solo ese archivo de test. Si pasa de forma confiable en aislamiento pero falla en el suite completo, el problema es contaminación de otro test que corrió antes.# Ejecutar en aislamiento
npx playwright test tests/checkout.spec.ts
# Ejecutar en el orden del suite para reproducir la contaminación
npx playwright test --workers=1// playwright.config.ts
export default defineConfig({
use: {
trace: 'on-first-retry',
},
retries: 1, // reintentar una vez para activar la captura del trace
});npx playwright test tests/checkout.spec.ts
npx playwright show-reportEl trace viewer muestra una línea de tiempo con capturas de pantalla de antes/después para cada acción, todas las solicitudes de red y los logs de consola. En la mayoría de los casos, el punto de fallo en el trace revela de inmediato si el problema es de timing, un elemento faltante o una respuesta de red inesperada.
Paso 4: Ejecuta con interfaz visible y cámara lenta. Si el trace no es concluyente, mira correr el test:npx playwright test tests/checkout.spec.ts --headed --slow-mo=500La cámara lenta agrega una pausa de 500ms entre acciones. Lo que parece instantáneo en una ejecución normal se vuelve visible, y a menudo verás el momento exacto en que la UI no está lista para la siguiente interacción.
Paso 5: Revisa qué corre antes. Si el test de aislamiento reveló contaminación, encuentra el test anterior:# Ejecutar con un solo worker para obtener un orden determinístico, luego verificar cuál corrió antes del que falla
npx playwright test --workers=1 --reporter=listBusca tests en el archivo anterior que crean registros, establecen cookies o modifican el estado de la aplicación sin limpieza.
Paso 6: Aplica la corrección correcta. Según lo que encontraste:- Problema de timing: reemplaza
waitForTimeoutcon una espera basada en condición - Contaminación entre tests: agrega limpieza en
afterEacho convierte setup/teardown a fixtures - Inestabilidad de selectores: cambia a
getByRole,getByLabelogetByTestId - Dependencia de red: mockea la llamada externa con
page.route - Diferencia de entorno: fija la zona horaria, el viewport y cualquier valor que varíe por máquina
La mayoría de los tests inestables caen en el paso 2 (aislamiento) o el paso 3 (trace viewer). La investigación raramente necesita llegar al paso 6.
FAQ
¿Cómo sé si un test es inestable o si realmente detectó un bug?Ejecútalo 10 veces en el mismo commit sin cambios de código. Si falla 1 a 3 veces de 10, es inestable. Si falla de forma consistente (7 o más de 10), probablemente detectó una regresión real. La distinción importa porque los tests inestables necesitan investigación mientras los fallos consistentes necesitan una corrección de bug.
Mi test solo falla en CI, no localmente. ¿Qué es diferente?Los runners de CI son típicamente más lentos, headless y están en una zona horaria diferente. Las causas más comunes específicas de CI son problemas de timing que el hardware local enmascara (la página carga suficientemente rápido localmente que la race condition nunca se activa), diferencias de renderizado headless para animaciones, y discrepancias de zona horaria en aserciones de fechas. Ejecuta localmente con --slow-mo=500 para simular una máquina más lenta, y revisa cualquier formateo de fecha en busca de suposiciones sobre zona horaria.
test.skip o test.fixme para un test inestable conocido?
test.skip lo excluye completamente. test.fixme lo marca como esperado que falle: el test igual corre, se espera que falle, y se convierte en una alerta visible si empieza a pasar (lo que podría significar que el problema subyacente cambió). Para un test genuinamente inestable sin corrección inmediata, test.skip con un comentario explicando por qué y un link al issue de seguimiento es la mejor opción. Un test.fixme sin explicación es solo confusión esperando a ocurrir.
Agregué un data-testid pero el test sigue siendo inestable. ¿Qué más puedo verificar?
Un selector estable no garantiza un test estable. Después de corregir el selector, verifica si el elemento está siendo accionado antes de estar listo (timing), si hay estado en conflicto de otro test (aislamiento), y si el test pasa en aislamiento pero falla en el suite (contaminación). La estabilidad del selector y el aislamiento del test son problemas separados.
Tenemos 40 tests inestables. ¿Por dónde empezamos?Ordena por tasa de fallo, no por lo molestos que son. Corrige primero los tests que fallan con más frecuencia: son los que más degradan la confiabilidad de CI. A medida que los corriges, emergerán patrones: si 15 de ellos comparten la misma causa raíz (digamos, un spinner que necesita desaparecer antes de las interacciones), un solo patrón de corrección se aplica a todos.
→ See also: Depurando Tests Inestables: Una Guía Práctica | Estrategias de Espera en Playwright: Sin Más sleep() | Aislamiento de Tests: Por qué Cada Test de Playwright Debe ser sin Estado