Un test.beforeAll que crea un usuario y lo comparte entre múltiples tests es una race condition: cuando el test de eliminación corre primero en un worker paralelo, los tests de vista y edición fallan con un 404. Cada test necesita sus propios datos, creados antes de que corra y eliminados después. Este artículo cubre cinco patrones para gestionar datos de prueba en Playwright, desde un archivo de constantes para datos de referencia estables hasta fixtures de API que crean y eliminan registros por test, incluyendo cuándo la siembra de base de datos es la decisión correcta y cómo manejar credenciales específicas por entorno sin commitearlas al repositorio.
El problema de los datos hardcodeados
// Frágil — se rompe cuando el usuario es eliminado o cambia la contraseña
test('el usuario puede iniciar sesión', async ({ page }) => {
await page.fill('[data-testid="email"]', 'admin@test.com');
await page.fill('[data-testid="password"]', 'AdminPass1');
});Problemas: el test depende de que existan datos específicos en la base de datos, múltiples tests compartiendo una cuenta generan conflictos en ejecuciones paralelas, y el test solo puede ejecutarse en un entorno a menos que recuerdes cambiar el email.
Patrón 1: archivo de constantes
El paso más simple desde los valores hardcodeados: centralizar todos los datos de prueba en un archivo.
// data/users.ts
export const TEST_USERS = {
admin: {
email: 'admin@test.com',
password: 'AdminPass1',
name: 'Test Admin',
role: 'admin' as const,
},
member: {
email: 'member@test.com',
password: 'MemberPass1',
name: 'Test Member',
role: 'member' as const,
},
viewer: {
email: 'viewer@test.com',
password: 'ViewerPass1',
name: 'Test Viewer',
role: 'viewer' as const,
},
} as const;
export const TEST_PRODUCTS = {
basic: { id: 1, name: 'Basic Plan', price: 9.99 },
pro: { id: 2, name: 'Pro Plan', price: 29.99 },
enterprise: { id: 3, name: 'Enterprise', price: 99.99 },
} as const;// En los tests
import { TEST_USERS, TEST_PRODUCTS } from '../data/users';
test('el admin puede acceder al dashboard', async ({ loginPage }) => {
await loginPage.login(TEST_USERS.admin.email, TEST_USERS.admin.password);
});Mejor que los valores hardcodeados dispersos, pero sigue dependiendo de que esos usuarios específicos existan.
Patrón 2: funciones factory
Las funciones factory generan datos de prueba únicos por test:
// data/factories.ts
let counter = 0;
export function generateUser(overrides: Partial<User> = {}): CreateUserRequest {
counter++;
return {
email: `test_user_${Date.now()}_${counter}@example.com`,
password: 'ValidPass1!',
name: `Test User ${counter}`,
role: 'member',
...overrides,
};
}
export function generateProduct(overrides: Partial<Product> = {}): CreateProductRequest {
return {
name: `Test Product ${Date.now()}`,
price: Math.floor(Math.random() * 100) + 10,
category: 'electronics',
description: 'A test product for automated testing',
inStock: true,
...overrides,
};
}// En los tests
import { generateUser } from '../data/factories';
test('crear un nuevo usuario', async ({ request }) => {
const userData = generateUser({ role: 'admin' });
const response = await request.post('/api/users', {
data: userData,
});
expect(response.status()).toBe(201);
const created = await response.json();
expect(created.email).toBe(userData.email);
});Cada test obtiene un email único. Sin más colisiones.
Patrón 3: setup de API en fixtures
Crear datos frescos vía API antes de cada test y eliminarlos después:
// fixtures/index.ts
import { test as base } from '@playwright/test';
import { generateUser } from '../data/factories';
interface TestFixtures {
testUser: { id: number; email: string; password: string; token: string };
adminToken: string;
}
export const test = base.extend<TestFixtures>({
// Token de admin — compartido, scope de worker (creado una vez por worker)
adminToken: [async ({ request }, use) => {
const response = await request.post('/api/auth/login', {
data: { email: 'admin@test.com', password: 'AdminPass1' },
});
const { token } = await response.json();
await use(token);
}, { scope: 'worker' }],
// Usuario de test — único por test
testUser: async ({ request, adminToken }, use) => {
const userData = generateUser();
// CREAR: nuevo usuario antes del test
const createResp = await request.post('/api/users', {
data: userData,
headers: { Authorization: `Bearer ${adminToken}` },
});
const user = await createResp.json();
// Login para obtener el token
const loginResp = await request.post('/api/auth/login', {
data: { email: userData.email, password: userData.password },
});
const { token } = await loginResp.json();
// Entregar al test lo que necesita
await use({
id: user.id,
email: userData.email,
password: userData.password,
token
});
// TEARDOWN: eliminar después del test
await request.delete(`/api/users/${user.id}`, {
headers: { Authorization: `Bearer ${adminToken}` },
});
},
});// tests/profile.spec.ts
import { test, expect } from '../fixtures';
test('el usuario puede actualizar su perfil', async ({ page, testUser }) => {
// testUser es un usuario fresco, creado solo para este test
await page.goto(`/login`);
await page.fill('[data-testid="email"]', testUser.email);
await page.fill('[data-testid="password"]', testUser.password);
await page.click('[data-testid="submit"]');
await page.click('[data-testid="edit-profile"]');
await page.fill('[data-testid="name"]', 'Updated Name');
await page.click('[data-testid="save"]');
await expect(page.getByTestId('profile-name')).toHaveText('Updated Name');
// Después del test: el usuario se elimina automáticamente
});Patrón 4: siembra de base de datos
Para escenarios de datos complejos, sembrar la base de datos directamente:
// setup/seed.ts
import { chromium } from '@playwright/test';
async function seed() {
const response = await fetch('http://localhost:3000/api/seed', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Seed-Secret': process.env.SEED_SECRET || 'dev-seed-secret',
},
body: JSON.stringify({
users: [
{ email: 'admin@test.com', password: 'AdminPass1', role: 'admin' },
{ email: 'member@test.com', password: 'MemberPass1', role: 'member' },
],
products: [
{ name: 'Basic Plan', price: 9.99, category: 'subscription' },
{ name: 'Pro Plan', price: 29.99, category: 'subscription' },
],
}),
});
if (!response.ok) {
throw new Error(`Seed failed: ${response.status}`);
}
console.log('Database seeded successfully');
}
seed();Ejecutar antes de los tests: node setup/seed.ts && npx playwright test
Patrón 5: estado de auth guardado
Evita hacer login al inicio de cada test. Haz login una vez, guarda el estado del navegador, reutilízalo:
// auth.setup.ts
import { test as setup } from '@playwright/test';
import path from 'path';
const authFile = path.join(__dirname, '../playwright/.auth/user.json');
setup('autenticar', async ({ page }) => {
await page.goto('/login');
await page.fill('[data-testid="email"]', 'member@test.com');
await page.fill('[data-testid="password"]', 'MemberPass1');
await page.click('[data-testid="submit"]');
await page.waitForURL('/dashboard');
// Guardar el estado de almacenamiento (cookies, localStorage)
await page.context().storageState({ path: authFile });
});// playwright.config.ts
projects: [
{
name: 'setup',
testMatch: /auth\.setup\.ts/,
},
{
name: 'authenticated',
use: {
storageState: 'playwright/.auth/user.json', // Ya logueado
},
dependencies: ['setup'],
},
],Los tests en el proyecto authenticated saltan el flujo de login: arrancan con una sesión ya autenticada.
Gestionar datos para diferentes entornos
Usa variables de entorno para apuntar a los datos correctos:
// data/config.ts
export const ENV_USERS = {
local: {
admin: { email: 'admin@local.test', password: 'LocalAdmin1' },
},
staging: {
admin: { email: 'admin@staging.test', password: process.env.STAGING_ADMIN_PASS! },
},
production: {
// Usuario de solo lectura para smoke tests en producción
reader: { email: process.env.PROD_READER_EMAIL!, password: process.env.PROD_READER_PASS! },
},
};
const env = (process.env.TEST_ENV || 'local') as keyof typeof ENV_USERS;
export const USERS = ENV_USERS[env];TEST_ENV=staging npx playwright test --project=chromiumManejar el aislamiento de tests
Cuando los tests se ejecutan en paralelo, no deben compartir estado mutable.
Mal: estado compartido
let userId: number;
test.beforeAll(async ({ request }) => {
const user = await request.post('/api/users', { data: generateUser() });
userId = (await user.json()).id;
});
// Múltiples tests usan el mismo userId — ¡race conditions!
test('ver usuario', async ({ page }) => { await page.goto(`/users/${userId}`); });
test('editar usuario', async ({ page }) => { /* edita el mismo usuario */ });
test('eliminar usuario', async ({ page }) => { /* ¡lo elimina! */ });Bien: estado aislado
// Usar fixtures para que cada test obtenga su propio usuario
test('ver usuario', async ({ page, testUser }) => {
await page.goto(`/users/${testUser.id}`);
});
test('editar usuario', async ({ page, testUser }) => {
// Este testUser es diferente al de arriba
});Limpiar después de los tests
Siempre limpia los datos que crean tus tests:
test('crea un producto', async ({ request, adminToken }) => {
let productId: number;
try {
const response = await request.post('/api/products', {
data: { name: 'Test Product', price: 19.99 },
headers: { Authorization: `Bearer ${adminToken}` },
});
const product = await response.json();
productId = product.id;
expect(response.status()).toBe(201);
expect(product.name).toBe('Test Product');
} finally {
// Siempre se ejecuta, aunque el test falle
if (productId!) {
await request.delete(`/api/products/${productId}`, {
headers: { Authorization: `Bearer ${adminToken}` },
});
}
}
});Mejor aún: pon la limpieza en el teardown del fixture (después de await use(...)) para que siempre se ejecute.
Referencia de patrones
| Patrón | Ideal para |
|--------|-----------|
| Archivo de constantes | Datos de referencia estables (roles, categorías) |
| Funciones factory | Generar datos de prueba únicos |
| Setup/teardown de fixture API | Datos frescos y aislados por test |
| Siembra de base de datos | Estado inicial complejo antes de la suite |
| Estado de auth guardado | Saltarse el login en cada test |
El enfoque más sólido los combina: funciones factory para generar datos únicos, fixture de API para crearlos y eliminarlos por test, estado de auth guardado para la sesión autenticada base.
→ See also: Datos de Prueba Reutilizables: Factories, Fixtures y Faker.js en Playwright | Aislamiento de Tests: Por qué Cada Test de Playwright Debe ser sin Estado | Manejo de Autenticación en Playwright con storageState (Sin Iniciar Sesión en Cada Test) | Pruebas de API con el APIRequestContext de Playwright (Sin Postman)