Cuando una barra de navegación aparece en todas las páginas, duplicar sus locators en cada page object es la primera señal de que necesitás objetos de componente: una clase NavigationBar compuesta en BasePage para que cada página herede nav.logout() sin copiar código. Los flujos de varios pasos necesitan un patrón diferente: proceedToCheckout() que devuelve un CheckoutPage y continueToPayment() que devuelve un PaymentPage le da al sistema de tipos un mapa preciso de dónde aterriza cada acción, así los tests se leen como el flujo mismo. Esta guía cubre objetos de componente para UI compartida como navbars y modales, el enfoque de composición para BasePage, componentes dinámicos de tabla, cadenas de páginas tipadas para flujos de varios pasos, y el patrón builder para armar datos de prueba sin constructores gigantes.

El problema del POM ingenuo

El POM básico funciona para páginas simples. Se rompe cuando:

  • La barra de navegación aparece en todas las páginas: ¿la duplicas en cada page object?
  • Un modal puede aparecer desde múltiples páginas: ¿dónde vive?
  • Un formulario complejo de varios pasos abarca múltiples páginas: ¿cómo los encadenas?
  • Los locators se vuelven stale porque la estructura de la página cambia con frecuencia

Objetos de componente: partes de UI reutilizables

Extrae los elementos de UI compartidos en sus propias clases:

// components/NavigationBar.ts
import { Page, Locator } from '@playwright/test';

export class NavigationBar {
  readonly page: Page;
  readonly userMenu: Locator;
  readonly notificationBell: Locator;
  readonly searchBar: Locator;
  readonly logo: Locator;

  constructor(page: Page) {
    this.page = page;
    this.userMenu = page.getByTestId('user-menu');
    this.notificationBell = page.getByTestId('notification-bell');
    this.searchBar = page.getByTestId('nav-search');
    this.logo = page.getByTestId('logo');
  }

  async logout() {
    await this.userMenu.click();
    await this.page.getByTestId('logout-option').click();
  }

  async goToProfile() {
    await this.userMenu.click();
    await this.page.getByTestId('profile-option').click();
  }

  async search(query: string) {
    await this.searchBar.fill(query);
    await this.searchBar.press('Enter');
  }
}

// components/ConfirmationModal.ts
import { Page, Locator } from '@playwright/test';

export class ConfirmationModal {
  readonly page: Page;
  readonly modal: Locator;
  readonly title: Locator;
  readonly message: Locator;
  readonly confirmButton: Locator;
  readonly cancelButton: Locator;

  constructor(page: Page) {
    this.page = page;
    this.modal = page.getByTestId('confirmation-modal');
    this.title = page.getByTestId('modal-title');
    this.message = page.getByTestId('modal-message');
    this.confirmButton = page.getByTestId('modal-confirm');
    this.cancelButton = page.getByTestId('modal-cancel');
  }

  async confirm() {
    await this.confirmButton.click();
    await this.modal.waitFor({ state: 'hidden' });
  }

  async cancel() {
    await this.cancelButton.click();
    await this.modal.waitFor({ state: 'hidden' });
  }

  async waitForOpen() {
    await this.modal.waitFor({ state: 'visible' });
  }
}

BasePage con composición

En lugar de herencia en todas partes, compón las páginas a partir de componentes:

// pages/BasePage.ts
import { Page } from '@playwright/test';
import { NavigationBar } from '../components/NavigationBar';
import { ConfirmationModal } from '../components/ConfirmationModal';

export abstract class BasePage {
  readonly page: Page;
  readonly nav: NavigationBar;
  readonly confirmModal: ConfirmationModal;

  constructor(page: Page) {
    this.page = page;
    this.nav = new NavigationBar(page);
    this.confirmModal = new ConfirmationModal(page);
  }

  abstract navigate(): Promise<void>;

  async waitForPageLoad() {
    await this.page.waitForLoadState('networkidle');
  }

  async getTitle() {
    return this.page.title();
  }
}

// pages/UserManagementPage.ts
import { Page, Locator } from '@playwright/test';
import { BasePage } from './BasePage';

export class UserManagementPage extends BasePage {
  readonly userRows: Locator;
  readonly addUserButton: Locator;
  readonly searchInput: Locator;

  constructor(page: Page) {
    super(page);  // Configura nav y confirmModal
    this.userRows = page.getByTestId('user-row');
    this.addUserButton = page.getByTestId('add-user');
    this.searchInput = page.getByTestId('user-search');
  }

  async navigate() {
    await this.page.goto('/admin/users');
    await this.waitForPageLoad();
  }

  async deleteUser(userId: number) {
    await this.page.getByTestId(`delete-user-${userId}`).click();
    await this.confirmModal.waitForOpen();
    await this.confirmModal.confirm();
  }
}

// En los tests
test('el admin puede eliminar un usuario', async ({ page }) => {
  const usersPage = new UserManagementPage(page);
  await usersPage.navigate();
  
  const initialCount = await usersPage.userRows.count();
  await usersPage.deleteUser(123);
  
  await expect(usersPage.userRows).toHaveCount(initialCount - 1);
  // La nav también está disponible:
  await usersPage.nav.logout();
});

Filas y tablas dinámicas

Maneja listas y tablas con contenido dinámico:

// components/DataTable.ts
import { Page, Locator } from '@playwright/test';

export class DataTable {
  readonly rows: Locator;
  readonly headers: Locator;
  readonly sortButtons: Locator;
  readonly pagination: Locator;

  constructor(page: Page, tableTestId: string) {
    const table = page.getByTestId(tableTestId);
    this.rows = table.getByTestId('table-row');
    this.headers = table.getByTestId('table-header');
    this.sortButtons = table.getByTestId('sort-button');
    this.pagination = table.getByTestId('pagination');
  }

  getRow(index: number) {
    return this.rows.nth(index);
  }

  async getRowByText(text: string) {
    return this.rows.filter({ hasText: text });
  }

  async getCellValue(row: number, column: number) {
    return this.rows.nth(row)
      .getByTestId('table-cell')
      .nth(column)
      .textContent();
  }

  async sortBy(columnName: string) {
    await this.sortButtons.filter({ hasText: columnName }).click();
  }

  async getCount() {
    return this.rows.count();
  }
}

// En un page object
class OrdersPage extends BasePage {
  readonly ordersTable: DataTable;
  
  constructor(page: Page) {
    super(page);
    this.ordersTable = new DataTable(page, 'orders-table');
  }
}

// En los tests
test('órdenes ordenadas por fecha', async ({ page }) => {
  const ordersPage = new OrdersPage(page);
  await ordersPage.navigate();
  
  await ordersPage.ordersTable.sortBy('Date');
  
  const firstDate = await ordersPage.ordersTable.getCellValue(0, 2);
  const secondDate = await ordersPage.ordersTable.getCellValue(1, 2);
  
  expect(new Date(firstDate!)).toBeGreaterThan(new Date(secondDate!));
});

Flujos de varios pasos: cadenas de páginas

Para flujos de varios pasos como el checkout, devolvé la siguiente página desde cada paso:

// pages/checkout/CartPage.ts
import { Page } from '@playwright/test';
import { CheckoutPage } from './CheckoutPage';

export class CartPage extends BasePage {
  constructor(page: Page) {
    super(page);
  }

  async navigate() {
    await this.page.goto('/cart');
  }

  async proceedToCheckout(): Promise<CheckoutPage> {
    await this.page.getByTestId('proceed-checkout').click();
    await this.page.waitForURL('/checkout/shipping');
    return new CheckoutPage(this.page);
  }
}

// pages/checkout/CheckoutPage.ts
import { Page } from '@playwright/test';
import { PaymentPage } from './PaymentPage';

export class CheckoutPage extends BasePage {
  readonly firstName: Locator;
  readonly lastName: Locator;
  readonly address: Locator;
  readonly city: Locator;
  
  constructor(page: Page) {
    super(page);
    this.firstName = page.getByTestId('first-name');
    this.lastName = page.getByTestId('last-name');
    this.address = page.getByTestId('address');
    this.city = page.getByTestId('city');
  }

  async fillShipping(details: ShippingDetails): Promise<void> {
    await this.firstName.fill(details.firstName);
    await this.lastName.fill(details.lastName);
    await this.address.fill(details.address);
    await this.city.fill(details.city);
  }

  async continueToPayment(): Promise<PaymentPage> {
    await this.page.getByTestId('continue-payment').click();
    await this.page.waitForURL('/checkout/payment');
    return new PaymentPage(this.page);
  }
}

// Test usando la cadena
test('flujo de checkout completo', async ({ page }) => {
  const cart = new CartPage(page);
  await cart.navigate();
  
  const checkout = await cart.proceedToCheckout();
  await checkout.fillShipping({
    firstName: 'John', lastName: 'Doe',
    address: '123 Main St', city: 'Portland',
  });
  
  const payment = await checkout.continueToPayment();
  await payment.fillCard({ number: '4242424242424242', expiry: '12/28', cvc: '123' });
  
  const confirmation = await payment.submitOrder();
  await expect(confirmation.orderNumber).toBeVisible();
});

El patrón builder para setup complejo

Para crear datos de prueba complejos o llenar formularios extensos:

class UserFormBuilder {
  private data: Partial<UserFormData> = {};

  withName(firstName: string, lastName: string) {
    this.data.firstName = firstName;
    this.data.lastName = lastName;
    return this;  // Interfaz fluida — devuelve this para encadenar
  }

  withEmail(email: string) {
    this.data.email = email;
    return this;
  }

  withRole(role: 'admin' | 'member') {
    this.data.role = role;
    return this;
  }

  asAdmin() {
    this.data.role = 'admin';
    this.data.permissions = ['manage-users', 'view-reports'];
    return this;
  }

  build(): UserFormData {
    return {
      firstName: this.data.firstName ?? 'Test',
      lastName: this.data.lastName ?? 'User',
      email: this.data.email ?? `test_${Date.now()}@example.com`,
      role: this.data.role ?? 'member',
      permissions: this.data.permissions ?? [],
    };
  }
}

// En los tests — muy legible
const adminUser = new UserFormBuilder()
  .withName('Alice', 'Smith')
  .withEmail('alice@test.com')
  .asAdmin()
  .build();

const regularUser = new UserFormBuilder()
  .withEmail('bob@test.com')
  .build();  // Usa defaults para todo lo demás

Inicialización lazy de locators

Calcula los locators una sola vez, al primer acceso:

class ProductPage extends BasePage {
  private _productCards?: Locator;
  private _filterPanel?: Locator;
  
  get productCards(): Locator {
    if (!this._productCards) {
      this._productCards = this.page.getByTestId('product-card');
    }
    return this._productCards;
  }
  
  get filterPanel(): Locator {
    if (!this._filterPanel) {
      this._filterPanel = this.page.getByTestId('filter-panel');
    }
    return this._filterPanel;
  }
}

Referencia de patrones

| Patrón | Problema que resuelve |

|--------|-----------------------|

| Objetos de componente | UI compartida (nav, modales) sin duplicación |

| Composición en BasePage | Funcionalidad común sin cadenas de herencia profundas |

| Clase DataTable | Listas/tablas dinámicas con operaciones de fila reutilizables |

| Patrón de cadena de páginas | Flujos de varios pasos con transiciones de página tipadas |

| Patrón builder | Datos de prueba complejos y llenado de formularios |

| Locators lazy | Rendimiento: calculados solo cuando se necesitan |

El principio clave: los page objects deberían coincidir con cómo el usuario piensa sobre la app, no con cómo está estructurado el DOM. Cuando tus page objects se leen como una historia de usuario ("proceder al checkout", "completar datos de envío", "enviar orden"), están en el nivel correcto de abstracción.

→ See also: Page Object Model en Playwright: De Caótico a Mantenible | Patrones Avanzados de Page Object en Playwright | Fixtures Personalizados en Playwright: El Patrón que Hace los Tests Legibles | Interfaces y Tipos de TypeScript para Page Object Model