Cuando cada test importa page objects directamente y maneja su propio setup de login, un cambio en un fixture requiere actualizar decenas de archivos. La solución es un índice de fixtures: un único archivo que combina todas las definiciones de fixtures, para que los tests importen desde ../../fixtures y nada más. Este artículo cubre la estructura de carpetas que hace que esto funcione, la clase BasePage para navegación compartida, el patrón mergeTests para componer archivos de fixtures, configuración de entorno con un wrapper requireEnv() que falla en el arranque si faltan variables, y el patrón de migración strangler fig para introducir estructura en una suite existente sin tener que reescribir todo de golpe.
La estructura de carpetas que escala de verdad
Las decisiones de arquitectura tomadas a nivel de carpetas se propagan a todas partes. Una estructura que refleja el propósito del framework (separación entre lógica de tests, interacción con páginas, configuración de datos y utilidades) mantiene la complejidad manejable a medida que la suite crece.
my-app-tests/
tests/
auth/
login.spec.ts
logout.spec.ts
items/
items-crud.spec.ts
items-search.spec.ts
pages/
BasePage.ts
LoginPage.ts
DashboardPage.ts
fixtures/
index.ts
auth.fixture.ts
pages.fixture.ts
data.fixture.ts
data/
factories/
userFactory.ts
itemFactory.ts
seeds/
seedItems.ts
helpers/
waitHelpers.ts
apiHelpers.ts
utils/
envConfig.ts
logger.ts
playwright.config.ts
tsconfig.json
.eslintrc.json
.env.exampleLas reglas clave que hacen que esto funcione: tests/ contiene solo archivos spec y ninguna lógica compartida. pages/ contiene solo clases de page objects. fixtures/ es la capa de pegamento que une todo. data/ es dueña de toda la creación y siembra de datos de prueba. helpers/ contiene funciones reutilizables que no pertenecen a una página específica. utils/ contiene la infraestructura: configuración, logging, todo lo que sea a nivel de framework.
Los tests importan desde fixtures/index.ts y nada más. Esa única restricción mantiene limpio el grafo de dependencias.
La clase base de página
Cada page object en una suite en crecimiento necesita el mismo conjunto de capacidades: navegación, esperar a que la página llegue a un estado conocido, y una forma consistente de manejar patrones comunes de UI. Sin una clase base, estos patrones se copian y pegan entre páginas y van divergiendo con el tiempo.
// pages/BasePage.ts
import { Page, Locator, expect } from '@playwright/test';
import { envConfig } from '../utils/envConfig';
export abstract class BasePage {
protected readonly page: Page;
abstract readonly path: string;
constructor(page: Page) {
this.page = page;
}
async navigate(params?: Record<string, string>): Promise<void> {
const url = new URL(this.path, envConfig.baseURL);
if (params) {
Object.entries(params).forEach(([key, value]) => {
url.searchParams.set(key, value);
});
}
await this.page.goto(url.toString());
await this.waitForPageLoad();
}
protected async waitForPageLoad(): Promise<void> {
await this.page.waitForLoadState('domcontentloaded');
}
async waitForVisible(locator: Locator, timeout = 10_000): Promise<void> {
await locator.waitFor({ state: 'visible', timeout });
}
async waitForHidden(locator: Locator, timeout = 10_000): Promise<void> {
await locator.waitFor({ state: 'hidden', timeout });
}
async assertHeading(text: string): Promise<void> {
await expect(this.page.getByRole('heading', { name: text })).toBeVisible();
}
async assertURL(expectedPath: string): Promise<void> {
await expect(this.page).toHaveURL(new RegExp(expectedPath));
}
async dismissModal(): Promise<void> {
const overlay = this.page.locator('[data-testid="modal-overlay"]');
if (await overlay.isVisible()) {
await this.page.keyboard.press('Escape');
await this.waitForHidden(overlay);
}
}
}Los page objects extienden la base y solo definen lo específico de esa página:
// pages/DashboardPage.ts
import { Page, Locator } from '@playwright/test';
import { BasePage } from './BasePage';
export class DashboardPage extends BasePage {
readonly path = '/dashboard';
readonly addItemButton: Locator;
readonly itemsTable: Locator;
readonly searchInput: Locator;
constructor(page: Page) {
super(page);
this.addItemButton = page.getByRole('button', { name: 'Add Item' });
this.itemsTable = page.getByRole('table', { name: 'Travel items' });
this.searchInput = page.getByRole('searchbox');
}
protected override async waitForPageLoad(): Promise<void> {
await super.waitForPageLoad();
await this.itemsTable.waitFor({ state: 'visible' });
}
async getRowCount(): Promise<number> {
const rows = this.itemsTable.getByRole('row');
return (await rows.count()) - 1;
}
async searchFor(term: string): Promise<void> {
await this.searchInput.fill(term);
await this.page.waitForResponse('**/api/items?search=**');
}
}El override de waitForPageLoad en DashboardPage es el patrón que elimina los tests flaky a escala. Cada página define su propia condición de "listo", y la navegación espera esa condición antes de retornar. Los tests nunca necesitan agregar esperas manuales.
La capa de fixtures
Los fixtures son el sistema de inyección de dependencias del framework. Un archivo exporta todo lo que los tests necesitan: el objeto test extendido, todos los page objects, helpers de datos, y expect. Los archivos de test importan desde exactamente un lugar.
// fixtures/auth.fixture.ts
import { test as base, Page } from '@playwright/test';
import { envConfig } from '../utils/envConfig';
type AuthFixtures = {
authenticatedPage: Page;
};
export const test = base.extend<AuthFixtures>({
authenticatedPage: async ({ page }, use) => {
await page.goto(envConfig.baseURL);
await page.getByRole('button', { name: 'Login' }).click();
await page.getByLabel('Username').fill(envConfig.testUser.email);
await page.getByLabel('Password').fill(envConfig.testUser.password);
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByRole('heading', { name: 'Dashboard' }).waitFor();
await use(page);
},
});// fixtures/pages.fixture.ts
import { test as base } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
import { DashboardPage } from '../pages/DashboardPage';
type PageFixtures = {
loginPage: LoginPage;
dashboardPage: DashboardPage;
};
export const test = base.extend<PageFixtures>({
loginPage: async ({ page }, use) => {
await use(new LoginPage(page));
},
dashboardPage: async ({ page }, use) => {
await use(new DashboardPage(page));
},
});// fixtures/index.ts
import { mergeTests, mergeExpects } from '@playwright/test';
import { test as authTest } from './auth.fixture';
import { test as pagesTest } from './pages.fixture';
import { test as dataTest } from './data.fixture';
export const test = mergeTests(authTest, pagesTest, dataTest);
export { expect } from '@playwright/test';mergeTests es la herramienta correcta aquí: compone fixtures de múltiples archivos sin perder la seguridad de tipos. Cada test del proyecto ahora tiene el mismo import:
import { test, expect } from '../../fixtures';Ese único import le da a cada test acceso a authenticatedPage, dashboardPage, loginPage y todos los fixtures de datos. Agregar un nuevo fixture significa editar un archivo en fixtures/ y queda disponible en todas partes inmediatamente.
mergeTests y re-exports. En el momento en que ponés lógica de fixture directamente en index.ts, se vuelve más difícil encontrar dónde está definido un fixture específico. Un archivo de fixture por dominio (auth, pages, data) mantiene todo navegable.Gestión de configuración entre entornos
Las URLs hardcodeadas son la forma más rápida de hacer inmantenible una suite de tests. La configuración específica de cada entorno necesita una única fuente de verdad desde la que lee el resto del framework.
// utils/envConfig.ts
import * as dotenv from 'dotenv';
import * as path from 'path';
const envFile = process.env.TEST_ENV
? `.env.${process.env.TEST_ENV}`
: '.env';
dotenv.config({ path: path.resolve(process.cwd(), envFile) });
function requireEnv(name: string): string {
const value = process.env[name];
if (!value) {
throw new Error(
`Missing required environment variable: ${name}. ` +
`Did you copy .env.example to ${envFile}?`
);
}
return value;
}
export const envConfig = {
baseURL: requireEnv('BASE_URL'),
apiBaseURL: requireEnv('API_BASE_URL'),
testUser: {
email: requireEnv('TEST_USER_EMAIL'),
password: requireEnv('TEST_USER_PASSWORD'),
},
apiToken: requireEnv('API_TOKEN'),
environment: (process.env.TEST_ENV ?? 'local') as 'local' | 'staging' | 'prod',
} as const;Tres archivos .env viven en la raíz del proyecto y se commitean al repositorio (los secretos van en las variables de CI, no aquí):
# .env.example
BASE_URL=http://localhost:3000
API_BASE_URL=http://localhost:3001/api
TEST_USER_EMAIL=test@example.com
TEST_USER_PASSWORD=replace_me
API_TOKEN=replace_me
# .env.staging
BASE_URL=https://staging.myapp.com
API_BASE_URL=https://staging.myapp.com/api
TEST_USER_EMAIL=staging-test@myapp.com
TEST_USER_PASSWORD=
API_TOKEN=playwright.config.ts lee desde envConfig en lugar de desde process.env directamente:
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
import { envConfig } from './utils/envConfig';
export default defineConfig({
testDir: './tests',
fullyParallel: true,
forbidOnly: envConfig.environment !== 'local',
retries: envConfig.environment === 'local' ? 0 : 2,
workers: envConfig.environment === 'local' ? undefined : 4,
reporter: [
['html', { outputFolder: 'playwright-report' }],
['./utils/slackReporter.ts'],
],
use: {
baseURL: envConfig.baseURL,
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
],
});Ejecutar contra staging ahora es una variable de entorno: TEST_ENV=staging npx playwright test.
Estrategia de datos de prueba
Los tests que dependen de datos creados por un test anterior son los más frágiles de cualquier suite. Cada test debe ser dueño de sus datos desde la creación hasta la limpieza. Tres patrones manejan diferentes escenarios: factories para datos dentro del test, builders para objetos complejos, y siembra vía API para precondiciones costosas.
Las factories generan objetos válidos con defaults sensibles y dejan que los tests sobreescriban solo lo que importa para el escenario específico:
// data/factories/itemFactory.ts
import { faker } from '@faker-js/faker';
export interface ItemData {
name: string;
category: 'Documents' | 'Electronics' | 'Clothing' | 'Other';
quantity: number;
notes?: string;
}
export function buildItem(overrides: Partial<ItemData> = {}): ItemData {
return {
name: faker.commerce.productName(),
category: 'Documents',
quantity: faker.number.int({ min: 1, max: 10 }),
...overrides,
};
}
export function buildItems(count: number, overrides: Partial<ItemData> = {}): ItemData[] {
return Array.from({ length: count }, () => buildItem(overrides));
}Para objetos complejos con muchas dependencias, el patrón builder le da a los tests una API fluida:
// data/factories/userFactory.ts
import { faker } from '@faker-js/faker';
export interface UserData {
email: string;
password: string;
firstName: string;
lastName: string;
role: 'admin' | 'member' | 'viewer';
}
export class UserBuilder {
private data: UserData = {
email: faker.internet.email(),
password: 'TestPass123!',
firstName: faker.person.firstName(),
lastName: faker.person.lastName(),
role: 'member',
};
withRole(role: UserData['role']): this {
this.data.role = role;
return this;
}
withEmail(email: string): this {
this.data.email = email;
return this;
}
asAdmin(): this {
this.data.role = 'admin';
return this;
}
build(): UserData {
return { ...this.data };
}
}La siembra vía API maneja el caso donde la creación por UI es demasiado lenta o genera un estado poco confiable. El fixture de datos lo conecta todo y maneja la limpieza:
// fixtures/data.fixture.ts
import { test as base, APIRequestContext } from '@playwright/test';
import { buildItem, ItemData } from '../data/factories/itemFactory';
import { envConfig } from '../utils/envConfig';
type DataFixtures = {
apiRequest: APIRequestContext;
seededItem: ItemData & { id: string };
};
export const test = base.extend<DataFixtures>({
apiRequest: async ({ playwright }, use) => {
const context = await playwright.request.newContext({
baseURL: envConfig.apiBaseURL,
extraHTTPHeaders: {
Authorization: `Bearer ${envConfig.apiToken}`,
'Content-Type': 'application/json',
},
});
await use(context);
await context.dispose();
},
seededItem: async ({ apiRequest }, use) => {
const itemData = buildItem();
const response = await apiRequest.post('/items', { data: itemData });
const created = await response.json() as ItemData & { id: string };
await use(created);
// La limpieza corre tanto si el test pasa como si falla
await apiRequest.delete(`/items/${created.id}`).catch(() => {
console.warn(`Cleanup failed for item ${created.id} — may need manual removal`);
});
},
});El .catch() en el teardown es intencional. Si la limpieza falla, el resultado del test no debería verse afectado. Registra el warning y sigue.
Reporters: HTML y notificaciones de Slack
El reporter HTML integrado es suficiente para el desarrollo local. Los pipelines de CI necesitan algo que entregue resultados donde el equipo realmente mira. En la mayoría de los casos, Slack.
Un reporter personalizado implementa la interfaz Reporter de Playwright:
// utils/slackReporter.ts
import type {
Reporter,
FullConfig,
Suite,
TestCase,
TestResult,
FullResult,
} from '@playwright/test/reporter';
import * as https from 'https';
export default class SlackReporter implements Reporter {
private passed = 0;
private failed = 0;
private skipped = 0;
private failedTests: string[] = [];
private startTime = Date.now();
onBegin(_config: FullConfig, _suite: Suite): void {
this.startTime = Date.now();
}
onTestEnd(test: TestCase, result: TestResult): void {
if (result.status === 'passed') this.passed++;
else if (result.status === 'skipped') this.skipped++;
else {
this.failed++;
this.failedTests.push(test.titlePath().join(' > '));
}
}
async onEnd(result: FullResult): Promise<void> {
const webhookUrl = process.env.SLACK_WEBHOOK_URL;
if (!webhookUrl) return;
const duration = ((Date.now() - this.startTime) / 1000).toFixed(1);
const status = result.status === 'passed' ? ':white_check_mark:' : ':x:';
const total = this.passed + this.failed + this.skipped;
const blocks = [
{
type: 'section',
text: {
type: 'mrkdwn',
text: `${status} *Playwright Tests — ${process.env.TEST_ENV ?? 'local'}*\n${this.passed}/${total} passed in ${duration}s`,
},
},
];
if (this.failedTests.length > 0) {
blocks.push({
type: 'section',
text: {
type: 'mrkdwn',
text: `*Failed tests:*\n${this.failedTests.map(t => `• ${t}`).join('\n')}`,
},
});
}
const payload = JSON.stringify({ blocks });
await this.postToSlack(webhookUrl, payload);
}
private postToSlack(webhookUrl: string, payload: string): Promise<void> {
return new Promise((resolve, reject) => {
const url = new URL(webhookUrl);
const req = https.request(
{ hostname: url.hostname, path: url.pathname, method: 'POST',
headers: { 'Content-Type': 'application/json' } },
() => resolve()
);
req.on('error', reject);
req.write(payload);
req.end();
});
}
}Registra el reporter en playwright.config.ts:
reporter: [
['html', { outputFolder: 'playwright-report', open: 'never' }],
envConfig.environment !== 'local' ? ['./utils/slackReporter.ts'] : ['list'],
],El reporter de Slack solo se activa en entornos que no son local. Sin ruido durante el desarrollo local.
TypeScript en modo estricto y linting
El código de tests es código de producción. Se ejecuta en CI, afecta las decisiones de release, y los bugs en los tests son más difíciles de detectar que los bugs en el código de la aplicación porque no hay nada que testee los tests. El modo estricto de TypeScript y ESLint detectan categorías enteras de problemas antes de que lleguen al equipo.
// tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"lib": ["ES2022"],
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"exactOptionalPropertyTypes": true,
"moduleResolution": "node",
"esModuleInterop": true,
"outDir": "./dist",
"baseUrl": ".",
"paths": {
"@fixtures": ["./fixtures/index.ts"],
"@pages/*": ["./pages/*"],
"@data/*": ["./data/*"],
"@helpers/*": ["./helpers/*"],
"@utils/*": ["./utils/*"]
}
},
"include": ["**/*.ts"],
"exclude": ["node_modules", "dist"]
}La configuración de paths permite que los archivos de test usen imports limpios:
import { test, expect } from '@fixtures';
import { buildItem } from '@data/factories/itemFactory';Para ESLint, las reglas clave para la calidad de los tests son las que previenen errores comunes específicos de Playwright:
// .eslintrc.json
{
"parser": "@typescript-eslint/parser",
"plugins": ["@typescript-eslint", "playwright"],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/strict-type-checked",
"plugin:playwright/recommended"
],
"parserOptions": {
"project": "./tsconfig.json"
},
"rules": {
"@typescript-eslint/no-floating-promises": "error",
"@typescript-eslint/await-thenable": "error",
"playwright/no-wait-for-timeout": "error",
"playwright/no-conditional-in-test": "warn",
"playwright/prefer-web-first-assertions": "error",
"playwright/no-networkidle": "warn"
}
}no-floating-promises es la regla más importante en una suite de tests de Playwright. Olvidarse de un await antes de una llamada a Playwright es una fuente común de falsos positivos: la aserción corre antes de que la acción se complete, el test pasa, y luego la UI está en un estado inesperado para el siguiente paso. TypeScript solo no detecta esto; la regla de linting sí.
Crecer junto a una suite existente: el patrón strangler fig
El patrón strangler fig describe la sustitución incremental de un sistema viejo haciendo crecer uno nuevo alrededor, redirigiendo gradualmente el tráfico del viejo al nuevo hasta que nada toca el sistema viejo y se puede eliminar. El mismo enfoque aplica a los frameworks de tests.
Iniciar una "reescritura del framework" como un esfuerzo paralelo siempre falla. El nuevo framework vive en una rama separada, la suite vieja sigue cambiando, la rama nunca se fusiona. El enfoque strangler fig mantiene al equipo escribiendo tests en la estructura vieja mientras la nueva la va absorbiendo gradualmente.
Paso 1
Crear la nueva estructura de carpetas junto a los tests existentes. No mover nada todavía.
tests/ ← estructura plana existente, sin tocar
framework/ ← nueva estructura, empieza vacía
tests/
pages/
fixtures/
...
playwright.config.ts ← actualizado para ejecutar ambosActualiza playwright.config.ts para incluir ambos directorios de tests:
export default defineConfig({
projects: [
{
name: 'legacy',
testDir: './tests',
use: { baseURL: envConfig.baseURL },
},
{
name: 'framework',
testDir: './framework/tests',
use: { baseURL: envConfig.baseURL },
},
],
});Paso 2
Cuando escribas un nuevo test, siempre escríbelo en la nueva estructura. Nunca agregar a la carpeta plana vieja. Esto detiene el crecimiento de la estructura vieja.
Paso 3
Cuando modificas un test existente (para corregirlo, o porque cambió la funcionalidad que cubre), mígralo a la nueva estructura en el mismo PR. El test mejora y se migra en un solo cambio.
// framework/tests/items/items-search.spec.ts
// Migrado desde tests/items-search.spec.ts
// Migración: extraído LoginPage, conectado a fixtures, eliminada URL hardcodeada
import { test, expect } from '../../fixtures';
test('la búsqueda filtra ítems por nombre', async ({ authenticatedPage, dashboardPage }) => {
await dashboardPage.navigate();
await dashboardPage.searchFor('Passport');
await expect(dashboardPage.itemsTable.getByRole('row')).toHaveCount(2); // encabezado + 1 resultado
});Paso 4
Agrega una regla de lint o un check simple en CI que falle si el directorio tests/ gana nuevos archivos:
// package.json
"scripts": {
"check:no-new-legacy-tests": "node scripts/checkLegacyTests.js",
"test": "playwright test",
"test:framework": "playwright test --project=framework",
"test:legacy": "playwright test --project=legacy",
"lint": "eslint . --ext .ts",
"typecheck": "tsc --noEmit"
}El script de check lee el git diff y falla si aparecen nuevos archivos .spec.ts en tests/. Los equipos dejan de agregar a la estructura vieja no por una regla sino porque la nueva es claramente mejor, y el check sirve de red de seguridad para quien no haya notado el patrón todavía.
Después de unos meses con esto, el directorio legacy contiene solo tests viejos que nadie ha tocado. En ese punto, un sprint de migración dedicado convierte el resto y se elimina el directorio legacy. La migración ocurrió de forma incremental, el equipo siguió entregando funcionalidades todo el tiempo, y el framework estuvo en producción desde el primer día.
FAQ
¿Cuántas páginas debería cubrir una clase de page object?Una página por clase, un modal por clase. Si una página tiene dos secciones completamente separadas (un sidebar y un panel principal con responsabilidades diferentes), divídelas en dos clases y compónlas en el fixture. Una clase que cubre dos páginas es señal de que el límite se trazó en el lugar equivocado.
¿Deberían los fixtures contener aserciones?No. Los fixtures configuran y limpian estado. Una aserción en un fixture hace imposible saber si el fallo de un test vino de la lógica del test o del setup. Si necesitas verificar que el setup se completó correctamente, usa waitFor de Playwright con una condición en lugar de una aserción. Las aserciones pertenecen exclusivamente a los archivos de test.
Creá fixtures de auth separados, uno por rol: adminPage, memberPage, viewerPage. Cada fixture hace login como un usuario diferente y le entrega la página autenticada al test. Si la cantidad de roles crece, considera un patrón factory: authenticatedAs('admin') devuelve el fixture correcto basado en un parámetro.
Empezá con workers: '50%' en playwright.config.ts (la mitad de los núcleos de CPU disponibles). Monitoreá el uso de recursos de tu runner de CI durante varias ejecuciones. Si los tests empiezan a fallar por contención de recursos, reduce los workers. Si el runner tiene margen, auméntalos. El número correcto depende de las especificaciones del runner y de cuántos recursos consume cada test, no de una fórmula universal.
test.describe vs archivos spec separados?
Archivos spec separados para funcionalidades separadas. test.describe para agrupaciones lógicas dentro de una funcionalidad: happy path vs casos extremos, u operaciones de lectura vs operaciones de escritura. La regla general: si dos grupos de tests necesitan configuración diferente de test.use() (distintos overrides de fixtures), pertenecen a bloques describe separados o archivos separados. Si usan el mismo setup, agruparlos es una decisión de estilo.