O Page Object Model básico quebra quando a mesma tabela de dados aparece em cinco páginas e seus locators são duplicados cinco vezes. O problema também surge quando loginWith() retorna Page em vez de DashboardPage e os testes precisam adivinhar em qual página aterrissaram.

Classe base de page object

Todo page object da sua suite compartilha comportamento comum: navegar para a página, aguardar o carregamento, verificar o título. Extraia isso para uma 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();
  }

  // Sobrescreva nas subclasses para aguardar indicadores de carregamento específicos
  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();
  }
}

O tipo de retorno DashboardPage torna o fluxo explícito: loginWith() retorna a página em que você aterrissa.

Component objects

Páginas grandes têm seções que se repetem: uma tabela de dados, um widget de notificação, um painel de filtros que aparece em múltiplas páginas. Extraia-os para component objects:

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

Teste:

test('tabela de pedidos mostra dados corretos', 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');
});

O mesmo componente DataTable pode ser reutilizado em ProductsPage, UsersPage ou em qualquer lugar que apareça uma tabela.

Page object factories

Com muitos page objects e testes que precisam navegar entre eles, uma factory evita instanciação 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); }
}

Com um 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));
  },
});

Teste:

import { test } from '../fixtures/pages';
import { expect } from '@playwright/test';

test('usuário pode ver seus pedidos', 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 fluente (encadeamento de métodos)

Para legibilidade dos testes, métodos podem retornar this para permitir encadeamento:

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

Teste:

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!');

Quando o POM básico é suficiente

Nem todo projeto precisa de component objects, factories e encadeamento fluente. O POM básico (uma classe por página, locators e métodos) é o nível certo para:

  • Projetos com 20 a 50 testes
  • Times novos no POM que ainda estão aprendendo o padrão
  • Apps com uma estrutura de navegação simples e majoritariamente plana

Adicione complexidade só quando o padrão básico estiver causando dor real. Isso significa código copiado que quebra em vários lugares, dificuldade de encontrar onde os seletores estão definidos, ou testes difíceis de ler porque a relação entre páginas não está clara.

O objetivo é sempre testes legíveis e fáceis de manter, não sofisticação arquitetural.

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