Quando uma barra de navegação aparece em toda página, duplicar seus locators em cada page object é o primeiro sinal de que você precisa de component objects. Uma classe NavigationBar composta em BasePage faz toda página herdar nav.logout() sem copiar código. Fluxos de múltiplas etapas precisam de um padrão diferente. proceedToCheckout() retornando um CheckoutPage e continueToPayment() retornando um PaymentPage dão ao sistema de tipos um mapa preciso de onde cada ação aterrissa, tornando os testes legíveis como o próprio fluxo.

O problema com o POM ingênuo

O POM básico funciona para páginas simples. Ele quebra quando:

  • A barra de navegação aparece em toda página. Você a duplica em cada page object?
  • Um modal pode aparecer a partir de múltiplas páginas. Onde ele fica?
  • Um formulário complexo de múltiplas etapas abrange várias páginas. Como encadeá-las?
  • Locators ficam desatualizados porque a estrutura da página muda com frequência

Os padrões avançados de POM resolvem esses problemas.

Component objects: partes de UI reutilizáveis

Extraia elementos de UI compartilhados para suas próprias classes:

// 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 com composição

Em vez de herança em todo lugar, componha 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 e 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();
  }
}

// Nos testes
test('admin pode deletar um usuário', 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);
  // Nav também está disponível:
  await usersPage.nav.logout();
});

Linhas e tabelas dinâmicas

Lide com listas e tabelas com conteúdo 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();
  }
}

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

// Nos testes
test('pedidos ordenados por data', 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!));
});

Fluxos de múltiplas etapas: encadeamento de páginas

Para fluxos de múltiplas etapas como checkout, retorne a próxima página em cada etapa:

// 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);
  }
}

// Teste usando o encadeamento
test('fluxo completo de checkout', 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();
});

O padrão builder para configuração complexa

Ao criar dados de teste complexos ou preencher formulários complexos:

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

  withName(firstName: string, lastName: string) {
    this.data.firstName = firstName;
    this.data.lastName = lastName;
    return this;  // Interface fluente — retorna this para encadeamento
  }

  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 ?? [],
    };
  }
}

// Nos testes — muito legível
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 o resto

Inicialização lazy de locators

Calcule locators uma vez, no primeiro acesso:

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;
  }
}

Resumo

| Padrão | Problema que resolve |

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

| Component objects | UI compartilhada (nav, modais) sem duplicação |

| Composição em BasePage | Funcionalidade comum sem cadeias profundas de herança |

| Classe DataTable | Listas/tabelas dinâmicas com operações de linha reutilizáveis |

| Encadeamento de páginas | Fluxos de múltiplas etapas com transições de página tipadas |

| Padrão builder | Dados de teste complexos e preenchimento de formulários |

| Locators lazy | Performance — calcula só quando necessário |

O princípio central: page objects devem corresponder a como os usuários pensam sobre o app, não como o DOM está estruturado. Quando seus page objects leem como uma história de usuário ("prosseguir para o checkout", "preencher dados de envio", "submeter pedido"), eles estão no nível certo de abstração.

→ Veja também: Page Object Model no Playwright: Do Caos à Manutenibilidade | Padrões Avançados de Page Object no Playwright | Fixtures Personalizados no Playwright: O Padrão que Torna os Testes Legíveis | Interfaces e Tipos TypeScript para o Page Object Model