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