Los suites de tests que funcionan bien con 50 tests suelen colapsar en 200: los tests que dependen entre sí no pueden correr en paralelo, las esperas hardcodeadas hacen el suite lento y aun así inestable, y las cadenas de localizadores repetidas en distintos archivos hacen que un solo cambio en la UI rompa decenas de tests. Las prácticas que previenen esto son consistentes: nombres de tests descriptivos, aislamiento estricto del estado, reemplazar waitForTimeout por esperas basadas en elementos, y el Page Object Model introducido en el momento correcto. Este artículo cubre cada práctica con ejemplos concretos de Playwright y el razonamiento detrás de cada decisión.
Nombrá los tests como oraciones, no como código
El nombre del test es lo primero que lees cuando algo falla a las 2am en CI. Debería decirte exactamente qué se rompió sin abrir el archivo.
Mal:
test('test de login', async ({ page }) => { ... });
test('test1', async ({ page }) => { ... });
test('verificarTabla', async ({ page }) => { ... });Bien:
test('el usuario puede iniciar sesión con credenciales válidas', async ({ page }) => { ... });
test('el login falla con contraseña incorrecta', async ({ page }) => { ... });
test('la tabla de ítems muestra 5 filas después del login', async ({ page }) => { ... });El patrón es: [quién] puede/no puede [hacer qué] [bajo qué condición]. Escríbelo de forma que una persona no técnica que lee el output de CI entienda qué falló.
Los bloques describe funcionan igual:
test.describe('Login', () => {
test('tiene éxito con credenciales válidas', async ({ page }) => { ... });
test('falla con contraseña incorrecta', async ({ page }) => { ... });
test('falla con email vacío', async ({ page }) => { ... });
});Una aserción por test: el ideal, no la regla
Vas a ver el consejo de "una aserción por test." La regla real es: un concepto lógico por test.
Un test que inicia sesión y verifica el título de la página está bien (son parte del mismo flujo). Un test que inicia sesión, verifica el título, edita un registro, verifica que el registro se actualizó y luego cierra sesión está haciendo demasiado. Cuando falle no sabrás qué parte se rompió.
Bien. Un concepto, múltiples aserciones relacionadas:
test('el login redirige al dashboard con el header correcto', async ({ page }) => {
await page.goto('https://lab.becomeqa.com');
await page.getByRole('button', { name: 'Login' }).click();
await page.getByLabel('Username').fill('admin@becomeqa.com');
await page.getByLabel('Password').fill('testpass123');
await page.getByRole('button', { name: 'Submit' }).click();
await expect(page).toHaveURL(/dashboard/);
await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
await expect(page.getByText('My Travel Items')).toBeVisible();
});Mantené los tests independientes entre sí
Los tests que dependen entre sí son una trampa. Si el test 3 solo funciona cuando el test 2 corrió primero, no puedes ejecutar los tests en paralelo, no puedes correr un solo test en aislamiento, y cuando el test 2 se rompe obtenés una cascada de fallos difícil de diagnosticar.
Cada test debe configurar su propio estado y limpiar después.
Cada test que necesite un usuario con sesión iniciada debe hacer el login por sí mismo, o usar storageState para guardar la cookie de auth y reutilizarla sin repetir el flujo de UI.
// Mal: depende de que el test anterior haya iniciado sesión
test('puede ver los ítems de viaje', async ({ page }) => {
// asume que ya hay sesión iniciada: falla si corre solo
await expect(page.getByText('My Travel Items')).toBeVisible();
});
// Bien: configura su propio estado
test('puede ver los ítems de viaje', async ({ page }) => {
await page.goto('https://lab.becomeqa.com');
await page.getByRole('button', { name: 'Login' }).click();
await page.getByLabel('Username').fill('admin@becomeqa.com');
await page.getByLabel('Password').fill('testpass123');
await page.getByRole('button', { name: 'Submit' }).click();
await expect(page.getByText('My Travel Items')).toBeVisible();
});test.only en código commiteado. Silenciosamente deshabilita todos los demás tests del archivo. Si un test.only llega a la rama principal, tu CI pasa con 1 test en lugar de 50 y nadie lo nota hasta que algo se rompe en producción.Usá Page Object Model cuando los archivos crecen
Cuando tu archivo de tests llega a 200 líneas o más y cada test repite las mismas llamadas a getByLabel('Username').fill(...), es momento del Page Object Model (POM).
El POM mueve las interacciones con la página a una clase separada. Los tests llaman a métodos de esa clase en lugar de comandos directos de Playwright. Cuando el formulario de login cambia, actualizas una sola clase en lugar de cada test que toca el login.
// pages/LoginPage.ts
import { Page } from '@playwright/test';
export class LoginPage {
constructor(private page: Page) {}
async goto() {
await this.page.goto('https://lab.becomeqa.com');
await this.page.getByRole('button', { name: 'Login' }).click();
}
async login(username: string, password: string) {
await this.page.getByLabel('Username').fill(username);
await this.page.getByLabel('Password').fill(password);
await this.page.getByRole('button', { name: 'Submit' }).click();
}
}// tests/login.spec.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
test('el usuario puede iniciar sesión', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('admin@becomeqa.com', 'testpass123');
await expect(page.getByText('My Travel Items')).toBeVisible();
});Cuando la UI del login cambie, corriges LoginPage.ts y todos los tests siguen en verde.
No te apresures con el POM. Escribí los tests sin él primero. Cuando te agarres copiando y pegando las mismas 5 líneas por tercera vez, esa es la señal.
Evitá las esperas hardcodeadas
page.waitForTimeout(3000) es una señal de alerta. Le estás diciendo a Playwright que espere 3 segundos sin importar lo que haya en pantalla. El test se vuelve lento en máquinas rápidas y sigue siendo inestable en los runners lentos de CI.
Playwright espera automáticamente a los elementos antes de interactuar con ellos. Cuando realmente necesitás esperar algo específico, esperá esa cosa específica:
// Mal
await page.waitForTimeout(3000);
await page.getByRole('button', { name: 'Save' }).click();
// Bien: Playwright espera automáticamente antes de hacer clic
await page.getByRole('button', { name: 'Save' }).click();
// Bien: esperar que aparezca un elemento específico
await page.waitForSelector('[data-testid="success-toast"]');
// Bien: esperar que se complete una solicitud de red
await page.waitForResponse(resp => resp.url().includes('/api/items'));El único momento en que waitForTimeout es aceptable es en la depuración local para ralentizar la ejecución y ver qué está pasando. Nunca debería existir en código de tests commiteado.
Usá variables de entorno para credenciales y URLs
Hardcodear credenciales y URLs base en los tests crea dos problemas: quedan en el historial de git y cambiarlas implica buscar en cada archivo de tests.
Guárdalas en un archivo .env y cárgalas a través de la config de Playwright:
// .env (nunca commitear este archivo)
BASE_URL=https://lab.becomeqa.com
TEST_USER=admin@becomeqa.com
TEST_PASS=testpass123// playwright.config.ts
export default defineConfig({
use: {
baseURL: process.env.BASE_URL || 'https://lab.becomeqa.com',
},
});// tests/login.spec.ts
test('el usuario puede iniciar sesión', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: 'Login' }).click();
await page.getByLabel('Username').fill(process.env.TEST_USER!);
await page.getByLabel('Password').fill(process.env.TEST_PASS!);
await page.getByRole('button', { name: 'Submit' }).click();
await expect(page.getByText('My Travel Items')).toBeVisible();
});Agrega .env al .gitignore. En CI, configura las variables de entorno en la config del pipeline.
Estructurá la carpeta de tests antes de que crezca
Una estructura de carpetas que funciona para 10 tests se rompe con 100. Configúrala desde el principio:
tests/
auth/
login.spec.ts
logout.spec.ts
items/
items-list.spec.ts
items-crud.spec.ts
api/
items-api.spec.ts
pages/
LoginPage.ts
ItemsPage.ts
fixtures/
auth.fixture.tsAgrupa por función, no por tipo. tests/auth/ es mejor que tests/ui/ porque cuando algo en auth se rompe, sabes exactamente dónde buscar.
npx playwright test tests/auth/ para correr solo los tests de una carpeta. Útil cuando estás trabajando en una función específica y no quieres esperar el suite completo.Escribí tests que documenten la intención
Un test es documentación: los nombres de variables deben describir lo que contienen, los datos de test deben verse realistas, y los comentarios solo deben aparecer para el setup no obvio.
// Difícil de entender
const u = 'admin@becomeqa.com';
const p = 'testpass123';
await page.getByLabel('Username').fill(u);
// Claro
const adminEmail = 'admin@becomeqa.com';
const adminPassword = 'testpass123';
await page.getByLabel('Username').fill(adminEmail);Diferencia pequeña en el código, diferencia grande en la legibilidad seis meses después.
Corré el suite completo antes de mergear
Los tests que solo corren localmente son sugerencias, no tests. Conecta tus tests de Playwright a CI para que corran en cada pull request automáticamente.
Como mínimo, tu CI debe instalar dependencias, instalar navegadores, correr el suite y subir el reporte HTML si los tests fallan:
# .github/workflows/tests.yml (simplificado)
- run: npm ci
- run: npx playwright install --with-deps
- run: npx playwright testSi los tests pasan en CI, mergeas. Si fallan, corriges antes de mergear. Ese es el contrato.
FAQ
¿Cuántos tests son demasiados para un archivo?Alrededor de 300 a 400 líneas, cuando hacer scroll para encontrar un test se vuelve molesto. Divide por función en ese punto.
¿Debería testear cada caso extremo?No. Testea el camino feliz, el camino de error más común y cualquier caso extremo que haya causado bugs reales. El objetivo es la confianza, no el 100% de cobertura por sí mismo.
Mis tests pasan localmente pero fallan en CI. ¿Qué suele estar mal?Las tres causas más comunes: una URL con localhost hardcodeada, un await faltante, o una race condition ocultada por el hecho de que tu máquina es más rápida que el runner de CI. Revisa el output del trace viewer de CI: muestra exactamente dónde se rompió.
beforeEach vs un fixture?
beforeEach para un setup simple específico a un archivo de tests. Fixtures para setup reutilizado en múltiples archivos (como una página con sesión iniciada o datos de test precargados).
→ See also: Page Object Model en Playwright: De Caótico a Mantenible | Fixtures de Playwright Explicados: De los Integrados a los Personalizados | Depurando Tests Inestables: Una Guía Práctica | Aislamiento de Tests: Por qué Cada Test de Playwright Debe ser sin Estado