FRAMEWORK DESIGNINTERMEDIATE

Page Object Model with Playwright: A Practical Guide

Structure Playwright tests using Page Objects — reduce duplication, isolate selectors, and make refactors painless.

iff Solution Academy July 3, 2026 14 min read Updated July 8, 2026
Playwright Page Object Model POM Architecture

Why POM Matters

The Page Object Model (POM) is the most widely adopted design pattern in UI test automation, and for good reason. Selectors change, workflows are renamed, and product teams ship UI redesigns constantly. Without a layer of abstraction between your tests and the DOM, every visual change can break dozens of tests and force painful search-and-replace across your entire suite.

POM solves this by encapsulating the structure and behaviour of a single screen (or reusable component) inside a class. Tests describe intent — 'log in as admin', 'add product to cart' — while page objects own the messy details of locators, waits, and navigation. When the UI changes, you edit one file instead of one hundred.

In enterprise Playwright projects, POM is the single biggest factor in keeping a suite maintainable past 500 tests. Combined with fixtures (covered below), it turns test files into readable specifications your product managers can actually review.

Prerequisites

  • Node.js 18+ installed and a working Playwright project (npm init playwright@latest).
  • Comfort with TypeScript classes, async/await, and ES modules.
  • Familiarity with Playwright locators — especially getByRole, getByLabel and getByTestId.
  • A test app to point at (the Playwright demo TodoMVC or your own staging environment).

A Basic Page Object

A page object is just a class that takes the Playwright Page in its constructor, exposes locators as readonly properties, and provides methods for the high-level actions a user can perform on that screen.

pages/LoginPage.ts
import { Page, Locator } from '@playwright/test';

export class LoginPage {
  readonly username: Locator;
  readonly password: Locator;
  readonly submit: Locator;

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

  async goto() {
    await this.page.goto('/login');
  }

  async loginAs(user: string, pass: string) {
    await this.username.fill(user);
    await this.password.fill(pass);
    await this.submit.click();
  }
}

Notice three things: locators are defined once in the constructor (not re-queried in every method), the action method loginAs describes user intent rather than mechanics, and there are no assertions inside the class — those belong in the test.

Using It in a Test

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

test('admin can log in', async ({ page }) => {
  const login = new LoginPage(page);
  await login.goto();
  await login.loginAs('admin', 'secret');
  await expect(page).toHaveURL(/dashboard/);
});

Read that test aloud. It describes what a human user does — no CSS selectors, no waiting logic, no clicks by coordinate. If marketing renames 'Sign in' to 'Log in' tomorrow, you fix one line in LoginPage.ts and every test that uses it stays green.

Level Up with Fixtures

Manually calling new LoginPage(page) in every test gets repetitive. Playwright fixtures let you inject fully-constructed page objects the same way the built-in page fixture works.

fixtures.ts
import { test as base } from '@playwright/test';
import { LoginPage } from './pages/LoginPage';
import { DashboardPage } from './pages/DashboardPage';

type Pages = { login: LoginPage; dashboard: DashboardPage };

export const test = base.extend<Pages>({
  login: async ({ page }, use) => use(new LoginPage(page)),
  dashboard: async ({ page }, use) => use(new DashboardPage(page)),
});
export { expect } from '@playwright/test';
ts
import { test, expect } from '../fixtures';

test('admin sees dashboard', async ({ login, dashboard, page }) => {
  await login.goto();
  await login.loginAs('admin', 'secret');
  await expect(dashboard.welcomeBanner).toBeVisible();
});

Composition Over Inheritance

Beginners often reach for a BasePage class with shared helpers. Resist that reflex. Inheritance chains get tangled fast, and shared 'header' or 'nav' widgets are almost always better modelled as their own component objects that a page object composes.

ts
export class DashboardPage {
  readonly nav = new NavComponent(this.page);
  readonly welcomeBanner: Locator;
  constructor(private page: Page) {
    this.welcomeBanner = page.getByRole('heading', { name: /welcome/i });
  }
}

Common Pitfalls

  • Putting assertions inside page objects — mixes the responsibility of 'act' and 'verify' and hides failures inside deep stack traces.
  • Returning raw ElementHandles from getters — they go stale on re-render. Return Locators.
  • One giant PageObject per feature — split it. If a class has 30 methods, it is really 3 classes.
  • Hardcoded waits (page.waitForTimeout) — rely on Playwright's auto-waiting instead.
  • Duplicating selectors across page objects and tests — the whole point of POM is that selectors live in exactly one place.

Debugging Tips

  • Run with --debug or PWDEBUG=1 to step through page object methods interactively.
  • Enable trace: 'on-first-retry' in playwright.config.ts and inspect failures in the Trace Viewer — you can see exactly which locator inside your page object failed.
  • Log the resolved locator with await locator.evaluate(el => el.outerHTML) when a selector unexpectedly matches the wrong element.
  • Use page.pause() inside a method while iterating on it — the Playwright Inspector lets you tweak selectors live.

When to Use — and When Not To

Use POM when your suite has more than a handful of tests, when the UI changes with any regularity, or when multiple engineers contribute to the same codebase. It is the default for enterprise projects.

Skip POM (or use only lightweight component helpers) for tiny throwaway spikes, one-off smoke tests, or when you are automating a stable third-party UI that will never change. Over-engineering a 5-test suite with a POM layer is real waste.

FAQ

Should page objects extend a common BasePage?

Usually no. Prefer composing shared components (Header, Nav, Modal) into pages. Inheritance couples pages that have no real 'is-a' relationship.

Where do I put shared UI components?

Under components/ next to your pages/ folder. A HeaderComponent that owns the nav locators is instantiated inside every page object that displays a header.

Can page objects call the API?

They can, but prefer to inject an APIRequestContext through a fixture. Keep 'talking to the UI' and 'talking to the backend' as separate collaborators — it makes tests easier to reason about.

How do I handle navigation between pages?

An action that navigates away can return the new page object: async openCart(): Promise<CartPage> { await this.cartLink.click(); return new CartPage(this.page); }

Should page objects use data-testid attributes?

Prefer accessible locators (getByRole, getByLabel) because they double as an accessibility guardrail. Fall back to data-testid only for elements that genuinely have no accessible name — icon buttons, decorative wrappers, third-party embeds. When you do use data-testid, define it in the page object, never in the test.

How big should a page object get?

As a rule of thumb, if a page object grows past 300 lines or exposes more than a dozen actions, the screen it models is probably really two or three collaborating components. Extract them. Small, focused classes are easier to reason about, easier to reuse, and easier to delete when the product changes.

Do I need a separate page object for every URL?

Usually yes — one page object per user-visible screen keeps responsibilities clean. Two exceptions: modals and drawers that appear over any page are better modelled as component objects, and multi-step wizards can share a single page object per step or one aggregate object with clearly named methods per step.

Summary

The Page Object Model is the backbone of any Playwright suite that has to survive real product change. Encapsulate locators and behaviour per screen, keep assertions in tests, inject page objects with fixtures, and prefer composition over inheritance. Do that and your tests read like specifications — while a UI redesign turns from a week of firefighting into a single well-scoped pull request.

The framework does not stop you from writing tests without page objects — Playwright is happy to run raw scripts. But every team we have worked with that scaled past a hundred tests eventually landed on some flavour of POM, because the alternative is a tangled ball of duplicated selectors that resists every UI redesign. Save yourself that migration and adopt the pattern from day one.

Combine everything in this guide — small focused page objects, composition over inheritance, fixtures for dependency injection, assertions kept firmly in the tests — and you have the foundation that the rest of an enterprise framework (custom reporters, tagging, data-driven cases, CI parallelism) builds on top of. Master POM first, and the rest of the automation stack falls into place around it.

When POM Helps and When It Hurts

The Page Object Model earns its keep on applications with stable, repeated screens — login, checkout, admin tables — where the same locators appear in many tests. There, a selector change is one edit instead of forty. It hurts on pages visited by a single test, on highly dynamic dashboards where every widget differs, and in teams that turn page objects into god classes containing assertions, test data, and business rules.

A useful rule: a page object is a locator vocabulary plus the actions a user can perform, and nothing else. It should not know what a correct result looks like, it should not create test data, and it should not decide whether to retry. When those responsibilities creep in, the page object becomes a second, hidden test framework that nobody can safely change.

Review Rules for Page Objects

  • No expect calls inside page objects — assertions belong in tests where the intent is visible.
  • No returning raw Locator objects to tests unless the test genuinely needs to assert on the element; otherwise expose intent-revealing methods.
  • Action methods that navigate should return the next page object, so a flow reads as a chain of screens.
  • Locators declared once as class fields, not re-created inside each method.
  • Component objects for repeated fragments — headers, modals, data tables — instead of duplicating them in every page.

One more practical point that separates a mature suite from a tutorial one: page objects should be constructed by fixtures, not with new inside every test. That gives you one place to attach logging, one place to change constructor arguments, and tests whose first line is already the interesting one.

Frequently Asked Questions

Is POM obsolete now that Playwright has fixtures and getByRole?

No, but its scope has shrunk. Modern suites use smaller page and component objects for locator ownership and let fixtures handle setup, which used to be crammed into base page classes.

Should each page object have a navigate method?

Only for pages a test can legitimately land on directly. Adding goto to a page that is only reachable through a flow encourages tests that skip the flow they were meant to cover.

How do I handle a page with several variants?

Compose from component objects rather than subclassing. Inheritance chains between page objects become very hard to follow after the second level.

Get Playwright tutorials in your inbox

Weekly tips, real-world examples, and framework patterns – no spam, unsubscribe anytime.

Playwright Framework Series

View all →
  1. 1Getting Started with Playwright: Installation, Setup, and Your First Test
  2. 2Playwright Locators: The Complete Guide with Real-World Examples
  3. 3Playwright Actions: Complete Guide to Click, Fill, Hover, Keyboard, Mouse & File Upload
  4. 4Playwright Assertions: The Complete Guide with Real-World Examples
  5. 5Playwright Auto Waiting: The Complete Guide with Real-World Examples
  6. 6Playwright Fixtures: The Complete Guide with Real-World Examples
  7. 7Playwright Browser Context & Multiple Tabs: The Complete Guide with Real-World Examples
  8. 8Playwright Authentication & Session Management: Complete Guide with Enterprise Examples
  9. 9Playwright Network Interception & API Mocking: Complete Guide with Real-World Examples
  10. 10Playwright Page Object Model (POM): The Complete Guide with Enterprise Examples
  11. 11Page Object Model with Playwright: A Practical Guide
  12. 12How to Build a Robust Professional Playwright Framework from Scratch (Step-by-Step)
  13. 13Building an Enterprise Playwright Framework from Scratch

API Testing Series

View all →
  1. 1Playwright API Testing: The Complete Guide with Real-World Examples
  2. 2Part 1 : Playwright API Testing Tutorial: Build Enterprise-Level API Automation Framework
  3. 3Part 2: Playwright API Testing Tutorial: Setting Up Project & Sending Your First API Request
  4. 4Part 3: Mastering CRUD Operations in Playwright API Testing
  5. 5Part 4: Authentication in Playwright API Testing (Bearer Token, JWT, API Key & Reusable Fixtures)
  6. 6Part 5: Building an Enterprise-Level Playwright API Automation Framework
  7. 7Part 6: API Models, Schema Validation & Test Data Management in Playwright
  8. 8Part 7: Custom Fixtures, Hooks, Logging & Parallel Execution in Playwright API Testing
  9. 9Part 8: Building a Production-Ready Playwright API Framework (Environment Management, CI/CD, Reporting & Best Practices)
  10. 10Part 9: Advanced Playwright API Testing Techniques for Enterprise Automation
  11. 11Part 10: Building a Complete Enterprise Playwright API Automation Framework (Final Part)
  12. 12API Testing with Playwright: Request Context, Auth, and Assertions

Recommended Next Articles