Skip to content
ExponaBLOG
PLAYWRIGHT & BROWSER AUTOMATION · DAY 012 / 100

Page Object Model with Playwright: a step-by-step guide

Keep tests readable as suites grow.

Expona Admin7 min read

Test suites grow fast. What starts as a handful of smoke tests becomes dozens, then hundreds of test cases across your application. Without structure, you'll find yourself drowning in brittle locators, duplicated click sequences, and tests that break whenever the UI shifts even slightly. The Page Object Model with Playwright: a step-by-step guide is your answer to this chaos—a pattern that keeps tests readable, maintainable, and scalable as your automation suite matures.

One class per page — Locators and actions in one place

The foundation of the Page Object Model is simple: create a class that represents a single page or logical UI section. All locators for that page live in one place, and all interactions with that page happen through this class's methods. This centralization means when a button moves or an ID changes, you fix it once, not fifty times across your test suite.

Let's say we're testing a typical e-commerce login flow. Instead of scattering selectors throughout your tests, we create a LoginPage class:

typescript
import { Page, Locator } from '@playwright/test';

export class LoginPage {
  readonly page: Page;
  readonly emailInput: Locator;
  readonly passwordInput: Locator;
  readonly submitButton: Locator;
  readonly errorMessage: Locator;

  constructor(page: Page) {
    this.page = page;
    this.emailInput = page.locator('input[name="email"]');
    this.passwordInput = page.locator('input[name="password"]');
    this.submitButton = page.locator('button[type="submit"]');
    this.errorMessage = page.locator('[data-testid="error-message"]');
  }

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

Notice we're storing locators as class properties. This lets us reference them consistently and makes it trivial to update them later. If the team changes the email input's selector, we update it in one place and every test using this page object automatically benefits.

In larger applications, you might have multiple pages. A dashboard with different sections could warrant separate classes—one for the header, one for the sidebar, one for the main content area. This granularity helps keep each class focused and testable.

Expose actions — login(email, password) instead of raw clicks

Page objects shine when you abstract low-level UI interactions into high-level business actions. Rather than exposing raw locators and asking tests to orchestrate clicks and fills, the page object should expose semantic methods that mirror what a user actually does.

Extending our LoginPage, we add action methods:

typescript
export class LoginPage {
  readonly page: Page;
  readonly emailInput: Locator;
  readonly passwordInput: Locator;
  readonly submitButton: Locator;
  readonly errorMessage: Locator;

  constructor(page: Page) {
    this.page = page;
    this.emailInput = page.locator('input[name="email"]');
    this.passwordInput = page.locator('input[name="password"]');
    this.submitButton = page.locator('button[type="submit"]');
    this.errorMessage = page.locator('[data-testid="error-message"]');
  }

  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();
    await this.page.waitForLoadState('networkidle');
  }

  async loginAndExpectError(email: string, password: string) {
    await this.emailInput.fill(email);
    await this.passwordInput.fill(password);
    await this.submitButton.click();
    await this.errorMessage.waitFor({ state: 'visible' });
  }

  async getErrorText(): Promise {
    return this.errorMessage.textContent();
  }
}

Now tests call loginPage.login('user@example.com', 'password') instead of manually filling inputs and clicking buttons. The action encapsulates the correct sequence of steps, including necessary waits. If the team discovers a new delay after login, you add one wait inside the method—not in twenty different tests.

This is where page objects transform from convenience to genuine leverage. Tests become readable from a business perspective. A test that reads "navigate to dashboard, add item to cart, proceed to checkout" is infinitely clearer than one showing raw input fills and button clicks.

Keep assertions in tests — Page objects act; tests verify

A common mistake is putting assertions inside page object methods. Resist this temptation. Page objects should navigate and interact; tests should verify outcomes. This separation keeps concerns clear and makes it easier to reuse page object methods across different test scenarios.

For example, a bad approach:

typescript
// Don't do this
export class LoginPage {
  async login(email: string, password: string) {
    await this.emailInput.fill(email);
    await this.passwordInput.fill(password);
    await this.submitButton.click();
    await this.page.waitForLoadState('networkidle');
    
    // Assertions belong in tests, not here
    await expect(this.page).toHaveURL('/dashboard');
  }
}

A good approach keeps page objects clean and testable:

typescript
// Better: page object only acts
export class LoginPage {
  async login(email: string, password: string) {
    await this.emailInput.fill(email);
    await this.passwordInput.fill(password);
    await this.submitButton.click();
    await this.page.waitForLoadState('networkidle');
  }
}

// Tests verify outcomes
test('successful login redirects to dashboard', async ({ page }) => {
  const loginPage = new LoginPage(page);
  await loginPage.goto();
  await loginPage.login('user@example.com', 'correct-password');
  
  await expect(page).toHaveURL('/dashboard');
});

This design is more flexible. The same login() method works for tests checking successful login, tests checking subsequent user actions, or tests verifying that the user is added to an analytics event. Each test stays focused on its specific assertion, while the page object remains a reusable tool.

Combine with fixtures — Inject page objects automatically

Playwright's fixture system pairs beautifully with page objects. Instead of instantiating page objects in every test, define a fixture that creates them automatically. This reduces boilerplate and ensures consistent setup across your suite.

Here's a typical fixture setup:

typescript
import { test as base, expect } from '@playwright/test';
import { LoginPage } from './pages/LoginPage';
import { DashboardPage } from './pages/DashboardPage';
import { CartPage } from './pages/CartPage';

type Pages = {
  loginPage: LoginPage;
  dashboardPage: DashboardPage;
  cartPage: CartPage;
};

export const test = base.extend({
  loginPage: async ({ page }, use) => {
    const loginPage = new LoginPage(page);
    await use(loginPage);
  },
  dashboardPage: async ({ page }, use) => {
    const dashboardPage = new DashboardPage(page);
    await use(dashboardPage);
  },
  cartPage: async ({ page }, use) => {
    const cartPage = new CartPage(page);
    await use(cartPage);
  },
});

export { expect };

Now tests import this custom test function and receive page objects as parameters:

typescript
import { test, expect } from './fixtures';

test('add item to cart and checkout', async ({ loginPage, dashboardPage, cartPage }) => {
  await loginPage.goto();
  await loginPage.login('user@example.com', 'password');
  
  await dashboardPage.addItemToCart('Widget Pro');
  await dashboardPage.openCart();
  
  const cartTotal = await cartPage.getTotal();
  expect(cartTotal).toBe('$99.99');
});

The test doesn't create page objects; Playwright handles that via fixtures. This approach scales well: as your suite grows, you add new page objects to the fixture file once, and every test automatically has access to them. Teardown also becomes automatic—Playwright manages the lifecycle.

Common mistakes

Mixing locators across page objects: If a modal appears on multiple pages, resist the urge to put its selectors in each page object class. Create a separate ModalPage class and compose it where needed, or use shared utility functions for truly generic components.

Page objects that do too much: A single class should represent a single, cohesive view or component. If your page object has 30 methods, it's probably representing too much. Break it into smaller, focused classes.

Over-engineering before you need it: Start with page objects for pages you test frequently. You don't need one for every modal or overlay on day one. Let the pattern emerge naturally as your suite grows.

Storing test data in page objects: Page objects should not hardcode usernames, emails, or test credentials. Pass these as parameters. This keeps page objects reusable across different test environments and data scenarios.

Ignoring waits: A common trap is creating an action method that doesn't wait for the result. If login() doesn't wait for the dashboard to load, tests become flaky. Always think about what state the application should reach after an action completes, and wait for it.

Key takeaways

  • Page Object Model centralizes locators and interactions into classes, reducing duplication and simplifying maintenance as test suites grow.
  • Expose semantic action methods like login() instead of raw interactions; tests become readable from a business perspective.
  • Keep assertions in tests, not in page objects; this separation makes page objects reusable and keeps test intent clear.
  • Use Playwright fixtures to inject page objects automatically, reducing boilerplate and ensuring consistent setup.
  • Start simple and let the pattern evolve with your suite; resist over-engineering before you have a real need.
  • Always include appropriate waits inside action methods so page objects are resilient and tests don't become flaky.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles