Playwright
Intermediate 14 min read Playwright

Page Objects in Playwright

Page Objects encapsulate selectors and interactions behind a class so tests read as user journeys instead of selector soup. Playwright's fixtures then inject page objects cleanly and give each test a fresh instance.

page object modelpomfixturesencapsulationtest.usecompositionpage object
Introduction

What is this?

The Page Object Model (POM) wraps a page's locators and actions in a plain class — LoginPage, CheckoutPage — so tests call page.fill and page.submit instead of re-typing selectors. Playwright treats these classes as plain code: you instantiate them from fixtures, and locators keep their lazy, auto-waiting behavior.

Motivation

Why do you need it?

  • Selectors live in one place; when markup changes you fix the page object, not fifty tests.
  • Tests read as scenarios ('login(), addToCart(), checkout()') instead of CSS soup, so reviewers understand intent.
  • Reusable behavior (a login helper, a date picker) is shared across suites without copy-paste.
  • Page objects pair naturally with fixtures, giving each test a fresh, correctly-constructed instance.
  • Composable fragments (Header, CartLine) mirror the UI's structure and keep giant classes manageable.
Think about it

Mental model

Imagine

An API client library for a backend

Think of

A page object for a UI

You never hand-build the URL and JSON body inline everywhere; you call client.getUser(id). A page object does the same for the DOM: login() hides page.fill('#email', ...).

Mechanics

How it works

  • A page object class takes the page (or other dependencies) in its constructor and stores them.
  • Properties expose locators, e.g. this.emailInput = page.getByLabel('Email') — lazily resolved, so they stay fresh.
  • Methods compose actions into scenario steps: async login(email, pw) fills fields and clicks submit.
  • A test instantiates the object in a fixture or test body, then calls its methods.
  • Playwright fixtures can build page objects declaratively, so the object is available as a test argument.

How page objects sit between tests and the browser.

test() body
Page object method
Encapsulated locators
Auto-wait + act
State asserted in test
Fresh instance per test
Reference

Key commands

test.extend({ loginPage: async ({ page }, use) => { ... } })

Declare a fixture that builds and injects a page object into matching tests.

class LoginPage { constructor(private readonly page: Page) {} }

The page object holds the page and exposes methods and locator properties.

await use(loginPage)

Hand the constructed object to the test body; cleanup runs after use returns.

test.use({ storageState: 'state.json' })

Seed an authenticated context so page objects can skip the login screen.

npx playwright test --grep @smoke

Tag page-object-backed tests and run only the smoke-tagged subset.

Hands-on

Examples

A LoginPage page object wired through a fixture

A LoginPage page object wired through a fixture
import { test, expect } from "@playwright/test";
class LoginPage {
constructor(private readonly page: import("@playwright/test").Page) {}
async goto() {
await this.page.goto("https://example.com/login");
}
async login(email: string, password: string) {
await this.page.getByLabel("Email").fill(email);
await this.page.getByLabel("Password").fill(password);
await this.page.getByRole("button", { name: "Sign in" }).click();
}
get errorBanner() {
return this.page.getByTestId("login-error");
}
}
const testWithLogin = test.extend<{ loginPage: LoginPage }>({
loginPage: async ({ page }, use) => {
const loginPage = new LoginPage(page);
await use(loginPage);
},
});
testWithLogin("shows an error for bad credentials", async ({ loginPage }) => {
await loginPage.goto();
await loginPage.login("wrong@example.com", "nope");
await expect(loginPage.errorBanner).toHaveText("Invalid email or password");
});

LoginPage keeps every selector and interaction for the login screen. The extended test fixture constructs a fresh LoginPage per test and injects it, so tests describe intent ('login with wrong credentials') rather than DOM operations. Locators remain lazy, so the page object is safe to reuse across assertions.

Real world

In production

  • Auth flows are modeled once and shared by dozens of suites, with storageState letting later suites skip login entirely.
  • Checkout page objects group cart, shipping and payment steps, so billing changes touch one class.
  • Fragment objects (Header, ProductCard) compose into full pages, mirroring the component tree.
  • Config-driven page objects read test users and URLs from env, keeping the same code green across staging and prod.
Guidelines

Best practices

  • Keep page objects free of assertions — leave verification in the test so objects stay reusable.
  • Expose locator getters (get submitButton) and full methods (submitOrder) rather than raw selector strings.
  • One object per screen or significant region; don't build a God object covering the whole app.
  • Compose smaller fragments instead of inheriting — prefer 'has a' over 'is a'.
  • Return meaningful results (e.g. a CheckoutPage) from methods that navigate, enabling fluent chains.
Watch out

Common mistakes

  • Putting assertions inside page objects, which couples them to specific test outcomes.
  • Storing raw selectors as strings and calling page.locator repeatedly inside every method.
  • Instantiating a new object per test method body instead of using fixtures, losing cleanup symmetry.
  • Copying the same login snippet into every test instead of a fixture or storageState.
Root cause analysis

Common issues

Error
TypeError: Cannot read properties of undefined (reading 'getByLabel')
Root cause

The page object was constructed without the page, or used before the fixture initialized it.

Fix

Ensure the fixture creates the object after the page fixture resolves: ({ page }, use) => use(new LoginPage(page)).

Error
Page object works in one test but not another
Root cause

Tests reuse state or the object holds a stale page from a previous navigation.

Fix

Make each test create its own object via the fixture and always begin with a goto() to a known URL.

Error
Test timed out inside a page object method
Root cause

A locator inside the method matched nothing because the screen never rendered.

Fix

Assert an earlier step first (e.g. await expect(page).toHaveURL(/login/)) before calling login().

When things break

Debugging tips

  • Put a console.log of the page object's locator count in a scratch test to confirm construction.
  • Use codegen against the page and paste the generated locators straight into the page object.
  • When a page object method fails, the trace shows the exact step — run with --trace on and inspect the frame.
  • Keep method names symmetrical with user steps (goto → login → logout) so failures read as a story.
Interviews

Interview checkpoint

Practice

Hands-on lab

Build a Checkout Page Object
40 min · intermediate
In progress
Objectives
  • Model a checkout screen as a page object with locator getters
  • Compose a full checkout journey in one method
  • Inject the object through a custom fixture
  • Refactor three duplicated tests onto the page object

Build a Checkout Page Object

40 min · intermediate

0/4 steps
0%
sandbox · Linux sandbox
$
Try these commands
Test yourself

Quick quiz

0/3
1

Where should selectors and interaction logic live in the Page Object Model?

2

Page objects should contain assertions so they verify themselves.

3

A team duplicates a 5-line login sequence in 30 tests. The best first step is to:

Quick reference

Cheat sheet

Page object shape
constructor(private readonly page: Page) {}Inject the page
get emailInput() { return this.page.getByLabel('Email') }Expose locator getters
async login(email, pw) { ... }Compose scenario steps
await use(instance)Provide via fixture
async goto() { await this.page.goto(url) }Navigate to the screen
Go deeper

Official documentation