Playwright & Web Automation Hub

Playwright architecture, selector reliability, and advanced interaction patterns.

Structuring Large Projects with the Page Object Model

A handful of test files can get away with inlining selectors and navigation steps, but once a suite grows past a few dozen specs the duplication starts to hurt: a single button rename forces edits in twenty places, and every file reinvents how to log in. The Page Object Model fixes this by giving each screen one class that owns its locators and its actions, so specs read like user intent and selectors live in exactly one place. This page shows how to lay out the directories, wire page objects into tests through fixtures so you never hand-instantiate them, and keep locators decoupled from test logic as the suite scales. It is the practical build-out of Page Object Model Design, which sits under Playwright Setup & Core Architecture.

Layered Page Object Model structure Spec files depend on fixtures, fixtures construct page objects, and page objects own the locators that touch the page. tests/ *.spec.ts fixtures/ test.extend() pages/ page objects locators getByRole() utils/ shared by pages and fixtures typed helpers
Dependencies flow one way: specs lean on fixtures, fixtures build page objects, and only page objects hold locators.

Root cause: brittle suites come from coupling specs to the DOM

A test that contains page.click('.btn-primary.submit') is welded to the current markup. Rename the class, restructure the form, and that test breaks even though the feature still works. Multiply that by every spec that touches the same screen and a small UI change becomes a day of mechanical edits. The deeper problem is that test files are carrying two responsibilities at once — describing what the user does and knowing how to find each element — so neither concern can change independently. The Page Object Model separates them: a page class is the single home for every locator and action on a screen, the spec calls high-level methods, and a fixture constructs the page object so the test never news it up by hand. Get those three layers right and a markup change touches exactly one file.

Minimal reproducible example

A page object exposes locators and intent-named methods; it never appears in a spec without going through a fixture. The example below defines a login page, registers it as a fixture, and consumes it in a spec.

// pages/login-page.ts
import { type Page, type Locator } from '@playwright/test';

export class LoginPage {
  // Locators are defined once, by accessible role, not by brittle CSS.
  private readonly username: Locator;
  private readonly password: Locator;
  private readonly submit: Locator;

  constructor(private readonly page: Page) {
    this.username = page.getByLabel('Username');
    this.password = page.getByLabel('Password');
    this.submit = page.getByRole('button', { name: 'Sign in' });
  }

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

  // Intent-named action the spec can call; the spec never sees a selector.
  async signIn(user: string, pass: string): Promise<void> {
    await this.username.fill(user);
    await this.password.fill(pass);
    await this.submit.click();
    await this.page.waitForURL('**/dashboard'); // deterministic, no fixed delay
  }
}
// fixtures/test.ts — inject page objects so specs never instantiate them
import { test as base } from '@playwright/test';
import { LoginPage } from '../pages/login-page';

type Pages = { loginPage: LoginPage };

export const test = base.extend<Pages>({
  loginPage: async ({ page }, use) => {
    await use(new LoginPage(page)); // built fresh per test, scoped to its page
  },
});
export { expect } from '@playwright/test';
// tests/auth.spec.ts — reads as user intent, free of selectors
import { test, expect } from '../fixtures/test';

test('user signs in', async ({ loginPage, page }) => {
  await loginPage.goto();
  await loginPage.signIn('admin', 'secret');
  await expect(page.getByRole('heading', { name: 'Welcome' })).toBeVisible();
});

Step-by-step fix

  1. Separate directories by responsibility. Create pages/, fixtures/, tests/, and utils/, and keep root config out of business logic so dependencies flow one way from specs down to locators.
  2. Give each screen one page class. Define every locator and action for a screen in a single class whose constructor takes the Page, so the screen has exactly one source of truth.
  3. Define locators by role, not by CSS chains. Use getByRole(), getByLabel(), and getByTestId() inside the page object; avoid page.$() and page.$$(), which bypass auto-waiting and return stale handles.
  4. Expose intent-named methods. Name methods after what the user does (signIn, addToCart) and keep selectors private, so specs describe behavior rather than mechanics.
  5. Inject page objects through fixtures. Register each page object with test.extend() and hand it to the test via use(), so no spec ever constructs a page object by hand and each one is scoped to the test's page.
  6. Synchronize with auto-waiting, never fixed delays. Use await page.waitForURL() and expect(locator).toBeVisible() inside the page object; replace any waitForTimeout() with a deterministic condition.

Troubleshooting variants

Specs still reference selectors directly

If a spec contains a raw selector, a locator escaped its page object. Move that locator into the relevant page class and expose a method instead, so the only file that knows the markup is the page object. This keeps a UI rename to a one-line change and is the core discipline behind the wider Reliable Selector Strategies for Playwright guidance.

State leaks between tests running in parallel

Phantom authentication or data pollution across workers means a context or page object is being shared instead of created per test. Construct each page object inside a fixture so it is bound to that test's page, and let each test get its own context as described in How to Configure Multiple Browser Contexts in Playwright. Do not reach for clearCookies() mid-test as a substitute for real isolation.

A lazy-loaded component is not ready when the page object acts on it

Single-page apps defer hydration until network calls resolve, so an action can fire before the element mounts. Inside the page object, guard the interaction with an auto-waiting assertion such as await expect(this.widget).toBeVisible() or expect.poll() rather than waitForLoadState('networkidle'), which can hang on long-polling endpoints.

Verification

Confirm the structure holds three ways. First, grep the tests/ directory for raw selectors — a clean result proves locators stay inside page objects. Second, run the suite fully parallel (npx playwright test --fully-parallel) and repeat it; stable results show page objects are correctly scoped per test. Third, rename one selector in a single page class and confirm every spec that uses that screen still passes without edits, which is the whole point of the model. For per-worker setup that complements this layout, see Setting Up Global Fixtures for Parallel Tests.

Frequently Asked Questions

Should a page object contain assertions?

Keep most assertions in the spec so the page object stays a reusable description of the screen. The one exception is a synchronization assertion inside an action, such as waiting for a heading to confirm navigation completed, which is part of performing the action reliably.

How do I avoid instantiating page objects by hand in every test?

Register each page object as a fixture with test.extend() and provide it through use(). Tests then receive a ready, page-scoped instance as a parameter, so no spec calls new on a page object and lifecycle is consistent.

Why prefer getByRole over CSS selectors in page objects?

getByRole and getByLabel target the accessibility tree, which is far more stable than class names and survives most layout refactors. They also auto-wait, unlike page.$(), so locators defined this way resist both flakiness and brittle coupling to markup.

Back to overview