En las suites de pruebas E2E desestructuradas, los selectores del DOM y las instrucciones de bajo nivel residen directamente en el cuerpo de las funciones de prueba. Esta práctica desencadena tres problemas críticos de arquitectura:
El patrón Page Object Model (POM) soluciona este dilema disociando la estructura del marcado visual de la lógica de verificación.
No obstante, la implementación tradicional de POM heredada de Java y Selenium—donde las páginas se instancian manualmente con new MyPage(page) en bloques beforeEach—está obsoleta. En el ecosistema de Playwright 2026, el estándar imperante fusiona los Page Objects con la Inyección de Dependencias (IoC) respaldada por Fixtures Personalizadas (Custom Fixtures).
Bajo una arquitectura de testing limpia, un escenario de prueba jamás interactúa directamente con el DOM en bruto. Se comunica exclusivamente a través de interfaces de dominio de alto nivel (Domain Pages & Widgets) inyectadas por el contenedor de Playwright.
+-----------------------------------------------------------------------------+
| Test Runner |
| test('usuario adquiere plan Enterprise', async ({ checkoutPage }) => {}) |
+-----------------------------------------------------------------------------+
│
▼ (Inyección de dependencia vía IoC)
+-----------------------------------------------------------------------------+
| Playwright Fixtures Engine (test.extend) |
| Setup automático -> Instanciación de páginas -> Teardown de recursos |
+-----------------------------------------------------------------------------+
│
▼ (Invocación de métodos de dominio)
+-----------------------------------------------------------------------------+
| Page Object Model Layer |
| |
| +--------------------------+ +------------------------------+ |
| | ShopPage | | CheckoutPage | |
| | - selectProduct() | | - fillPaymentDetails() | |
| | - openCart() | | - placeOrder() | |
| +--------------------------+ +------------------------------+ |
| │ │ |
| ▼ (Composición) ▼ (Composición) |
| +--------------------------+ +------------------------------+ |
| | HeaderNavigationWidget | | NotificationToastWidget | |
| +--------------------------+ +------------------------------+ |
+-----------------------------------------------------------------------------+
│
▼ (Localizadores getByRole encapsulados)
+-----------------------------------------------------------------------------+
| Playwright Core Driver |
| Page API -> Chrome DevTools Protocol / WebDriver BiDi |
+-----------------------------------------------------------------------------+
│
▼ (Eventos físicos de ratón/teclado)
+-----------------------------------------------------------------------------+
| Browser DOM |
+-----------------------------------------------------------------------------+Siguiendo estándares de nivel empresarial, los localizadores se declaran como propiedades readonly de tipo Locator. El constructor recibe la instancia de Page.
import { type Page, type Locator, expect } from '@playwright/test';
export interface PaymentInfo {
cardNumber: string;
cardHolder: string;
expiryDate: string;
cvv: string;
}
export class CheckoutPage {
// Localizadores encapsulados orientados al usuario (User-Facing)
private readonly pageTitle: Locator;
private readonly cardNumberInput: Locator;
private readonly cardHolderInput: Locator;
private readonly expiryInput: Locator;
private readonly cvvInput: Locator;
private readonly submitOrderButton: Locator;
private readonly successBanner: Locator;
private readonly errorMessageContainer: Locator;
constructor(private readonly page: Page) {
this.pageTitle = page.getByRole('heading', { name: 'Tramitar Pedido', level: 1 });
this.cardNumberInput = page.getByLabel('Número de tarjeta');
this.cardHolderInput = page.getByLabel('Titular de la tarjeta');
this.expiryInput = page.getByPlaceholder('MM/AA');
this.cvvInput = page.getByLabel('CVV/CVC');
this.submitOrderButton = page.getByRole('button', { name: 'Confirmar y pagar' });
this.successBanner = page.getByRole('status').filter({ hasText: /pedido tramitado con éxito/i });
this.errorMessageContainer = page.getByRole('alert');
}
/** Navegación hacia la ruta de checkout */
async navigate(): Promise<void> {
await this.page.goto('/checkout');
await expect(this.pageTitle).toBeVisible();
}
/** Relleno integral del formulario de pago */
async fillPaymentForm(info: PaymentInfo): Promise<void> {
await this.cardNumberInput.fill(info.cardNumber);
await this.cardHolderInput.fill(info.cardHolder);
await this.expiryInput.fill(info.expiryDate);
await this.cvvInput.fill(info.cvv);
}
/** Confirmación del pedido */
async submitOrder(): Promise<void> {
await this.submitOrderButton.click();
}
/** Verificación de tramitación exitosa */
async expectOrderSuccessful(): Promise<void> {
await expect(this.successBanner).toBeVisible({ timeout: 10_000 });
}
/** Verificación de mensaje de error de validación */
async expectValidationError(message: string): Promise<void> {
await expect(this.errorMessageContainer).toContainText(message);
}
}test.extend#En lugar de instanciar clases manualmente en cada archivo, extendemos el ejecutor base con test.extend<T>() para habilitar inyección de dependencias tipada:
import { test as baseTest } from '@playwright/test';
// 1. Declaración de la interfaz de fixtures
export interface AppFixtures {
checkoutPage: CheckoutPage;
catalogPage: CatalogPage;
}
// 2. Creación del runner con ciclo de vida automatizado
export const test = baseTest.extend<AppFixtures>({
checkoutPage: async ({ page }, use) => {
// Fase SETUP: instanciación del Page Object
const checkoutPage = new CheckoutPage(page);
// Cesión del control al cuerpo de la prueba
await use(checkoutPage);
// Fase TEARDOWN (se ejecuta automáticamente tras la prueba)
},
catalogPage: async ({ page }, use) => {
const catalogPage = new CatalogPage(page);
await use(catalogPage);
},
});
export { expect } from '@playwright/test';Las aplicaciones de gran envergadura comparten estructuras comunes: Cabeceras (Header), Barras laterales (Sidebar), Cajón del carrito (CartDrawer) y Notificaciones. Desarrollar Page Objects monolíticos de mil líneas es un antipatrón; descomponer en widgets asegura modularidad.
import { type Page, type Locator, expect } from '@playwright/test';
export class HeaderWidget {
private readonly root: Locator;
private readonly cartButton: Locator;
private readonly cartBadge: Locator;
private readonly searchInput: Locator;
private readonly userMenuButton: Locator;
constructor(page: Page) {
// Encapsulamos el contenedor raíz de la cabecera
this.root = page.getByRole('banner');
this.cartButton = this.root.getByRole('button', { name: /carrito/i });
this.cartBadge = this.cartButton.getByTestId('cart-items-count');
this.searchInput = this.root.getByRole('searchbox', { name: /buscar productos/i });
this.userMenuButton = this.root.getByRole('button', { name: /perfil de usuario/i });
}
async openCart(): Promise<void> {
await this.cartButton.click();
}
async searchProduct(query: string): Promise<void> {
await this.searchInput.fill(query);
await this.searchInput.press('Enter');
}
async expectCartItemCount(count: number): Promise<void> {
if (count === 0) {
await expect(this.cartBadge).toBeHidden();
} else {
await expect(this.cartBadge).toHaveText(String(count));
}
}
}Ahora la página ShopPage compone dicho HeaderWidget:
import { type Page, type Locator } from '@playwright/test';
import { HeaderWidget } from '../widgets/HeaderWidget';
export class ShopPage {
public readonly header: HeaderWidget;
private readonly productList: Locator;
constructor(private readonly page: Page) {
this.header = new HeaderWidget(page);
this.productList = page.getByRole('main').getByRole('list');
}
async navigate(): Promise<void> {
await this.page.goto('/shop');
}
async addProductToCart(productName: string): Promise<void> {
const productCard = this.productList.getByRole('listitem').filter({
hasText: productName,
});
await productCard.getByRole('button', { name: /añadir a la cesta/i }).click();
}
}Este patrón garantiza que cada prueba trabaje con entidades únicas sembradas vía API y limpie sus registros automáticamente tras la ejecución, impidiendo la degradación de la base de datos de pruebas.
import { test as baseTest, expect } from '@playwright/test';
import { ShopPage } from '../pages/ShopPage';
import { CheckoutPage } from '../pages/CheckoutPage';
export interface TestUser {
id: string;
email: string;
token: string;
}
export interface CustomFixtures {
shopPage: ShopPage;
checkoutPage: CheckoutPage;
temporaryCustomer: TestUser;
}
export const test = baseTest.extend<CustomFixtures>({
// Fixture para crear un cliente efímero aislado vía API
temporaryCustomer: async ({ request }, use) => {
// 1. SETUP: Sembrar usuario único mediante petición HTTP veloz
const uniqueId = `e2e_${Date.now()}_${Math.random().toString(36).substring(7)}`;
const createRes = await request.post('/api/testing/create-ephemeral-user', {
data: {
email: `${uniqueId}@agenticlab-test.internal`,
role: 'customer',
},
});
expect(createRes.ok()).toBeTruthy();
const customer = (await createRes.json()) as TestUser;
// 2. YIELD: ceder la entidad al contexto de la prueba
await use(customer);
// 3. TEARDOWN: eliminación garantizada del usuario tras la prueba
const deleteRes = await request.delete(`/api/testing/cleanup-user/${customer.id}`, {
headers: { Authorization: `Bearer ${customer.token}` },
});
expect(deleteRes.ok()).toBeTruthy();
},
shopPage: async ({ page }, use) => {
const shopPage = new ShopPage(page);
await use(shopPage);
},
checkoutPage: async ({ page }, use) => {
const checkoutPage = new CheckoutPage(page);
await use(checkoutPage);
},
});
export { expect };Combinando POM y fixtures personalizadas, el escenario resulta conciso, legible y resistente a alteraciones cosméticas de marcado:
import { test, expect } from '../fixtures/test-suite';
test.describe('Comercio Electrónico: Tramitación de Pedidos', () => {
test('el cliente añade un artículo a la cesta y concluye el pago', async ({
shopPage,
checkoutPage,
temporaryCustomer,
}) => {
// 1. Navegación a la tienda
await shopPage.navigate();
// 2. Incorporar producto y verificar contador mediante widget compuesto
await shopPage.addProductToCart('Curso de Arquitectura React 19');
await shopPage.header.expectCartItemCount(1);
// 3. Acceder al checkout desde la cabecera
await shopPage.header.openCart();
await checkoutPage.navigate();
// 4. Completar datos con la entidad sembrada
await checkoutPage.fillPaymentForm({
cardNumber: '4000 1234 5678 9010',
cardHolder: temporaryCustomer.email.toUpperCase(),
expiryDate: '12/28',
cvv: '789',
});
// 5. Tramitar y verificar resultado
await checkoutPage.submitOrder();
await checkoutPage.expectOrderSuccessful();
});
});Cuando los métodos del POM devuelven instancias de Locator al exterior, la prueba compradora se acopla a la estructura interna del DOM, quebrando el principio de encapsulación.
// ❌ INCORRECTO: La página expone el localizador en bruto
export class CartPage {
getCheckoutButton(): Locator {
return this.page.locator('#btn-checkout-submit');
}
}
// En la prueba:
await cartPage.getCheckoutButton().click();
// ✅ CORRECTO: Encapsular la acción y verificar estados dentro del POM
export class CartPage {
async proceedToCheckout(): Promise<void> {
await this.page.getByRole('button', { name: 'Ir al pago' }).click();
}
}Crear páginas manualmente mediante new en bloques beforeEach anula la inicialización perezosa de Playwright y complica la gestión de dependencias compartidas.
// ❌ INCORRECTO: beforeEach farragoso con instanciación manual
let loginPage: LoginPage;
let dashboardPage: DashboardPage;
test.beforeEach(async ({ page }) => {
loginPage = new LoginPage(page);
dashboardPage = new DashboardPage(page);
});
// ✅ CORRECTO: Inyección de dependencias limpia con test.extend
test('inicio de sesión exitoso', async ({ loginPage, dashboardPage }) => {
await loginPage.login('[email protected]', 'password');
await dashboardPage.expectLoaded();
});Omitir await al invocar use() o no llamarlo en absoluto impide que Playwright transfiera el control a la prueba, provocando bloqueos indefinidos hasta expirar los timeouts del worker.
// ❌ INCORRECTO: Falta await o se omite use
myFixture: async ({ page }, use) => {
const service = new MyService(page);
use(service); // Error: no aguarda la resolución de la prueba
}
// ✅ CORRECTO: Aguardar imperativamente la ejecución del test
myFixture: async ({ page }, use) => {
const service = new MyService(page);
await use(service); // Suspende la ejecución hasta que la prueba concluye
}Los métodos instrumentales (como fillForm()) no deben incorporar aserciones propias de escenarios concretos. Si un método de entrada valida un título específico, quedará inhabilitado para pruebas negativas (ej. verificar la reacción ante emails inválidos).
Definir CustomFixtures enumerando Page Objects, contextos y generadores de datos inyectables.
Se ejecuta antes del test: instancia Page Objects, realiza llamadas backend o siembra datos.
Cede el control al cuerpo del test. La función de fixture se pausa mientras transcurre la prueba.
El test invoca métodos de la fixture sin acceder al DOM crudo.
El código posterior a await use() se ejecuta siempre: elimina entidades y libera recursos.