level 04 / page-object-model

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

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');
});
Key insight: Fixtures are Playwright’s dependency injection. Tests declare what they need in the callback signature; the framework constructs and tears down. No new, no manual cleanup, no forgotten teardown.

Try it: a page object in pure TypeScript

Interview questions