Un test que falla aleatoriamente con código sin cambios le enseña al equipo a volver a ejecutar CI en lugar de investigar, y ese hábito es cómo los fallos reales pasan desapercibidos. Esta guía cubre las cinco causas raíz de la inestabilidad en Playwright: problemas de timing, contaminación entre tests, datos de test compartidos, dependencias de red e inestabilidad en el orden de los elementos, más un flujo de trabajo de depuración paso a paso usando el trace viewer para identificar con cuál te estás enfrentando.
Por qué los tests inestables son peores que no tener tests
Cuando un test falla de forma consistente, lo corriges. Cuando falla aleatoriamente, el equipo empieza a ignorar los builds en rojo. "Probablemente es solo el test inestable del login" se convierte en la respuesta estándar ante los fallos de CI. Con el tiempo un bug real se cuela porque nadie tomó en serio el build en rojo.
Los tests inestables erosionan la confianza en todo el suite. Por eso vale la pena el tiempo de corregirlos, incluso cuando el test en sí no es crítico.
Las causas más comunes
Los tests inestables casi siempre vienen de uno de cinco lugares.
Problemas de timing. La causa más común con diferencia. El test intenta interactuar con un elemento antes de que esté listo: antes de que aparezca, antes de que esté habilitado, antes de que termine una animación. El test pasa cuando la página carga rápido y falla cuando carga lento. Contaminación entre tests. Un test deja un estado que rompe el siguiente. Un registro creado, una cookie sobrante, un valor modificado en localStorage. Los tests que pasan solos pero fallan en el suite casi siempre son esto. Datos de test compartidos. Dos tests corren en paralelo y ambos intentan usar o modificar el mismo registro. Uno gana, el otro falla. Dependencias de red. Un test hace una llamada real a la API que ocasionalmente expira o devuelve datos inesperados. Inestabilidad en el orden de los elementos. Un test asume que los elementos aparecen en un orden específico (primera fila, segundo botón) pero el orden no está garantizado.Empezá con el trace viewer de Playwright
Antes de cambiar cualquier código, reproduce el fallo y captura un trace. El trace viewer es la herramienta de depuración más poderosa de Playwright: registra cada acción, solicitud de red y snapshot del DOM durante una ejecución de tests.
Habilita el tracing en playwright.config.ts:
export default defineConfig({
use: {
trace: 'on-first-retry', // captura el trace cuando un test falla y reintenta
},
retries: 1, // reintentar una vez para que se capture el trace
});Ejecuta los tests y abre el reporte:
npx playwright test
npx playwright show-reportHaz clic en un test fallido. La vista del trace muestra una línea de tiempo de cada acción con capturas de pantalla de antes y después. Puedes ver exactamente qué paso falló, cómo se veía la página en ese momento, y qué solicitudes de red estaban en vuelo.
Esto solo resuelve aproximadamente la mitad de las investigaciones de tests inestables sin necesidad de adivinar nada.
Corregir problemas de timing
Los problemas de timing se ven así en el output de errores:
Error: locator.click: Timeout 30000ms exceeded.
waiting for getByRole('button', { name: 'Submit' })O:
Error: expect(locator).toBeVisible()
Received: hiddenEl instinto es agregar una espera. La corrección incorrecta:
// Mal: adivinar cuánto tiempo esperar
await page.waitForTimeout(2000);
await page.getByRole('button', { name: 'Submit' }).click();Esto hace el test más lento y sigue siendo inestable. A veces 2 segundos no son suficientes.
La corrección correcta: espera la condición específica que necesita ser verdadera antes de la acción.
// Esperar a que desaparezca un indicador de carga
await page.getByTestId('loading-spinner').waitFor({ state: 'hidden' });
await page.getByRole('button', { name: 'Submit' }).click();
// Esperar a que un botón se habilite
await page.getByRole('button', { name: 'Submit' }).waitFor({ state: 'visible' });
await expect(page.getByRole('button', { name: 'Submit' })).toBeEnabled();
await page.getByRole('button', { name: 'Submit' }).click();
// Esperar a que termine una solicitud de red
await page.waitForResponse(resp =>
resp.url().includes('/api/items') && resp.status() === 200
);El auto-waiting integrado de Playwright maneja la mayoría de los casos automáticamente. Cuando el auto-waiting no es suficiente, espera la cosa específica, no una duración fija.
Corregir la contaminación entre tests
Si los tests pasan individualmente pero fallan cuando corren juntos, el problema casi siempre es estado que se filtra entre tests.
Busca estas fuentes de contaminación:
Almacenamiento del navegador. Si un test escribe enlocalStorage o sessionStorage y otro los lee, hay contaminación. Playwright crea un contexto de navegador nuevo para cada archivo de tests por defecto, pero los tests dentro del mismo archivo comparten contexto por defecto en algunas configuraciones.
// Limpiar el almacenamiento antes de cada test en el archivo
test.beforeEach(async ({ page }) => {
await page.goto('https://lab.becomeqa.com');
await page.evaluate(() => {
localStorage.clear();
sessionStorage.clear();
});
});test.afterEach(async ({ request }) => {
// Eliminar el registro de test creado durante el test
await request.delete('https://lab.becomeqa.com/api/items/test-item-id');
});--repeat-each=3 para ver si son estables cuando se repiten. Un test que falla en la segunda ejecución está filtrando estado. npx playwright test --repeat-each=3 tests/login.spec.tsCorregir conflictos de ejecución paralela
Playwright ejecuta tests en paralelo por defecto a través de múltiples workers. Si dos tests intentan modificar el mismo registro o usar la misma cuenta de usuario simultáneamente, entran en conflicto.
La corrección depende de la situación:
Usa datos de test únicos por test. En lugar de usar siempreadmin@becomeqa.com, genera un identificador único para cada ejecución de test:
const uniqueId = Date.now();
const testEmail = `test-${uniqueId}@example.com`;// Al inicio del archivo
test.describe.configure({ mode: 'serial' });Esto ejecuta todos los tests del archivo de forma secuencial, evitando conflictos.
Usa datos de test separados por worker. Playwright pasa unworkerIndex a los fixtures:
const workerEmail = `test-worker-${workerInfo.workerIndex}@example.com`;Usar los reintentos con cuidado
Playwright soporta reintentos automáticos para tests inestables:
// playwright.config.ts
export default defineConfig({
retries: process.env.CI ? 2 : 0,
});Los reintentos en CI ocultan problemas en lugar de corregirlos, pero son una herramienta práctica cuando tienes inestabilidad genuina de infraestructura (timeouts de red, variabilidad de las máquinas de CI) en lugar de bugs en el código de tus tests.
La regla: los reintentos son aceptables para la inestabilidad de infraestructura. No son aceptables como sustituto de corregir problemas reales de timing o aislamiento.
retries: 3 sin investigar por qué fallan los tests es cómo terminas con un suite que tarda 3 veces más en correr y sigue sin tener tests en los que confiar.Poner en cuarentena los tests persistentemente inestables
Si un test es inestable y no puedes corregirlo de inmediato, ponelo en cuarentena. No lo dejes en el suite principal fallando aleatoriamente.
test.skip('el flujo de checkout se completa con éxito', async ({ page }) => {
// Inestable por timeouts de la API de pagos — registrado en JIRA-1234
// TODO: mockear la respuesta de la API de pagos en lugar de usar la real
});Un test saltado con un comentario es infinitamente mejor que un test inestable que entrena al equipo a ignorar los builds en rojo.
Un flujo de trabajo de depuración sistemático
Cuando encuentras un test inestable, trabaja en este orden:
1. Captura el trace: corre con retries: 1 y trace: 'on-first-retry', mira el punto exacto de fallo
2. Ejecútalo 10 veces: npx playwright test --repeat-each=10 tests/tu.spec.ts, ve con qué frecuencia falla
3. Ejecútalo en aislamiento: npx playwright test tests/tu.spec.ts, si pasa solo, es contaminación entre tests
4. Ejecútalo con interfaz visible: npx playwright test --headed --slow-mo=500, observa cómo falla en cámara lenta
5. Revisa la pestaña de red en el trace: ¿hay solicitudes que fallan o expiran?
6. Agrega esperas explícitas para la condición específica que necesita ser verdadera antes de la acción que falla
7. Revisa el estado compartido: ¿qué hace el test anterior?
La mayoría de los tests inestables se resuelven en el paso 3 o el paso 6.
FAQ
¿Cómo sé si un test es genuinamente inestable o detectó un bug real?Ejecútalo 10 veces en el mismo commit. Si falla 2 de 10, es inestable. Si falla 10 de 10, detectó un bug.
Mi test solo falla en CI, nunca localmente. ¿Por qué?Las máquinas de CI son más lentas y tienen menos memoria. Los problemas de timing que son invisibles localmente aparecen bajo carga. Ejecuta localmente con --slow-mo=500 para simular una máquina más lenta. También verifica si CI usa una URL base o variables de entorno diferentes.
test.fixme o test.skip para tests inestables conocidos?
test.skip excluye el test completamente. test.fixme lo marca como roto pero igual lo ejecuta. Se espera que el test falle, y se convierte en un fallo si empieza a pasar (lo que te alerta para que lo revises). Para tests inestables conocidos que necesitan corrección, test.fixme es la opción más honesta.
El trace muestra que el elemento era visible pero el clic igual falló. ¿Qué pasó?
El elemento era visible pero probablemente estaba cubierto por otro elemento (un modal, un tooltip, un encabezado fijo). Revisa isVisible() vs isInViewport(). Puede que necesites hacer scroll hasta el elemento primero: await locator.scrollIntoViewIfNeeded().