Playwright crea un BrowserContext fresco para cada test de forma automática, así que el estado del navegador (cookies, localStorage, sesión) ya está aislado. El estado de la aplicación no lo está: un let testUserId a nivel de módulo escrito por un test y leído por el siguiente se romperá en el momento en que otro worker paralelo ejecute esos tests fuera de orden. Este artículo cubre los patrones de fallo responsables de la mayoría de los bugs de aislamiento, el enfoque de fixture createUser que hace la limpieza incondicional, cómo storageState aísla la autenticación sin compartir una sesión activa, y la comparación entre --workers=1 y --workers=4 que saca a la luz el estado compartido oculto.
Qué significa realmente el aislamiento de tests
Aislamiento significa que un test no asume nada sobre el mundo antes de ejecutarse y no deja ningún rastro después de terminar. Cada test aprovisiona lo que necesita, hace su trabajo, y el entorno después de que ese test termina es idéntico al entorno antes de que empezara.
Esa definición parece obvia hasta que ves qué cubre el "estado" en un proyecto real. Hay estado del navegador (cookies, localStorage, datos de sesión), estado de la aplicación (registros de base de datos, cuentas de usuario, feature flags), y estado del código de test (variables a nivel de módulo, fixtures compartidos con efectos secundarios). Cualquiera de estos puede filtrarse entre tests.
Los fixtures page y context de Playwright ya manejan el aislamiento del estado del navegador por ti. Cada test obtiene automáticamente un BrowserContext fresco: una sesión limpia sin cookies, sin localStorage, sin nada que venga de otro test. No es una función que tienes que habilitar; es el comportamiento por defecto. Si usas el fixture page, ya estás aislado a nivel del navegador.
// Cada test obtiene un contexto de navegador completamente fresco. Esto es automático.
test('el usuario anónimo ve el botón de login', async ({ page }) => {
await page.goto('/dashboard');
// Sin cookies, sin sesión. Realmente fresco.
await expect(page.getByRole('link', { name: 'Log in' })).toBeVisible();
});
test('también anónimo, el test anterior no dejó rastros', async ({ page }) => {
await page.goto('/dashboard');
// Mismo estado limpio, independientemente de lo que corrió antes
await expect(page.getByRole('link', { name: 'Log in' })).toBeVisible();
});La parte difícil es el estado de la aplicación. Playwright no puede aislar tu base de datos por ti. Eso es tu trabajo.
Fallos clásicos de aislamiento: patrones que vas a reconocer
El fallo de aislamiento más común se ve así. Un archivo de test tiene un test de setup que crea datos, un puñado de tests que usan esos datos, y un test de teardown que los elimina. Alguien lo escribió así para evitar repetir el código de creación.
// tests/user-profile.spec.ts
import { test, expect } from '@playwright/test';
// Este es el estado compartido, la raíz del problema
let testUserId: number;
test('setup: crear usuario de prueba', async ({ request }) => {
const response = await request.post('/api/users', {
data: { name: 'Test User', email: 'testuser@example.com' }
});
testUserId = (await response.json()).id;
});
test('puede ver el perfil del usuario', async ({ page }) => {
await page.goto(`/users/${testUserId}`);
await expect(page.getByRole('heading', { name: 'Test User' })).toBeVisible();
});
test('puede editar el nombre del usuario', async ({ page }) => {
await page.goto(`/users/${testUserId}/edit`);
// ...
});
test('teardown: eliminar usuario de prueba', async ({ request }) => {
await request.delete(`/api/users/${testUserId}`);
});Funciona perfectamente cuando los tests se ejecutan secuencialmente en el orden del archivo. Se rompe de cuatro formas diferentes una vez que cambian las condiciones: si habilitas fullyParallel, si el test de otro archivo borra tu usuario de prueba por razones no relacionadas, si el test de setup falla y deja testUserId como undefined para todos los tests siguientes, o si alguien agrega este archivo a un split de --shard y el setup y el teardown terminan en máquinas diferentes.
El segundo fallo clásico es la colisión de dirección de email. Un test crea un usuario con email: 'alice@test.com'. El test pasa. La próxima vez que corre, el usuario ya existe porque el teardown de la ejecución anterior falló (un crash del navegador, un timeout de CI, un error en el test que salteó el afterAll). Ahora tienes un error 409 Conflict que parece un bug en tu formulario de registro.
// MAL: el email hardcodeado va a generar conflictos al volver a ejecutar
test('registrar nuevo usuario', async ({ page }) => {
await page.goto('/register');
await page.getByLabel('Email').fill('alice@test.com');
await page.getByLabel('Password').fill('Password123!');
await page.getByRole('button', { name: 'Create account' }).click();
await expect(page.getByText('Welcome, alice')).toBeVisible();
});
// BIEN: email único por ejecución, sin colisión posible
test('registrar nuevo usuario', async ({ page }) => {
const email = `alice-${Date.now()}@test.com`;
await page.goto('/register');
await page.getByLabel('Email').fill(email);
await page.getByLabel('Password').fill('Password123!');
await page.getByRole('button', { name: 'Create account' }).click();
await expect(page.getByText('Welcome, alice')).toBeVisible();
});Date.now() es la estrategia de unicidad más simple. Para IDs más legibles puedes combinarlo con un sufijo aleatorio: ` alice-${Date.now()}-${Math.random().toString(36).slice(2, 6)}@test.com . El formato exacto no importa siempre que no pueda colisionar.
Aislamiento de datos: usar la API para ser dueño del mundo de tu test
El modelo correcto de aislamiento es: cada test crea todo lo que necesita, vía la API, al inicio del test, y lo elimina al final a través de
afterEach o un fixture de limpieza. Ningún test depende de que otro test haya creado algo.
import { test, expect } from '@playwright/test';
test('el admin puede desactivar una cuenta de usuario', async ({ page, request }) => {
// Crear los datos que necesita este test, completamente de su propiedad
const createResponse = await request.post('/api/users', {
data: {
name: 'Temporary User',
email: `temp-${Date.now()}@example.com`,
role: 'member'
}
});
expect(createResponse.ok()).toBeTruthy();
const { id: userId } = await createResponse.json();
try {
// El test en sí
await page.goto(`/admin/users/${userId}`);
await page.getByRole('button', { name: 'Deactivate account' }).click();
await page.getByRole('button', { name: 'Confirm' }).click();
await expect(page.getByTestId('account-status')).toHaveText('Inactive');
} finally {
// La limpieza corre incluso si el test falla
await request.delete(`/api/users/${userId}`);
}
});
El patrón
try/finally importa. Si pones la limpieza al final del test sin finally, un fallo del test saltará la limpieza y dejará datos huérfanos en la base de datos. A lo largo de docenas de ejecuciones de tests, esos registros huérfanos se acumulan y causan fallos impredecibles en otros lugares.
Una forma más limpia de manejar esto en Playwright es a través de un fixture personalizado que envuelve el ciclo de vida automáticamente:
// fixtures/api-fixtures.ts
import { test as base, expect } from '@playwright/test';
type ApiFixtures = {
createUser: (overrides?: Partial<{ name: string; email: string; role: string }>) => Promise<{ id: number; email: string }>;
};
export const test = base.extend<ApiFixtures>({
createUser: async ({ request }, use) => {
const createdIds: number[] = [];
const factory = async (overrides = {}) => {
const response = await request.post('/api/users', {
data: {
name: 'Test User',
email: `test-${Date.now()}-${Math.random().toString(36).slice(2, 6)}@example.com`,
role: 'member',
...overrides
}
});
const user = await response.json();
createdIds.push(user.id);
return user;
};
await use(factory);
// Limpia todos los usuarios creados por este test, corre después de cada test automáticamente
for (const id of createdIds) {
await request.delete(`/api/users/${id}`);
}
}
});
Ahora cualquier test puede usar
createUser y la limpieza está garantizada:
import { test } from '../fixtures/api-fixtures';
import { expect } from '@playwright/test';
test('el editor puede actualizar el perfil del usuario', async ({ page, createUser }) => {
const user = await createUser({ name: 'Jane', role: 'editor' });
await page.goto(`/users/${user.id}`);
await page.getByRole('button', { name: 'Edit profile' }).click();
await page.getByLabel('Display name').fill('Jane Updated');
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.getByRole('heading', { name: 'Jane Updated' })).toBeVisible();
});
El test es legible, la limpieza es invisible y automática, y crear múltiples usuarios en un test es solo llamar a
createUser dos veces.
Construye tus factories de datos como fixtures desde el día uno. Incorporarlos en una suite existente es mucho más difícil que empezar con ellos. Un conjunto de fixtures createUser, createOrder y createProduct cubre el 80% de las necesidades típicas de datos de prueba de e-commerce.
storageState y aislamiento de auth: hacer login una vez, mantenerse aislado
El patrón de fixture
createUser maneja el aislamiento de datos. La autenticación es una preocupación separada. No quieres que cada test haga un flujo completo de login por navegador, eso es lento. Pero tampoco quieres que los tests compartan una sesión activa del navegador, porque un test que hace logout o cambia la configuración de la cuenta romperá cada test concurrente.
El patrón correcto: hacer login una vez por worker (no una vez por test, no una vez globalmente), guardar el
storageState autenticado en un archivo, y cargarlo al inicio de cada test desde ese archivo. Cada test obtiene entonces su propio contexto de navegador que arranca en un estado autenticado, pero los contextos no comparten ninguna sesión activa.
// tests/auth.setup.ts se ejecuta una vez por worker antes de la suite de tests
import { test as setup, expect } from '@playwright/test';
import path from 'path';
const authFile = path.join(__dirname, '../.auth/user.json');
setup('autenticar', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill(process.env.TEST_USER_EMAIL!);
await page.getByLabel('Password').fill(process.env.TEST_USER_PASSWORD!);
await page.getByRole('button', { name: 'Sign in' }).click();
// Esperar hasta pasar la página de login
await page.waitForURL('/dashboard');
await expect(page.getByRole('navigation')).toBeVisible();
// Guardar el estado de autenticación
await page.context().storageState({ path: authFile });
});
// playwright.config.ts
import { defineConfig } from '@playwright/test';
import path from 'path';
export default defineConfig({
projects: [
{
name: 'setup',
testMatch: '**/auth.setup.ts',
},
{
name: 'authenticated tests',
dependencies: ['setup'],
use: {
storageState: path.join(__dirname, '.auth/user.json'),
},
testMatch: '**/*.spec.ts',
},
],
});
Con este setup, cada test arranca en un estado autenticado sin pasar por el flujo de login. Como
storageState carga desde un archivo en un BrowserContext completamente nuevo, las sesiones están completamente aisladas. Lo que el test A hace con su sesión no tiene ningún efecto en la sesión del test B.
Si tu app tiene múltiples roles de usuario (admin, editor, viewer), crea un archivo storageState separado para cada rol durante el setup. Tus fixtures pueden entonces cargar el estado correcto según lo que necesite un test. Esto es mucho más rápido que hacer login con credenciales diferentes dentro de tests individuales.
El aislamiento es lo que hace seguro el paralelismo
Hay una relación directa entre el aislamiento de tests y la ejecución paralela. No puedes ejecutar tests en paralelo de forma segura si comparten estado, y no puedes desbloquear el beneficio completo del paralelismo sin un aislamiento adecuado. Son las dos caras de la misma moneda.
Cuando Playwright ejecuta tests en paralelo, diferentes workers corren simultáneamente. No hay garantía de orden entre workers. Si el test A en el worker 1 crea un usuario con
email: 'admin@test.com' y el test B en el worker 2 también intenta crear un usuario con email: 'admin@test.com', uno de ellos fallará con una violación de unicidad. ¿Cuál? Depende de una race condition de timing. Esa es la definición de un test flaky.
// playwright.config.ts. Esto solo funciona si los tests están verdaderamente aislados.
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './tests',
fullyParallel: true, // Cada test en cada archivo corre concurrentemente
workers: process.env.CI ? 4 : '50%',
retries: process.env.CI ? 1 : 0,
use: {
baseURL: process.env.BASE_URL || 'http://localhost:3000',
trace: 'on-first-retry',
},
});
fullyParallel: true es el cambio de configuración de mayor valor que puedes hacer a una suite madura. Una suite de 150 tests a 3 segundos cada uno tarda 7,5 minutos secuencialmente. Con 4 workers y aislamiento adecuado, eso baja a unos 2 minutos. La restricción no es la capacidad de Playwright. Es si tus tests están suficientemente aislados para ejecutarse sin interferirse entre sí.
No agregues reintentos para enmascarar fallos de aislamiento. Los reintentos son una herramienta legítima para manejar flakiness genuina (timeouts de red, interrupciones de servicios de terceros). Pero si un test falla porque corrió al mismo tiempo que otro y se pisaron los datos de los dos, el reintento probablemente tendrá éxito, y nunca sabrás que tienes un problema de aislamiento hasta que se agrave en algo peor. Arregla el aislamiento, luego agrega reintentos si es necesario.
Los problemas de estado compartido escalan con la cantidad de workers. Un worker: los tests se ejecutan en un orden donde el problema de estado no se dispara. Dos workers: ves fallos ocasionales. Ocho workers: la suite falla de forma confiable. Si aumentar los workers hace tu suite más flaky, esa es una señal casi segura de que tienes estado compartido en algún lugar.
Encontrar fugas de aislamiento en una suite existente
Si heredas una suite y sospechas problemas de aislamiento, estos son los pasos concretos para encontrarlos.
Paso 1: Ejecutar con
--workers=1 y comparar
Si la suite completa pasa con un worker y falla con dos o más, tienes un problema de aislamiento. Los tests que fallan son las víctimas; los tests que los rompen son más difíciles de encontrar.
# ¿La suite pasa secuencialmente?
npx playwright test --workers=1
# ¿Sigue pasando con paralelismo?
npx playwright test --workers=4
Paso 2: Aleatorizar el orden
Algunos bugs de aislamiento solo aparecen cuando el test A corre antes que el test B, pero siempre corren en el mismo orden, así que nunca ves el fallo. Playwright no tiene aleatorización de orden integrada, pero puedes dividir los tests manualmente y ejecutarlos en diferentes secuencias para detectar dependencias de orden.
Paso 3: Buscar estos patrones de código específicamente
Las variables a nivel de módulo a las que los tests escriben son el culpable número uno.
// Buscá estos patrones en tus archivos de test. Cada uno es una posible fuga de aislamiento.
// Variable a nivel de módulo que se asigna dentro de un test
let userId: number;
let authToken: string;
let createdRecord: any;
// test.beforeAll creando datos usados por múltiples tests
test.beforeAll(async ({ request }) => {
// Si algo aquí crea estado mutable compartido, tenés una fuga
});
// Direcciones de email hardcodeadas, nombres de usuario, o cualquier identificador único fijo
data: { email: 'fixed@test.com' }
data: { username: 'testadmin' }
data: { id: 1 }
Paso 4: Verificar los caminos de limpieza
Busca
test.afterAll y verifica que cada llamada de limpieza también esté cubierta por afterEach o el teardown de un fixture. afterAll corre una vez por suite. Si un test falla a mitad de camino, afterAll igual corre, pero la limpieza puede estar operando sobre estado parcial.
Paso 5: Agregar el título del test a los registros de la base de datos
Durante el desarrollo, nombra tus datos de prueba con el test que los crea:
const user = await createUser({
name: `Test user for: ${test.info().title}`,
email: `test-${Date.now()}@example.com`
});
Cuando miras tu base de datos de test y ves diez filas con nombre "Test user for: admin can deactivate a user account", sabes inmediatamente que son fallos de limpieza huérfanos de ese test, y qué test investigar.
Cómo aplicar esto el lunes por la mañana
Si tienes una suite existente con problemas de aislamiento, no intentes arreglar todo a la vez. Un enfoque priorizado que entrega valor inmediatamente:
Primeros 30 minutos
Audita en busca de variables a nivel de módulo en los archivos de test. Cualquier
let o var declarado a nivel de módulo que se escriba dentro de un bloque test() es un problema. Mueve esas declaraciones dentro del test, usa beforeEach para crear estado fresco, y verifica que los tests sigan pasando.
La hora siguiente
Reemplaza todos los identificadores únicos hardcodeados en los datos de prueba. Direcciones de email, usernames, números de teléfono, cualquier campo con restricción de unicidad: hazlos dinámicos con
Date.now() o una estrategia similar. Esto previene la clase de fallos "el test falla en la segunda ejecución".
Esta semana
Construye un fixture
createUser (o la entidad más común de tu proyecto). Pon la lógica de creación y eliminación en un lugar, hazla automática, y migra los cinco archivos de test más problemáticos para que lo usen. Verás inmediatamente cuánto más simples se vuelven esos tests.
Este sprint
Habilita
fullyParallel: true` con dos workers y mira la cantidad de fallos. Cada nuevo fallo es una fuga de aislamiento que estaba oculta. Corrígela cada una a medida que aparece. Una vez que la suite está limpia con dos workers, avanza a cuatro. Sigue hasta el límite de la máquina o hasta que tu suite termine en menos de dos minutos.
→ See also: Fixtures de Playwright Explicados: De los Integrados a los Personalizados | Depurando Tests Inestables: Una Guía Práctica | Ejecución Paralela en Playwright: Workers, Fragmentos y Fragmentación para Mayor Velocidad | Tests Inestables: Por qué Ocurren y Cómo Eliminarlos | Gestión de Datos de Test en Playwright: Estrategias y Patrones