← Blog

"Playwright Series #5: Page Object Model — Structuring Tests for Scale"

Raw test code breaks as your app grows. The Page Object Model wraps every page into a reusable class, so when the UI changes you fix one file instead of fifty tests.

reading now
views
comments

Series Navigation

Part 4: Assertions — Expect, Soft Assertions and Custom Matchers

Part 6: Network Interception and API Mocking


The Problem With Raw Tests

Here's a realistic test written without any structure:

test('user can check out', async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('Email').fill('user@example.com');
  await page.getByLabel('Password').fill('secret123');
  await page.getByRole('button', { name: 'Log in' }).click();
  await expect(page).toHaveURL('/dashboard');

  await page.getByRole('link', { name: 'Shop' }).click();
  await page.getByTestId('product-card').first().getByRole('button', { name: 'Add to Cart' }).click();
  await page.getByRole('link', { name: 'Cart' }).click();
  await page.getByRole('button', { name: 'Proceed to Checkout' }).click();
  await page.getByLabel('Card number').fill('4242424242424242');
  await page.getByLabel('Expiry').fill('12/28');
  await page.getByLabel('CVV').fill('123');
  await page.getByRole('button', { name: 'Pay $49.99' }).click();
  await expect(page.getByRole('heading', { name: 'Order confirmed' })).toBeVisible();
});

This works. But now imagine you have 30 tests that all start with the login block. When the label for the password field changes from "Password" to "Enter password", you update 30 tests.

The Page Object Model fixes this.


What Is the Page Object Model?

The Page Object Model (POM) is a design pattern where each page (or significant section) of your application gets its own class. The class:

  • Exposes actions — methods that represent what a user can do on that page (login(), addToCart())
  • Exposes assertions — methods that verify the page state (expectLoggedIn(), expectOrderConfirmed())
  • Hides locators — internal implementation details the test code doesn't need to know

Tests become sequences of high-level actions:

// With POM — reads like a user story
await loginPage.login('user@example.com', 'secret123');
await shopPage.addFirstItemToCart();
await cartPage.proceedToCheckout();
await checkoutPage.payWithCard({ number: '4242...', expiry: '12/28', cvv: '123' });
await confirmationPage.expectOrderConfirmed();

Project Structure

tests/
├── pages/
│   ├── LoginPage.ts
│   ├── ShopPage.ts
│   ├── CartPage.ts
│   ├── CheckoutPage.ts
│   └── ConfirmationPage.ts
├── fixtures/
│   └── index.ts          ← custom test fixtures
├── checkout.spec.ts
├── login.spec.ts
└── shop.spec.ts

Writing a Page Object

LoginPage.ts

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

export class LoginPage {
  readonly page: Page;

  // Locators as readonly properties
  readonly emailInput: Locator;
  readonly passwordInput: Locator;
  readonly submitButton: Locator;
  readonly errorMessage: Locator;

  constructor(page: Page) {
    this.page = page;
    this.emailInput   = page.getByLabel('Email');
    this.passwordInput = page.getByLabel('Password');
    this.submitButton  = page.getByRole('button', { name: 'Log in' });
    this.errorMessage  = page.getByRole('alert');
  }

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

  // Actions
  async login(email: string, password: string) {
    await this.emailInput.fill(email);
    await this.passwordInput.fill(password);
    await this.submitButton.click();
  }

  async loginAndExpectDashboard(email: string, password: string) {
    await this.login(email, password);
    await expect(this.page).toHaveURL('/dashboard');
  }

  // Assertions
  async expectError(message: string) {
    await expect(this.errorMessage).toContainText(message);
  }

  async expectLoginPage() {
    await expect(this.page).toHaveURL('/login');
    await expect(this.emailInput).toBeVisible();
  }
}

ShopPage.ts

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

export class ShopPage {
  readonly page: Page;
  readonly productCards: Locator;
  readonly cartLink: Locator;
  readonly cartCount: Locator;

  constructor(page: Page) {
    this.page = page;
    this.productCards = page.getByTestId('product-card');
    this.cartLink  = page.getByRole('link', { name: 'Cart' });
    this.cartCount = page.getByTestId('cart-count');
  }

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

  async addToCart(productName: string) {
    const card = this.productCards.filter({ hasText: productName });
    await card.getByRole('button', { name: 'Add to Cart' }).click();
  }

  async addFirstItemToCart() {
    await this.productCards.first()
      .getByRole('button', { name: 'Add to Cart' })
      .click();
  }

  async goToCart() {
    await this.cartLink.click();
  }

  async expectCartCount(count: number) {
    await expect(this.cartCount).toHaveText(String(count));
  }
}

CheckoutPage.ts

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

interface CardDetails {
  number: string;
  expiry: string;
  cvv: string;
}

export class CheckoutPage {
  readonly page: Page;
  readonly cardNumberInput: Locator;
  readonly expiryInput: Locator;
  readonly cvvInput: Locator;
  readonly payButton: Locator;
  readonly orderSummary: Locator;

  constructor(page: Page) {
    this.page = page;
    this.cardNumberInput = page.getByLabel('Card number');
    this.expiryInput     = page.getByLabel('Expiry');
    this.cvvInput        = page.getByLabel('CVV');
    this.payButton       = page.getByRole('button', { name: /Pay \$/ });
    this.orderSummary    = page.getByTestId('order-summary');
  }

  async payWithCard(card: CardDetails) {
    await this.cardNumberInput.fill(card.number);
    await this.expiryInput.fill(card.expiry);
    await this.cvvInput.fill(card.cvv);
    await this.payButton.click();
  }

  async expectTotal(amount: string) {
    await expect(this.payButton).toContainText(amount);
  }

  async expectOrderSummaryContains(text: string) {
    await expect(this.orderSummary).toContainText(text);
  }
}

Using Page Objects in Tests

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

test('complete purchase flow', async ({ page }) => {
  const loginPage    = new LoginPage(page);
  const shopPage     = new ShopPage(page);
  const checkoutPage = new CheckoutPage(page);

  await loginPage.goto();
  await loginPage.loginAndExpectDashboard('user@example.com', 'secret123');

  await shopPage.goto();
  await shopPage.addToCart('Wireless Headphones');
  await shopPage.expectCartCount(1);
  await shopPage.goToCart();

  await checkoutPage.expectTotal('$49.99');
  await checkoutPage.payWithCard({
    number: '4242424242424242',
    expiry: '12/28',
    cvv: '123'
  });

  await expect(page.getByRole('heading', { name: 'Order confirmed' })).toBeVisible();
});

test('login fails with wrong password', async ({ page }) => {
  const loginPage = new LoginPage(page);

  await loginPage.goto();
  await loginPage.login('user@example.com', 'wrongpassword');
  await loginPage.expectError('Invalid credentials');
  await loginPage.expectLoginPage();
});

Clean. Readable. If the login form changes, you update LoginPage.ts once.


Custom Fixtures — Injecting Page Objects

Manually instantiating page objects in every test is repetitive. Playwright fixtures let you inject them automatically:

// tests/fixtures/index.ts
import { test as base } from '@playwright/test';
import { LoginPage }    from '../pages/LoginPage';
import { ShopPage }     from '../pages/ShopPage';
import { CheckoutPage } from '../pages/CheckoutPage';

type Fixtures = {
  loginPage:    LoginPage;
  shopPage:     ShopPage;
  checkoutPage: CheckoutPage;
};

export const test = base.extend<Fixtures>({
  loginPage: async ({ page }, use) => {
    await use(new LoginPage(page));
  },
  shopPage: async ({ page }, use) => {
    await use(new ShopPage(page));
  },
  checkoutPage: async ({ page }, use) => {
    await use(new CheckoutPage(page));
  },
});

export { expect } from '@playwright/test';

Now import from your fixture file instead of @playwright/test:

// tests/checkout.spec.ts
import { test, expect } from './fixtures';

// Page objects injected automatically — no manual instantiation
test('purchase flow', async ({ loginPage, shopPage, checkoutPage, page }) => {
  await loginPage.goto();
  await loginPage.loginAndExpectDashboard('user@example.com', 'secret123');

  await shopPage.goto();
  await shopPage.addToCart('Wireless Headphones');
  await shopPage.goToCart();

  await checkoutPage.payWithCard({
    number: '4242424242424242',
    expiry: '12/28',
    cvv: '123'
  });

  await expect(page.getByRole('heading', { name: 'Order confirmed' })).toBeVisible();
});

Fixtures With Setup State

Fixtures can also pre-run setup — like logging in before every test:

type AuthFixtures = {
  authenticatedPage: Page;
};

export const test = base.extend<AuthFixtures>({
  authenticatedPage: async ({ page }, use) => {
    // Log in before the test
    await page.goto('/login');
    await page.getByLabel('Email').fill('user@example.com');
    await page.getByLabel('Password').fill('secret123');
    await page.getByRole('button', { name: 'Log in' }).click();
    await page.waitForURL('/dashboard');

    // Run the test with this pre-authenticated page
    await use(page);

    // Optionally clean up after the test
  },
});

// Tests that need an authenticated user
test('dashboard loads', async ({ authenticatedPage }) => {
  await expect(authenticatedPage.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
});

In Part 7 (Authentication) we'll use storageState to do this much more efficiently.


Base Page Class

For shared functionality across all page objects, create a base class:

// tests/pages/BasePage.ts
import { Page } from '@playwright/test';

export abstract class BasePage {
  constructor(readonly page: Page) {}

  async waitForPageLoad() {
    await this.page.waitForLoadState('networkidle');
  }

  async getPageTitle() {
    return this.page.title();
  }

  async scrollToBottom() {
    await this.page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
  }

  async dismissCookieBanner() {
    const banner = this.page.getByTestId('cookie-banner');
    if (await banner.isVisible()) {
      await banner.getByRole('button', { name: 'Accept' }).click();
    }
  }
}
// Extend it
export class LoginPage extends BasePage {
  constructor(page: Page) {
    super(page);
    // ... locators
  }

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

Component Objects for Repeated UI

Some UI patterns appear on multiple pages — a header, a data table, a pagination control. Extract these as component objects:

// tests/components/DataTable.ts
import { Locator, expect } from '@playwright/test';

export class DataTable {
  constructor(private readonly locator: Locator) {}

  row(text: string) {
    return this.locator.getByRole('row').filter({ hasText: text });
  }

  async expectRowCount(count: number) {
    await expect(this.locator.getByRole('row')).toHaveCount(count + 1); // +1 for header
  }

  async clickAction(rowText: string, action: string) {
    await this.row(rowText).getByRole('button', { name: action }).click();
  }

  async expectRowVisible(text: string) {
    await expect(this.row(text)).toBeVisible();
  }
}
// Use it in any page object
export class OrdersPage extends BasePage {
  readonly ordersTable: DataTable;

  constructor(page: Page) {
    super(page);
    this.ordersTable = new DataTable(page.getByRole('table', { name: 'Orders' }));
  }

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

// In a test
test('orders table shows recent orders', async ({ page }) => {
  const ordersPage = new OrdersPage(page);
  await ordersPage.goto();
  await ordersPage.ordersTable.expectRowCount(10);
  await ordersPage.ordersTable.expectRowVisible('Order #1042');
  await ordersPage.ordersTable.clickAction('Order #1042', 'View');
});

What's Next

In Part 6 we cover network interception and API mocking — how to intercept HTTP requests from your tests, mock API responses, simulate error states and test without a real backend.

Discussion

Loading...

Leave a Comment

All comments are reviewed before appearing. No links please.

0 / 1000