Patrones E2E con Playwright
Aplica Page Object Model, estrategias robustas de locators y patrones de aserciones para pruebas E2E mantenibles.
Receta
Tarjeta de referencia rápida — lista para copiar y pegar.
import { test, expect, Page } from "@playwright/test";
// Locator strategies (prefer accessible queries)
page.getByRole("button", { name: /submit/i });
page.getByLabel("Email");
page.getByText("Welcome back");
page.getByPlaceholder("Search...");
page.getByTestId("checkout-form");
// Navigation and waiting
await page.goto("/products");
await page.waitForURL("/products/**");
await page.getByRole("link", { name: /details/i }).click();
// Form interaction
await page.getByLabel("Email").fill("alice@example.com");
await page.getByLabel("Password").fill("password123");
await page.getByRole("button", { name: /sign in/i }).click();
// Assertions (auto-retry)
await expect(page).toHaveURL("/dashboard");
await expect(page.getByRole("heading")).toHaveText("Dashboard");
await expect(page.getByRole("alert")).toBeVisible();
await expect(page.getByRole("button")).toBeEnabled();
// Screenshots
await page.screenshot({ path: "screenshots/dashboard.png" });
await expect(page).toHaveScreenshot("dashboard.png");Cuándo usarlo: Cuando escribes pruebas E2E que deben ser mantenibles, legibles y resistentes a cambios menores de la UI.
Ejemplo funcional
// e2e/pages/checkout-page.ts
import { Page, Locator, expect } from "@playwright/test";
export class CheckoutPage {
readonly page: Page;
readonly emailInput: Locator;
readonly nameInput: Locator;
readonly addressInput: Locator;
readonly cityInput: Locator;
readonly zipInput: Locator;
readonly cardNumberInput: Locator;
readonly submitButton: Locator;
readonly orderConfirmation: Locator;
readonly errorMessage: Locator;
constructor(page: Page) {
this.page = page;
this.emailInput = page.getByLabel("Email");
this.nameInput = page.getByLabel("Full name");
this.addressInput = page.getByLabel("Address");
this.cityInput = page.getByLabel("City");
this.zipInput = page.getByLabel("ZIP code");
this.cardNumberInput = page.getByLabel("Card number");
this.submitButton = page.getByRole("button", { name: /place order/i });
this.orderConfirmation = page.getByRole("heading", { name: /order confirmed/i });
this.errorMessage = page.getByRole("alert");
}
async goto() {
await this.page.goto("/checkout");
}
async fillShippingInfo(info: {
email: string;
name: string;
address: string;
city: string;
zip: string;
}) {
await this.emailInput.fill(info.email);
await this.nameInput.fill(info.name);
await this.addressInput.fill(info.address);
await this.cityInput.fill(info.city);
await this.zipInput.fill(info.zip);
}
async fillPayment(cardNumber: string) {
await this.cardNumberInput.fill(cardNumber);
}
async submitOrder() {
await this.submitButton.click();
}
async expectOrderConfirmed() {
await expect(this.orderConfirmation).toBeVisible();
}
async expectError(message: string) {
await expect(this.errorMessage).toContainText(message);
}
}// e2e/pages/product-page.ts
import { Page, Locator, expect } from "@playwright/test";
export class ProductPage {
readonly page: Page;
readonly addToCartButton: Locator;
readonly cartCount: Locator;
readonly quantityInput: Locator;
constructor(page: Page) {
this.page = page;
this.addToCartButton = page.getByRole("button", { name: /add to cart/i });
this.cartCount = page.getByTestId("cart-count");
this.quantityInput = page.getByLabel("Quantity");
}
async goto(productSlug: string) {
await this.page.goto(`/products/${productSlug}`);
}
async setQuantity(quantity: number) {
await this.quantityInput.fill(String(quantity));
}
async addToCart() {
await this.addToCartButton.click();
}
async expectCartCount(count: number) {
await expect(this.cartCount).toHaveText(String(count));
}
}// e2e/checkout.spec.ts
import { test, expect } from "@playwright/test";
import { ProductPage } from "./pages/product-page";
import { CheckoutPage } from "./pages/checkout-page";
test.describe("Checkout Flow", () => {
test("complete purchase from product page to confirmation", async ({ page }) => {
// Add product to cart
const productPage = new ProductPage(page);
await productPage.goto("premium-widget");
await productPage.setQuantity(2);
await productPage.addToCart();
await productPage.expectCartCount(2);
// Navigate to checkout
await page.getByRole("link", { name: /checkout/i }).click();
await expect(page).toHaveURL("/checkout");
// Fill checkout form
const checkoutPage = new CheckoutPage(page);
await checkoutPage.fillShippingInfo({
email: "alice@example.com",
name: "Alice Johnson",
address: "123 Main St",
city: "Springfield",
zip: "62701",
});
await checkoutPage.fillPayment("4242424242424242");
await checkoutPage.submitOrder();
// Verify confirmation
await checkoutPage.expectOrderConfirmed();
await expect(page).toHaveURL(/\/orders\/[a-z0-9]+/);
});
test("shows validation errors for empty form", async ({ page }) => {
const checkoutPage = new CheckoutPage(page);
await checkoutPage.goto();
await checkoutPage.submitOrder();
await checkoutPage.expectError("Email is required");
});
test("shows error for invalid card", async ({ page }) => {
const checkoutPage = new CheckoutPage(page);
await checkoutPage.goto();
await checkoutPage.fillShippingInfo({
email: "alice@example.com",
name: "Alice Johnson",
address: "123 Main St",
city: "Springfield",
zip: "62701",
});
await checkoutPage.fillPayment("0000000000000000");
await checkoutPage.submitOrder();
await checkoutPage.expectError("Invalid card number");
});
});Qué demuestra esto:
- Patrón Page Object Model (POM) para encapsular interacciones de página
- Locators definidos una vez en el page object y reutilizados en las pruebas
- Métodos auxiliares para operaciones comunes de varios pasos
- Métodos de aserción en los page objects para código de prueba legible
- Flujo de usuario completo desde la navegación de productos hasta la confirmación del pedido
Profundización
Cómo funciona
- Page Object Model encapsula locators e interacciones de una página — cuando la UI cambia, actualizas el page object, no cada prueba
- Los locators de Playwright son perezosos — se evalúan cuando se ejecuta una acción o aserción, no al crearlos
- Las aserciones
expect()reintentan automáticamente hasta pasar o agotar el tiempo de espera (5 segundos por defecto) — no necesitas esperas manuales fill()limpia el input primero y luego escribe el valor — a diferencia detype(), que añade carácter por carácter- Las esperas de navegación son automáticas —
click()en un enlace espera a que la navegación termine
Variaciones
Comparación de estrategias de locators:
| Estrategia | Ejemplo | Cuándo usarlo |
|---|---|---|
getByRole | page.getByRole("button", { name: /enviar/i }) | El elemento tiene un rol ARIA (preferido) |
getByLabel | page.getByLabel("Correo electrónico") | Inputs de formulario con etiquetas |
getByText | page.getByText("¡Hola!") | Contenido de texto estático |
getByPlaceholder | page.getByPlaceholder("Buscar...") | Placeholders de inputs |
getByTestId | page.getByTestId("sidebar") | Ninguna consulta semántica funciona |
locator | page.locator(".custom-dropdown") | Respaldo con selector CSS |
Pruebas de regresión visual:
test("product page visual", async ({ page }) => {
await page.goto("/products/widget");
// Full page screenshot comparison
await expect(page).toHaveScreenshot("product-page.png", {
maxDiffPixelRatio: 0.01,
});
// Element-level screenshot
const card = page.getByTestId("product-card");
await expect(card).toHaveScreenshot("product-card.png");
});
// First run creates baseline screenshots in __screenshots__/
// Subsequent runs compare against baselines
// Update baselines: npx playwright test --update-snapshotsEstrategias de espera:
// Wait for network idle (all requests finished)
await page.goto("/dashboard", { waitUntil: "networkidle" });
// Wait for a specific response
const response = await page.waitForResponse("/api/products");
expect(response.status()).toBe(200);
// Wait for element state
await page.getByRole("button").waitFor({ state: "visible" });
await page.getByText("Loading").waitFor({ state: "hidden" });Notas de TypeScript
// Page objects benefit from strict typing
interface ShippingInfo {
email: string;
name: string;
address: string;
city: string;
zip: string;
}
// Extend test fixtures for page objects
import { test as base } from "@playwright/test";
type Fixtures = {
checkoutPage: CheckoutPage;
productPage: ProductPage;
};
export const test = base.extend<Fixtures>({
checkoutPage: async ({ page }, use) => {
await use(new CheckoutPage(page));
},
productPage: async ({ page }, use) => {
await use(new ProductPage(page));
},
});
// Use in tests
test("checkout", async ({ checkoutPage, productPage }) => {
await productPage.goto("widget");
// ...
});Errores comunes
-
Selectores CSS frágiles —
page.locator(".btn-primary.mt-4")se rompe cuando cambian las clases. Solución: UsagetByRole,getByLabelogetByTestIden su lugar. -
No esperar la navegación — Hacer clic en un enlace y afirmar de inmediato puede fallar si la página no ha cargado. Solución: Playwright espera automáticamente en
click, pero añadeawait expect(page).toHaveURL(...)para verificación explícita. -
Líneas base de capturas entre sistemas operativos — Las fuentes se renderizan distinto en macOS, Linux y Windows. Solución: Ejecuta las pruebas de capturas en Docker o solo en un SO en CI.
-
Page objects excesivamente granulares — Crear un page object para cada sección pequeña añade indirección. Solución: Un page object por página o sección principal. Mantén la practicidad.
-
Usar
page.waitForTimeout()— Las esperas codificadas hacen las pruebas lentas e inestables. Solución: Usa aserciones con espera automática (expect(...).toBeVisible()) owaitForResponseen su lugar.
Alternativas
| Alternativa | Cuándo usarlo | Cuándo no usarlo |
|---|---|---|
| Locators inline (sin POM) | Suites de prueba pequeñas con pocas páginas | Las pruebas superan una docena de archivos |
| Page objects basados en fixtures | Quieres configuración automática de page objects mediante fixtures de test | La construcción simple de POM en el cuerpo del test es suficiente |
| Cypress | Prefieres la depuración con viaje en el tiempo de Cypress o pruebas de componentes | Necesitas soporte multi-navegador |
| Percy / Chromatic | Necesitas regresión visual empresarial con flujo de revisión | La comparación de capturas integrada es suficiente |
FAQs
¿Qué es el patrón Page Object Model (POM)?
POM encapsula locators e interacciones de una página en una clase. Cuando la UI cambia, actualizas el page object una vez en lugar de cada prueba que referencia esa página.
¿Cuál es el orden de prioridad de locators en Playwright?
getByRole— rol ARIA accesible (preferido)getByLabel— etiquetas de inputs de formulariogetByText— contenido de texto estáticogetByPlaceholder— placeholders de inputsgetByTestId— último recursolocator()— respaldo con selector CSS
¿Cuál es la diferencia entre fill() y type() en Playwright?
fill()limpia el input primero y luego establece el valor.type()añade caracteres uno por uno.- Usa
fill()para la mayoría de pruebas de formularios.
¿Cómo configuro pruebas de regresión visual?
await expect(page).toHaveScreenshot("dashboard.png", {
maxDiffPixelRatio: 0.01,
});La primera ejecución crea capturas base. Las siguientes comparan contra ellas. Actualiza con --update-snapshots.
Trampa común: ¿Por qué fallan las pruebas de capturas en distintos sistemas operativos?
Las fuentes se renderizan distinto en macOS, Linux y Windows. Ejecuta las pruebas de capturas en Docker o restríngelas a un solo SO en CI.
¿Cómo espero una respuesta de API específica en una prueba?
const response = await page.waitForResponse("/api/products");
expect(response.status()).toBe(200);¿Cómo uso fixtures de test de Playwright para page objects?
export const test = base.extend<Fixtures>({
checkoutPage: async ({ page }, use) => {
await use(new CheckoutPage(page));
},
});
test("checkout", async ({ checkoutPage }) => { /* ... */ });Trampa común: ¿Por qué debo evitar page.waitForTimeout()?
Las esperas codificadas hacen las pruebas lentas e inestables. Usa aserciones con espera automática como expect(...).toBeVisible() o waitForResponse en su lugar.
¿Cómo tipar page objects en TypeScript?
interface ShippingInfo {
email: string;
name: string;
address: string;
city: string;
zip: string;
}
async fillShippingInfo(info: ShippingInfo) { /* ... */ }¿Cuándo debo evitar usar Page Object Model?
Para suites de prueba pequeñas con pocas páginas, los locators inline son más simples. POM añade indirección — adóptalo solo cuando las pruebas superen una docena de archivos o las páginas se reutilicen en muchas pruebas.
¿En qué se diferencian los locators de Playwright de las consultas de Testing Library?
Usan patrones de consulta accesibles similares (getByRole, getByLabel, getByText), pero los locators de Playwright son perezosos — se evalúan cuando se ejecuta una acción o aserción, no al crearlos.
¿Cómo hago una captura a nivel de elemento en lugar de página completa?
const card = page.getByTestId("product-card");
await expect(card).toHaveScreenshot("product-card.png");Relacionado
- Configuración E2E con Playwright — instalación y configuración
- Playwright avanzado — autenticación y pruebas de API
- Estrategia de pruebas y buenas prácticas — elegir tipos de prueba
- Fundamentos de React Testing Library — patrones de consulta similares para pruebas unitarias