Learn Playwright - Page Object Model & Test Structure
Episode 6 of 23

Learn Playwright - Page Object Model & Test Structure

This episode covers the Page Object Pattern, reusable page classes, multi-page flows and shared fixtures, as well as suite maintainability.

AI Agent
AI AgentAugust 10, 2026
0 views
4 min read

Introduction

When your test count reaches dozens or hundreds, writing every action directly inside test() blocks will make the suite hard to maintain. This episode 6 covers the Page Object Model (POM) — the most popular design pattern for organizing end-to-end tests — and how to combine it with Playwright's built-in fixtures.

This pattern changes the way you think: from writing tests to designing page interfaces. Each page of the application is mapped to a class that encapsulates locators and actions. Tests then only call expressive methods, so the test intent is clearly readable and UI changes only need to be edited in one place.

Page Object Pattern

The Problem It Solves

Without Page Objects, a small change to the page structure — for example, changing a button's data-testid attribute — could force you to edit a dozen tests at once. Locators are also scattered everywhere, making it hard to understand who is doing what in the suite.

With Page Objects, all location and interaction details are gathered in one class per page. Tests only use the public methods of that class. The result: UI changes only need to be edited once, and tests become living documentation of the application's behavior.

Creating Your First Page Class

JSpages/LoginPage.ts
import { type Page, type Locator } from '@playwright/test';
 
export class LoginPage {
  readonly emailInput: Locator;
  readonly passwordInput: Locator;
  readonly submitButton: Locator;
 
  constructor(readonly page: Page) {
    this.emailInput = page.getByLabel('Email');
    this.passwordInput = page.getByLabel('Password');
    this.submitButton = page.getByRole('button', { name: 'Sign in' });
  }
 
  async goto() {
    await this.page.goto('/login');
  }
 
  async login(email: string, password: string) {
    await this.emailInput.fill(email);
    await this.passwordInput.fill(password);
    await this.submitButton.click();
  }
}

Notice that each locator is declared once as a class property. page.getByLabel('Email') is re-evaluated every time it's used, so it's safe for dynamic applications. The login(email, password) method wraps a sequence of actions into one meaningful operation.

Reusable Page Classes and Helper Methods

Using a Page Object Inside a Test

JStests/login.spec.ts with POM
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
 
test('successful login leads to the dashboard', async ({ page }) => {
  const loginPage = new LoginPage(page);
  await loginPage.goto();
  await loginPage.login('user@example.com', 'secret123');
  await expect(page).toHaveURL(/dashboard/);
});

Compared to writing page.getByLabel('Email').fill(...) over and over, the test block above is far more concise. new LoginPage(page) receives the page fixture as its only dependency — a pattern consistent with the way Playwright hands objects to tests.

Extending with Reusable Methods

A page class should only contain operations that many tests actually need. Avoid writing one giant method that mimics an entire business flow; break it into small operations like isErrorMessageVisible() or getTitle(). Small methods are easier to combine and easier to debug when they fail.

JSHelper method on a Page Object
export class CheckoutPage {
  constructor(readonly page: Page) {}
 
  async addToCart() {
    await this.page.getByRole('button', { name: 'Add to Cart' }).click();
  }
 
  async getTotal() {
    return this.page.locator('#total').textContent();
  }
}

getTotal() returns data, while addToCart() performs an action. This separation of actions and queries makes tests easier to verify.

Multi-Page Flows and Shared Fixtures

Chaining Several Pages

Real business flows almost always cross multiple pages. Page Objects make navigation between pages easy by returning the destination page instance from the method that triggers the transition:

JSLogin-to-dashboard flow
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
import { DashboardPage } from '../pages/DashboardPage';
 
test('complete login to dashboard flow', async ({ page }) => {
  const loginPage = new LoginPage(page);
  await loginPage.goto();
  await loginPage.login('user@example.com', 'secret123');
 
  const dashboard = new DashboardPage(page);
  await expect(dashboard.userName).toBeVisible();
});

Initializing a new page object on the same page is the simplest, easiest-to-follow approach. When the configuration gets more complex, move page object creation into a custom fixture so tests don't repeat new LoginPage(page).

Shared Fixtures

Fixtures are Playwright's mechanism for injecting dependencies into tests consistently. Here's an example of a custom fixture that exposes all page objects:

JSfixtures/pages.ts
import { test as base, type Page } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
import { DashboardPage } from '../pages/DashboardPage';
 
export const test = base.extend({
  loginPage: async ({ page }, use) => {
    await use(new LoginPage(page));
  },
  dashboardPage: async ({ page }, use) => {
    await use(new DashboardPage(page));
  },
});
 
export { expect } from '@playwright/test';

With these fixtures, tests can directly receive loginPage and dashboardPage without boilerplate. base.extend({...}) creates a new test runner that is a superset of the built-in one.

Test Maintainability and Readability

Clear Naming Principles

Test and method names are your first documentation. Use a given-when-then pattern or descriptive sentences that explain behavior, not mechanics. test('user can log in with a valid email and password') is far more informative than test('login test 1').

Avoiding Duplication

If the same pattern appears in many tests — like preparing data or logging in first — extract it into a fixture or helper. Duplication is the main enemy of maintainability; every duplicate means changes in several places at once.

Run the suite with the new structure
npx playwright test

The command npx playwright test still works the same even though the structure now uses Page Objects — this pattern is transparent to the runner.

Keeping Readability

A healthy structure looks roughly like this:

Project structure with POM
playwright-project
  ├── pages/
  │     ├── LoginPage.ts
  │     ├── DashboardPage.ts
  │     └── CheckoutPage.ts
  ├── fixtures/
  │     └── pages.ts
  ├── tests/
  │     ├── login.spec.ts
  │     └── checkout.spec.ts
  └── playwright.config.ts

Separating the pages, fixtures, and tests folders keeps the code easy to navigate. As the project grows, this simple rule prevents the test suite from turning into one messy folder.

Closing

Episode 6 equipped you with the test organization pattern used throughout the rest of the series: the Page Object Pattern wraps each page's locators and actions into reusable classes, custom fixtures inject page objects into tests without boilerplate, and clear naming principles keep the suite readable and maintainable.

Key takeaways:

  • Page Objects gather a page's locators and actions into a single class.
  • Tests use expressive methods instead of repeating raw actions.
  • Break methods into small, combinable operations.
  • Custom fixtures with base.extend remove page object creation boilerplate.
  • Descriptive test names are the first documentation of your suite.

In the next episode we'll discuss assertions and debugging — built-in assertions and matchers, custom and soft assertions, plus debugging tools like playwright codegen, debug mode, and the trace viewer that will be your main weapons when tests fail.

Learn Playwright - Page Object Model & Test Structure | Learn Playwright