Page Object Model
POM done properly — locators as fields, component objects for shared UI, fixture injection, and the navigation-returns-page pattern.
The contract
A page object encapsulates one page (or screen) behind an API that speaks
user language. Tests call inventory.addToCart('Backpack'), never
page.locator('#add-to-cart-sauce-labs-backpack').click().
Locators as readonly fields
Declare locators once in the constructor — not inline in methods. Playwright locators are lazy, so this costs nothing and gives every method the same single source of truth.
import { type Page, type Locator } from '@playwright/test';
export class InventoryPage {
readonly page: Page;
readonly searchBox: Locator;
readonly sortDropdown: Locator;
readonly cartBadge: Locator;
constructor(page: Page) {
this.page = page;
this.searchBox = page.getByPlaceholder('Search products');
this.sortDropdown = page.getByRole('combobox', { name: 'Sort' });
this.cartBadge = page.getByTestId('cart-badge');
}
async goto(): Promise<void> {
await this.page.goto('/inventory.html');
}
async addToCart(productName: string): Promise<void> {
await this.page
.getByRole('listitem')
.filter({ hasText: productName })
.getByRole('button', { name: 'Add to cart' })
.click();
}
async cartCount(): Promise<number> {
if (await this.cartBadge.isHidden()) return 0;
return Number(await this.cartBadge.innerText());
}
}
Navigation returns the next page
When an action navigates, return the destination page object. The framework documents its own page flow, and tests chain naturally:
export class LoginPage {
// ...
async signInExpectingSuccess(user: string, pass: string): Promise<InventoryPage> {
await this.usernameInput.fill(user);
await this.passwordInput.fill(pass);
await this.loginButton.click();
return new InventoryPage(this.page);
}
}
// Test reads as a journey
const inventory = await loginPage.signInExpectingSuccess('standard_user', 'secret_sauce');
await inventory.addToCart('Sauce Labs Backpack');
Component objects for shared UI
A header, cart drawer, or data grid that appears on many pages gets its own object, composed into pages that contain it:
export class HeaderComponent {
readonly cartLink: Locator;
readonly menuButton: Locator;
constructor(private root: Locator) {
this.cartLink = root.getByRole('link', { name: 'Cart' });
this.menuButton = root.getByRole('button', { name: 'Menu' });
}
async openCart(): Promise<void> {
await this.cartLink.click();
}
}
export class InventoryPage {
readonly header: HeaderComponent;
constructor(page: Page) {
// Scope the component to its container — not the whole page
this.header = new HeaderComponent(page.getByRole('banner'));
}
}
// Test: inventory.header.openCart()
Scoping the component to a root Locator (not the Page) means it works
even when two instances exist on one page.
Fixture injection: no new in tests
import { test as base } from '@playwright/test';
import { LoginPage } from '../pages/login.page';
import { InventoryPage } from '../pages/inventory.page';
import { CheckoutPage } from '../pages/checkout.page';
type PageFixtures = {
loginPage: LoginPage;
inventoryPage: InventoryPage;
checkoutPage: CheckoutPage;
};
export const test = base.extend<PageFixtures>({
loginPage: async ({ page }, use) => use(new LoginPage(page)),
inventoryPage: async ({ page }, use) => use(new InventoryPage(page)),
checkoutPage: async ({ page }, use) => use(new CheckoutPage(page)),
});
export { expect } from '@playwright/test';import { test, expect } from '../fixtures/test-fixtures';
// Pages arrive ready-made — zero construction noise
test('checkout totals include tax', async ({ inventoryPage, checkoutPage, page }) => {
await inventoryPage.goto();
await inventoryPage.addToCart('Sauce Labs Backpack');
await checkoutPage.startCheckout();
await expect(page.getByTestId('tax')).toContainText('$2.40');
});new, no manual cleanup, no forgotten teardown.