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.
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
- Separate directories by responsibility. Create
pages/,fixtures/,tests/, andutils/, and keep root config out of business logic so dependencies flow one way from specs down to locators. - 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. - Define locators by role, not by CSS chains. Use
getByRole(),getByLabel(), andgetByTestId()inside the page object; avoidpage.$()andpage.$$(), which bypass auto-waiting and return stale handles. - Expose intent-named methods. Name methods after what the user does (
signIn,addToCart) and keep selectors private, so specs describe behavior rather than mechanics. - Inject page objects through fixtures. Register each page object with
test.extend()and hand it to the test viause(), so no spec ever constructs a page object by hand and each one is scoped to the test's page. - Synchronize with auto-waiting, never fixed delays. Use
await page.waitForURL()andexpect(locator).toBeVisible()inside the page object; replace anywaitForTimeout()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.