Un test que hardcodea alice@example.com genera un conflicto de unicidad en la segunda ejecución si la limpieza del test anterior falló. Faker.js resuelve esto con datos generados, pero llamar a faker.internet.email() dos veces (una para llenar el formulario, otra para verificar que aparece) produce dos valores diferentes: genera una vez, almacénalo en una variable, úsalo en todas partes. Este artículo cubre el patrón de función factory que genera todos los campos de una vez y acepta overrides para lo que el test realmente necesita, emails con UUID embebido que garantizan sin colisiones, siembra de datos por API que bypasea la UI de registro, y fixtures de Playwright que ejecutan la limpieza de forma incondicional tanto si el test pasó como si falló.
El problema de los datos hardcodeados
Los datos hardcodeados se rompen de tres formas, y cada una agrava las otras.
La primera es los conflictos de unicidad. La mayoría de las aplicaciones reales fuerzan emails únicos, usernames únicos, números de orden únicos. Si tu test usa alice@example.com y se ejecutó ayer dejando esa fila en la base de datos, la ejecución de hoy falla en el punto de creación, no porque la funcionalidad esté rota, sino porque nunca ocurrió la limpieza.
La segunda es el estado compartido. Cinco tests que operan sobre item-id-42 son cinco tests esperando interferirse entre sí. En ejecuciones paralelas, las colisiones ocurren constantemente. En ejecuciones secuenciales, ocurren con suficiente frecuencia para parecer aleatorias.
La tercera es la fragilidad de los datos en sí. Una fecha hardcodeada 2024-01-15 que estaba "en el futuro" cuando escribiste el test ahora tiene dos años en el pasado. Un status "pending" que tenía sentido para un flujo fue renombrado a "awaiting_approval". Cada valor hardcodeado es una carga de mantenimiento futura.
// Lo que querés dejar de hacer
test('el usuario puede actualizar su perfil', async ({ page }) => {
// Estos eventualmente van a generar conflictos o quedar desactualizados
await loginAs(page, 'alice@example.com', 'password123');
await page.goto('/profile');
await page.getByLabel('Display name').fill('Alice Smith');
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.getByText('Profile updated')).toBeVisible();
});La solución no es ser más cuidadoso con los valores hardcodeados. Es dejar de hardcodearlos.
Faker.js: instalación y conceptos básicos
Faker.js es una librería para generar datos falsos realistas. Nombres, emails, direcciones, UUIDs, fechas, números de teléfono, nombres de productos: todos aleatorios, todos plausibles.
npm install --save-dev @faker-js/fakerLa API agrupa los generadores por categoría. Estos son los que más usarás en suites de tests:
import { faker } from '@faker-js/faker';
// Identidad
faker.person.firstName() // 'Marcus'
faker.person.lastName() // 'Holloway'
faker.internet.email() // 'marcus.holloway@gmail.com'
faker.internet.username() // 'marcus_holloway42'
faker.internet.password({ length: 12 }) // 'Kd9$mXp2vLqR'
// IDs y referencias
faker.string.uuid() // 'e2d4f6a8-...'
faker.number.int({ min: 1, max: 9999 }) // 7342
// Fechas
faker.date.future() // Objeto Date en el futuro
faker.date.past({ years: 2 }) // Objeto Date en los últimos 2 años
faker.date.between({ from: '2025-01-01', to: '2025-12-31' }) // Date en rango
// Contenido
faker.lorem.sentence() // 'Voluptas et dolorem rerum.'
faker.commerce.productName() // 'Sleek Rubber Shoes'
faker.commerce.price() // '42.99'faker.seed(12345) al inicio de un archivo de test para hacer la generación determinista. La misma semilla produce la misma secuencia de valores cada vez. Es útil para depurar un test flaky que depende de valores generados específicos: ejecútalo con una semilla, captura los datos, reproduce el fallo de forma confiable.Un hábito importante: genera el valor una vez y almacénalo en una variable. No llames faker.internet.email() en dos lugares esperando el mismo resultado.
// Mal — dos emails diferentes
await page.getByLabel('Email').fill(faker.internet.email());
await expect(page.getByText(faker.internet.email())).toBeVisible(); // ¡valor diferente!
// Bien — generar una vez, usar en todas partes
const email = faker.internet.email();
await page.getByLabel('Email').fill(email);
await expect(page.getByText(email)).toBeVisible();Funciones factory: buildUser() y buildOrder()
Una función factory es una función TypeScript normal que devuelve un objeto de datos completo. Usa Faker para los defaults pero acepta overrides para que los tests puedan especificar lo que realmente les importa.
// factories/user.factory.ts
import { faker } from '@faker-js/faker';
export interface User {
firstName: string;
lastName: string;
email: string;
password: string;
role: 'admin' | 'editor' | 'viewer';
createdAt: Date;
}
export function buildUser(overrides: Partial<User> = {}): User {
return {
firstName: faker.person.firstName(),
lastName: faker.person.lastName(),
email: faker.internet.email(),
password: faker.internet.password({ length: 12 }),
role: 'viewer',
createdAt: faker.date.past({ years: 1 }),
...overrides,
};
}// factories/order.factory.ts
import { faker } from '@faker-js/faker';
export interface Order {
id: string;
customerId: string;
items: { productId: string; quantity: number; price: number }[];
status: 'pending' | 'processing' | 'shipped' | 'delivered';
total: number;
placedAt: Date;
}
export function buildOrder(overrides: Partial<Order> = {}): Order {
const items = Array.from({ length: faker.number.int({ min: 1, max: 4 }) }, () => ({
productId: faker.string.uuid(),
quantity: faker.number.int({ min: 1, max: 5 }),
price: parseFloat(faker.commerce.price({ min: 5, max: 200 })),
}));
const total = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
return {
id: faker.string.uuid(),
customerId: faker.string.uuid(),
items,
status: 'pending',
total: parseFloat(total.toFixed(2)),
placedAt: new Date(),
...overrides,
};
}En los tests, solo especificas lo que el test necesita. Todo lo demás se completa automáticamente:
// Un test que solo le importa el rol del usuario
const adminUser = buildUser({ role: 'admin' });
// Un test que solo le importa el estado de la orden
const shippedOrder = buildOrder({ status: 'shipped' });
// Un test que necesita un email específico (para login)
const user = buildUser({ email: 'known-test-user@example.com' });Esto mantiene los tests expresivos. Cuando ves buildUser({ role: 'admin' }), entiendes inmediatamente que el rol es lo que importa en este test. Los otros 10 campos son ruido irrelevante que Faker manejó por ti.
Patrón builder para objetos de test complejos
Para objetos con muchos campos interdependientes (donde definir una propiedad debería cambiar otra), una clase builder con API fluida es más legible que una función factory con un objeto de overrides grande.
// builders/UserBuilder.ts
import { faker } from '@faker-js/faker';
import { User } from '../factories/user.factory';
export class UserBuilder {
private user: User;
constructor() {
this.user = {
firstName: faker.person.firstName(),
lastName: faker.person.lastName(),
email: faker.internet.email(),
password: faker.internet.password({ length: 12 }),
role: 'viewer',
createdAt: new Date(),
};
}
withRole(role: User['role']): UserBuilder {
this.user.role = role;
return this;
}
withEmail(email: string): UserBuilder {
this.user.email = email;
return this;
}
withName(firstName: string, lastName: string): UserBuilder {
this.user.firstName = firstName;
this.user.lastName = lastName;
return this;
}
asAdmin(): UserBuilder {
this.user.role = 'admin';
this.user.email = `admin-${faker.string.uuid().slice(0, 8)}@company.com`;
return this;
}
createdDaysAgo(days: number): UserBuilder {
const date = new Date();
date.setDate(date.getDate() - days);
this.user.createdAt = date;
return this;
}
build(): User {
return { ...this.user };
}
}El punto de llamada se lee casi como inglés natural:
const admin = new UserBuilder().asAdmin().build();
const recentUser = new UserBuilder()
.withRole('editor')
.createdDaysAgo(3)
.build();
const namedUser = new UserBuilder()
.withName('Jordan', 'Reeves')
.withRole('viewer')
.build();El builder brilla cuando asAdmin() necesita definir múltiples campos juntos (rol, dominio de email, y quizás un flag isVerified) y prefieres no dispersar esa lógica por cada test que crea un usuario admin.
Usa funciones factory para objetos simples y clases builder para cualquier cosa donde las combinaciones predefinidas de campos son comunes. Se complementan bien: una función factory puede usar internamente un builder si el objeto es complejo.
Siembra de datos por API
Crear datos de prueba a través de la UI es lento y frágil. Un flujo de registro que tarda 8 segundos en el navegador tarda 80 milisegundos a través de la API. Más importante aún, el setup por UI acopla tu test a dos funcionalidades a la vez: si el formulario de registro se rompe, cada test que lo usa como setup también se rompe, aunque esos tests sean sobre algo completamente diferente.
El fixture request de Playwright te da un contexto de API que puedes usar directamente en el código de setup.
// helpers/api.helpers.ts
import { APIRequestContext } from '@playwright/test';
import { buildUser, User } from '../factories/user.factory';
const BASE_URL = process.env.API_BASE_URL ?? 'https://lab.becomeqa.com/api';
export async function createUserViaApi(
request: APIRequestContext,
overrides: Partial<User> = {}
): Promise<User & { id: string }> {
const userData = buildUser(overrides);
const response = await request.post(`${BASE_URL}/users`, {
data: userData,
headers: {
Authorization: `Bearer ${process.env.API_SEED_TOKEN}`,
'Content-Type': 'application/json',
},
});
if (!response.ok()) {
throw new Error(`Failed to create user: ${response.status()} ${await response.text()}`);
}
const created = await response.json();
return { ...userData, id: created.id };
}
export async function deleteUserViaApi(
request: APIRequestContext,
userId: string
): Promise<void> {
await request.delete(`${BASE_URL}/users/${userId}`, {
headers: { Authorization: `Bearer ${process.env.API_SEED_TOKEN}` },
});
}Los tests que necesitan un usuario solo llaman al helper y obtienen un registro real de base de datos al instante:
test('el admin puede desactivar una cuenta de usuario', async ({ page, request }) => {
const user = await createUserViaApi(request, { role: 'editor' });
await page.goto('/admin/users');
await page.getByTestId(`user-row-${user.id}`).getByRole('button', { name: 'Deactivate' }).click();
await page.getByRole('button', { name: 'Confirm' }).click();
await expect(page.getByTestId(`user-row-${user.id}`).getByText('Inactive')).toBeVisible();
await deleteUserViaApi(request, user.id); // limpieza explícita
});Este test verifica la UI de desactivación del admin sin depender de la UI de registro, la UI de login, ni ningún otro camino que pueda fallar por razones no relacionadas.
Fixture de datos con limpieza automática
El test anterior maneja la limpieza de forma manual. Funciona, pero si el test lanza una excepción antes de llegar a deleteUserViaApi, la limpieza nunca ocurre y el usuario queda en la base de datos. Un fixture de Playwright resuelve esto haciendo la limpieza incondicional.
// fixtures/data.fixture.ts
import { test as base, APIRequestContext } from '@playwright/test';
import { buildUser, User } from '../factories/user.factory';
import { createUserViaApi, deleteUserViaApi } from '../helpers/api.helpers';
type DataFixtures = {
testUser: User & { id: string };
testAdminUser: User & { id: string };
};
export const test = base.extend<DataFixtures>({
testUser: async ({ request }, use) => {
const user = await createUserViaApi(request);
await use(user);
// Se ejecuta después del test — pase o falle
try {
await deleteUserViaApi(request, user.id);
} catch (error) {
console.warn(`Cleanup failed for user ${user.id}:`, error);
}
},
testAdminUser: async ({ request }, use) => {
const user = await createUserViaApi(request, { role: 'admin' });
await use(user);
try {
await deleteUserViaApi(request, user.id);
} catch (error) {
console.warn(`Cleanup failed for admin user ${user.id}:`, error);
}
},
});
export { expect } from '@playwright/test';Los tests ahora reciben un usuario completamente creado y nunca se ocupan de la limpieza ellos mismos:
// tests/profile.spec.ts
import { test, expect } from '../fixtures/data.fixture';
test('el usuario puede actualizar su nombre de pantalla', async ({ page, testUser }) => {
await loginAs(page, testUser.email, testUser.password);
await page.goto('/profile');
await page.getByLabel('Display name').fill('New Display Name');
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.getByText('Profile updated')).toBeVisible();
// testUser se elimina después de esta línea — automáticamente
});
test('el admin puede ver los detalles del usuario', async ({ page, testAdminUser, testUser }) => {
await loginAs(page, testAdminUser.email, testAdminUser.password);
await page.goto(`/admin/users/${testUser.id}`);
await expect(page.getByText(testUser.email)).toBeVisible();
// Ambos usuarios se eliminan después de este test
});El try/catch en el bloque de teardown es intencional. Si la limpieza lanza y no lo capturas, Playwright puede mostrar un fallo secundario confuso que oculta lo que realmente salió mal en el test. Registra el warning, pero no relances.
Datos con scope de worker para datos de referencia de solo lectura
Algunos datos no cambian entre tests: un catálogo de categorías de productos, una lista de países, un conjunto de definiciones de permisos. Crear y eliminar estos datos para cada test es innecesario cuando nunca se modifican.
Los fixtures con scope de worker crean los datos una vez por proceso worker y los comparten entre todos los tests de ese worker. El tipo del fixture se mueve al segundo parámetro genérico de extend.
// fixtures/worker-data.fixture.ts
import { test as base } from '@playwright/test';
interface Category {
id: string;
name: string;
slug: string;
}
type WorkerFixtures = {
productCategories: Category[];
};
export const test = base.extend<{}, WorkerFixtures>({
productCategories: [
async ({ request }, use) => {
// Sembrar las categorías una vez por worker
const created: Category[] = [];
for (const name of ['Electronics', 'Books', 'Clothing']) {
const response = await request.post('/api/categories', {
data: { name, slug: name.toLowerCase() },
headers: { Authorization: `Bearer ${process.env.API_SEED_TOKEN}` },
});
const category = await response.json();
created.push(category);
}
await use(created);
// Limpieza después de que todos los tests del worker terminen
for (const category of created) {
try {
await request.delete(`/api/categories/${category.id}`, {
headers: { Authorization: `Bearer ${process.env.API_SEED_TOKEN}` },
});
} catch (error) {
console.warn(`Category cleanup failed for ${category.id}:`, error);
}
}
},
{ scope: 'worker' },
],
});
export { expect } from '@playwright/test';Los tests en el mismo worker reciben el mismo array productCategories. Como solo leen de él, no hay interferencia entre tests. Si los tests corren en paralelo en múltiples workers, cada worker crea su propio conjunto, lo que está bien para datos de referencia.
Nunca uses scope de worker para datos que los tests modifican. Si un test cambia un dato compartido, los tests que corran después en el mismo worker verán la versión modificada, y tienes fallos sutiles dependientes del orden que son difíciles de depurar.
Manejar la unicidad: emails basados en UUID e IDs sin colisiones
Incluso con Faker, pueden ocurrir colisiones de unicidad. El internet.email() de Faker toma de un pool de nombres y dominios comunes, así que marcus.holloway@gmail.com podría aparecer dos veces en una ejecución larga. Para cualquier campo que la base de datos fuerza como único, necesitas una estrategia que garantice que no haya repeticiones.
El enfoque más confiable es embeber un UUID en el valor:
// helpers/unique.ts
import { faker } from '@faker-js/faker';
export function uniqueEmail(prefix = 'test'): string {
const id = faker.string.uuid().slice(0, 8);
return `${prefix}+${id}@test-suite.local`;
}
export function uniqueUsername(): string {
const id = faker.string.uuid().slice(0, 8);
return `user_${id}`;
}
export function uniqueSlug(base: string): string {
const id = faker.string.uuid().slice(0, 6);
return `${base}-${id}`.toLowerCase().replace(/\s+/g, '-');
}Esto produce valores como test+a3f9c2b1@test-suite.local y user_a3f9c2b1. Ocho caracteres de UUID equivalen a 16^8 = 4.300 millones de valores posibles, así que la probabilidad de colisión en una suite de tests es efectivamente cero. Usar un dominio test-suite.local también hace trivial identificar y eliminar en masa los datos de prueba si la limpieza se atrasa.
Actualiza tu factory para usar estos helpers:
// factories/user.factory.ts (actualizado)
import { faker } from '@faker-js/faker';
import { uniqueEmail, uniqueUsername } from '../helpers/unique';
export function buildUser(overrides: Partial<User> = {}): User {
return {
firstName: faker.person.firstName(),
lastName: faker.person.lastName(),
email: uniqueEmail(), // garantizadamente único
username: uniqueUsername(), // garantizadamente único
password: faker.internet.password({ length: 12 }),
role: 'viewer',
createdAt: faker.date.past({ years: 1 }),
...overrides,
};
}test-suite.local con un dominio real que controles, o configura un dominio de test especial en la lista permitida de tu entorno de staging. Algunas apps también rechazan el + en las direcciones de email; en ese caso usa un formato con subdominio prefijado por UUID: a3f9c2b1.test@tudominio.com.Para IDs numéricos generados por una secuencia de base de datos, la secuencia maneja la unicidad por vos, así que no necesitas pre-generar IDs en absoluto. Solo deja que la API devuelva el ID creado y úsalo en tu test. Solo genera IDs del lado del cliente cuando testees sistemas que aceptan IDs provistos por el cliente, como UUIDs almacenados directamente en la base de datos.
FAQ
¿Debería usar una función factory o una clase builder?Las funciones factory son más simples y funcionan para la mayoría de los casos. Usa builders cuando tienes varios presets significativos (como asAdmin(), asUnverified(), asSuspended()) que combinan múltiples valores de campos. Si te encuentras pasando el mismo objeto de overrides repetidamente, es señal de que un método builder con nombre sería más limpio.
Guarda el token de siembra en una variable de entorno y cárgalo con process.env. Para CI, inyecta la variable a través de los secretos del pipeline (GitHub Actions: secrets.API_SEED_TOKEN). Nunca hardcodees credenciales en archivos fuente.
Sí. Pide tantos fixtures como el test necesite: async ({ testUser, testAdminUser, productCategories }). Playwright los resuelve y crea todos antes de que el test corra, luego los destruye todos después, en orden inverso al de creación.
El teardown del fixture corre independientemente del resultado del test. El patrón try/catch en el bloque de teardown asegura que si la eliminación falla (quizás el test ya eliminó el recurso como parte del flujo que se estaba testeando), el error se registra pero no produce un fallo falso. Si un recurso fue eliminado intencionalmente por el test, verifica antes de eliminar: if (response.status() !== 404) await deleteViaApi(id).
Sí. Instálalo con --save-dev e impórtalo solo en archivos de test y archivos de factory/helper. Nunca llega a producción. Si de alguna forma usas una factory en código de producción, los límites de módulos de TypeScript y el tree-shaking lo detectarán, o puedes forzarlo con una regla de ESLint.