El Page Object Model básico se rompe cuando la misma tabla de datos aparece en cinco páginas y sus locators están duplicados cinco veces, o cuando loginWith() devuelve Page en lugar de DashboardPage y los tests tienen que adivinar en qué página aterrizaron. Este artículo cubre los patrones que resuelven esos problemas: una clase abstracta BasePage para navegación y espera de carga compartidas, objetos de componente para secciones de UI reutilizables como tablas de datos, un fixture PageFactory que entrega todos los page objects a través de un único parámetro pages, y encadenamiento fluido de métodos donde cada método devuelve el siguiente page object. También cubre cuándo parar: el POM básico con una clase por página es la decisión correcta para la mayoría de los proyectos con menos de 50 tests.
Clase base de página
Todos los page objects de tu suite comparten comportamiento común: navegar a la página, esperar a que cargue, verificar el título. Extrae esto en una BasePage:
// pages/BasePage.ts
import { Page, Locator } from '@playwright/test';
export abstract class BasePage {
protected page: Page;
protected readonly url: string;
constructor(page: Page, url: string) {
this.page = page;
this.url = url;
}
async navigate() {
await this.page.goto(this.url);
await this.waitForLoad();
}
// Sobreescribir en subclases para esperar indicadores de carga específicos de la página
protected async waitForLoad() {
await this.page.waitForLoadState('domcontentloaded');
}
async getTitle() {
return this.page.title();
}
async getURL() {
return this.page.url();
}
}Página concreta:
// pages/LoginPage.ts
import { Page, Locator } from '@playwright/test';
import { BasePage } from './BasePage';
import { DashboardPage } from './DashboardPage';
export class LoginPage extends BasePage {
private readonly emailInput: Locator;
private readonly passwordInput: Locator;
private readonly submitButton: Locator;
private readonly errorMessage: Locator;
constructor(page: Page) {
super(page, '/login');
this.emailInput = page.getByLabel('Email');
this.passwordInput = page.getByLabel('Password');
this.submitButton = page.getByRole('button', { name: 'Log in' });
this.errorMessage = page.getByRole('alert');
}
protected async waitForLoad() {
await this.emailInput.waitFor({ state: 'visible' });
}
async loginWith(email: string, password: string): Promise<DashboardPage> {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.submitButton.click();
return new DashboardPage(this.page);
}
async getErrorMessage() {
return this.errorMessage.textContent();
}
}El tipo de retorno DashboardPage hace el flujo explícito: loginWith() devuelve la página en la que aterrizas.
Objetos de componente
Las páginas grandes tienen secciones que se repiten: una tabla de datos, un widget de notificaciones, un panel de filtros que aparece en varias páginas. Extrae estos en objetos de componente:
// components/DataTable.ts
import { Page, Locator } from '@playwright/test';
export class DataTable {
private readonly container: Locator;
constructor(container: Locator) {
this.container = container;
}
get rows() {
return this.container.getByRole('row');
}
async getRowCount() {
return this.rows.count();
}
async getRowByText(text: string) {
return this.rows.filter({ hasText: text });
}
async clickAction(rowText: string, actionName: string) {
const row = this.rows.filter({ hasText: rowText });
await row.getByRole('button', { name: actionName }).click();
}
async sortBy(columnName: string) {
await this.container
.getByRole('columnheader', { name: columnName })
.click();
}
}Uso en page objects:
// pages/OrdersPage.ts
import { Page } from '@playwright/test';
import { BasePage } from './BasePage';
import { DataTable } from '../components/DataTable';
export class OrdersPage extends BasePage {
readonly ordersTable: DataTable;
constructor(page: Page) {
super(page, '/orders');
this.ordersTable = new DataTable(
page.getByTestId('orders-table')
);
}
}Test:
test('la tabla de órdenes muestra los datos correctos', async ({ page }) => {
const ordersPage = new OrdersPage(page);
await ordersPage.navigate();
expect(await ordersPage.ordersTable.getRowCount()).toBeGreaterThan(0);
await ordersPage.ordersTable.clickAction('Order #123', 'View details');
});El mismo componente DataTable se puede reutilizar en ProductsPage, UsersPage o en cualquier otro lugar donde aparezca una tabla.
Factories de page objects
Cuando tienes muchos page objects y los tests necesitan navegar entre ellos, una factory evita la instanciación repetitiva:
// pages/PageFactory.ts
import { Page } from '@playwright/test';
import { LoginPage } from './LoginPage';
import { DashboardPage } from './DashboardPage';
import { OrdersPage } from './OrdersPage';
import { ProfilePage } from './ProfilePage';
export class PageFactory {
constructor(private page: Page) {}
get login() { return new LoginPage(this.page); }
get dashboard() { return new DashboardPage(this.page); }
get orders() { return new OrdersPage(this.page); }
get profile() { return new ProfilePage(this.page); }
}Con un fixture:
// fixtures/pages.ts
import { test as base } from '@playwright/test';
import { PageFactory } from '../pages/PageFactory';
export const test = base.extend<{ pages: PageFactory }>({
pages: async ({ page }, use) => {
await use(new PageFactory(page));
},
});Test:
import { test } from '../fixtures/pages';
import { expect } from '@playwright/test';
test('el usuario puede ver sus órdenes', async ({ pages }) => {
await pages.login.navigate();
const dashboard = await pages.login.loginWith('user@example.com', 'password');
await pages.orders.navigate();
expect(await pages.orders.ordersTable.getRowCount()).toBeGreaterThan(0);
});API fluida (encadenamiento de métodos)
Para mejorar la legibilidad de los tests, los métodos pueden devolver this para permitir el encadenamiento:
// pages/CheckoutPage.ts
export class CheckoutPage extends BasePage {
async fillShipping(address: ShippingAddress) {
await this.page.getByLabel('Street').fill(address.street);
await this.page.getByLabel('City').fill(address.city);
await this.page.getByLabel('Postal code').fill(address.postalCode);
return this;
}
async selectPaymentMethod(method: 'card' | 'paypal') {
await this.page.getByRole('radio', { name: method }).click();
return this;
}
async submit() {
await this.page.getByRole('button', { name: 'Place order' }).click();
return new ConfirmationPage(this.page);
}
}Test:
const confirmation = await checkoutPage
.fillShipping({ street: '123 Main St', city: 'Berlin', postalCode: '10115' })
.then(p => p.selectPaymentMethod('card'))
.then(p => p.submit());
await expect(confirmation.heading).toHaveText('Order confirmed!');Cuándo el POM básico es suficiente
No todos los proyectos necesitan objetos de componente, factories y encadenamiento fluido. El POM básico (una clase por página, locators y métodos) es el nivel correcto para proyectos con 20 a 50 tests, equipos que recién aprenden el patrón POM, y apps con una estructura de navegación simple y mayormente plana.
Agregá complejidad solo cuando el patrón básico esté causando problemas reales: código copiado y pegado que se rompe en varios lugares, dificultad para encontrar dónde están definidos los selectores, tests difíciles de leer porque la relación entre páginas no está clara.
El objetivo siempre son tests legibles y fáciles de mantener, no sofisticación arquitectónica.
→ See also: Page Object Model en Playwright: De Caótico a Mantenible | Interfaces y Tipos de TypeScript para Page Object Model | Fixtures Personalizados en Playwright: El Patrón que Hace los Tests Legibles