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=chromium

Manejar 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ónIdeal para
Archivo de constantesDatos de referencia estables (roles, categorías)
Funciones factoryGenerar datos de prueba únicos
Setup/teardown de fixture APIDatos frescos y aislados por test
Siembra de base de datosEstado inicial complejo antes de la suite
Estado de auth guardadoSaltarse 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.

→ Ver también: 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)