Playwright & Web Automation Hub

Playwright architecture, selector reliability, and advanced interaction patterns.

Page Object Model Design

The Page Object Model is the pattern that keeps a Playwright suite maintainable as it grows past a handful of specs. Instead of scattering selectors across every test, you encapsulate each screen's locators and user actions inside a class that receives the Page through its constructor. Tests then express intent — loginPage.submitCredentials(user, pass) — while the brittle details of how an element is found live in exactly one place. When the UI is refactored, you edit one class, not fifty specs. This guide covers the architectural rules, how to write async action methods that wait correctly, how to wire page objects into fixtures, and how to structure them by feature domain so they compose at scale. It sits beneath Playwright Setup & Core Architecture.

Tests depend on page objects, which own selectors Test specs call methods on page object classes, which encapsulate the locators that touch the DOM, isolating selector changes to one layer. login.spec.ts test intent checkout.spec.ts test intent Page objects locators + async methods DOM selectors
Specs depend on page objects, which own the selectors — so a DOM change ripples through one class, not every test.

Architectural foundations

A clean page object obeys a few rules. Map each route or feature domain to one class. Receive the Page instance through the constructor so the object is bound to a single isolated context. Declare every locator once as a readonly field, built with Playwright's role- and label-based queries so resolution stays unambiguous. Expose only public async methods for user actions and state queries, and keep raw selectors private to the class. The result is a layer where test code never names a CSS string directly.

This topology eliminates selector duplication and centralizes maintenance — the same goal that motivates the broader Playwright Setup & Core Architecture object model. The selectors themselves should follow the resilience rules in Reliable Selector Strategies for Playwright: prefer accessible roles and stable text anchors over auto-generated classes, because a page object is only as durable as the locators it wraps.

Async action methods with built-in waiting

Every method on a page object is asynchronous and returns a typed Promise. The discipline that keeps these methods reliable is never sleeping — Playwright's locators auto-wait for actionability, and any genuine synchronization point should be an explicit condition like waitForURL() rather than a fixed delay. A login method, for example, fills the form, clicks submit, and then asserts the navigation it expects, so the method only resolves once the app has actually moved on.

import { type Page, type Locator } from '@playwright/test';

export class LoginPage {
  readonly page: Page;
  readonly username: Locator;
  readonly submit: Locator;

  constructor(page: Page) {
    this.page = page;
    // Role/label locators survive class and id churn.
    this.username = page.getByRole('textbox', { name: /username/i });
    this.submit = page.getByRole('button', { name: 'Sign In' });
  }

  async navigate(): Promise<void> {
    await this.page.goto('/auth/login');
  }

  async submitCredentials(user: string, pass: string): Promise<void> {
    await this.username.fill(user);
    await this.page.getByLabel('Password').fill(pass);
    await this.submit.click();
    // Resolve only when the app has actually navigated — no sleeps.
    await this.page.waitForURL('**/dashboard');
  }
}

Chain an expect(locator).toBeVisible() immediately before any interaction that depends on a prior transition, and return Promise<T> from query methods so callers get typed data instead of raw strings.

Integrating page objects with fixtures

Instantiating a page object by hand in every test reintroduces boilerplate. The fix is a fixture: extend the base test object with a factory that builds the page object, optionally drives it to a known state, and hands it to the test body. The runner then injects a ready-to-use object per spec, and tears down its context afterward, so isolation is preserved automatically.

import { test as base, expect } from '@playwright/test';
import { LoginPage } from './pages/LoginPage';

type Fixtures = { loginPage: LoginPage };

export const test = base.extend<Fixtures>({
  loginPage: async ({ page }, use) => {
    const loginPage = new LoginPage(page);
    await loginPage.navigate();      // arrive at a known starting state
    await use(loginPage);            // hand the ready object to the test
  },
});

test('redirects to the dashboard after login', async ({ loginPage }) => {
  await loginPage.submitCredentials('admin', 'securePass');
  await expect(loginPage.page).toHaveURL(/dashboard/);
});

The fixture lifecycle — worker versus test scope, dependency chaining, and shared use() configuration — is the domain of Playwright Config & Fixtures. Because each fixture-built page object binds to a fresh context, the isolation guarantees from Browser Contexts & Isolation carry through unchanged, keeping parallel workers from leaking state across page objects.

Structuring for scale

A monolithic pages/ folder collapses under its own weight once the app has dozens of screens. Organize page objects by feature domain — auth/, checkout/, admin/ — rather than by raw URL, so shared components like a data grid or a modal live in one reusable class instead of being copied per route. Compose larger flows from smaller objects, and reuse a DataGrid component object across every screen that renders a table.

import { type Page, type Locator, expect } from '@playwright/test';

export class DataGrid {
  readonly page: Page;
  readonly rows: Locator;

  constructor(page: Page) {
    this.page = page;
    this.rows = page.locator('tr.data-row');
  }

  async waitForData(timeout = 10_000): Promise<void> {
    // Wait for the first row to attach, then for the count to settle.
    await this.rows.first().waitFor({ state: 'attached', timeout });
    await expect(this.rows).toHaveCount(await this.rows.count(), { timeout });
  }

  async cellText(row: number): Promise<string> {
    return (await this.rows.nth(row).textContent()) ?? '';
  }
}

Enable test.describe.configure({ mode: 'parallel' }) so independent specs distribute across CI shards, and attach trace snapshots on failure for diagnosis. The full directory topology, component-reuse strategy, and large-repository patterns are documented in Structuring Large Projects with the Page Object Model.

Component objects and composition

Not every reusable piece is a full page. Modals, navigation bars, date pickers, and data grids appear across many screens, and modeling each as a component object — a class that wraps a root locator and exposes the actions for that widget — avoids duplicating its logic in every page that hosts it. A page object then composes the components it contains rather than re-declaring their selectors, which is the single biggest lever for keeping a large suite small.

import { type Page, type Locator } from '@playwright/test';
import { DataGrid } from './components/DataGrid';

export class OrdersPage {
  readonly page: Page;
  readonly grid: DataGrid;          // composed, not re-implemented
  readonly newOrder: Locator;

  constructor(page: Page) {
    this.page = page;
    this.grid = new DataGrid(page); // reuse the component everywhere a grid appears
    this.newOrder = page.getByRole('button', { name: 'New order' });
  }

  async openLatestOrder(): Promise<void> {
    await this.grid.waitForData();
    await this.grid.rows.first().click();
  }
}

Scoping a component to a root locator with locator.locator(...) also makes it safe when two instances of the same widget appear on one page — each component object queries only inside its own subtree, so the locators never collide. This scoping discipline mirrors the strict, unambiguous selector rules in Reliable Selector Strategies for Playwright.

Where assertions belong

A recurring design question is whether assertions live in the page object or the test. The pragmatic answer is both, split by purpose. Keep business-level assertions — "the order total equals the sum of line items" — in the test, because they express what the spec is verifying. Push readiness assertions — "the grid has finished loading," "the modal is open" — into the page object's methods, because they are preconditions for the action, not the thing under test. This split keeps tests readable as statements of intent while ensuring that an action method never proceeds against a half-rendered screen. It also means a method like waitForData() can be reused by twenty specs without each one re-implementing the same readiness check.

Avoiding the common anti-patterns

Most page object failures trace back to two habits. The first is wrapping fragile selectors — auto-generated ids, hashed class names — which makes the whole abstraction brittle; audit locators and replace them with semantic roles, accessible labels, and stable text. The second is reintroducing implicit timing through page.waitForTimeout(); replace it with auto-retrying locators and expect.poll() for conditions that genuinely need polling. Keep test data generation scoped per context so outcomes stay deterministic across distributed runners, and lean on strict locator mode, which throws on ambiguous matches during development before they reach CI.

Frequently Asked Questions

What exactly belongs inside a page object?

Locators for the screen's elements and async methods for the actions and queries a user performs on it. Keep raw selectors private and expose only intent-revealing methods. Assertions can live in the test or in dedicated verification methods, but the selectors themselves should never appear in spec files.

How do I avoid creating a page object by hand in every test?

Wrap it in a fixture. Extend the base test object with a factory that constructs the page object, drives it to a known state, and hands it to the test through use(). The runner then injects a ready instance per spec and tears down its context automatically.

Should I organize page objects by URL or by feature?

By feature domain. URL-based folders fragment shared components across routes, while feature folders let a single DataGrid or modal class be reused everywhere it appears. Compose larger flows from these smaller component objects to maximize reuse.

Back to overview