What Is the Page Object Model?

The Page Object Model (POM) is the most widely used design pattern in UI test automation. It creates a class for each page or component of your application, encapsulating all interactions with that page.

Without POM — selectors and actions are scattered across tests:

test('user can login', async ({ page }) => {
  await page.fill('#email', 'admin@test.com');
  await page.fill('#password', 'secret');
  await page.click('button.login-btn');
  await expect(page.locator('.welcome-msg')).toHaveText('Welcome, Admin');
});

With POM — page details are encapsulated:

test('user can login', async ({ page }) => {
  const loginPage = new LoginPage(page);
  await loginPage.login('admin@test.com', 'secret');
  const dashboard = new DashboardPage(page);
  await expect(dashboard.welcomeMessage).toHaveText('Welcome, Admin');
});

Core Principles

1. One Class Per Page (or Component)

Each page of your application gets its own class:

pages/
  LoginPage.ts
  DashboardPage.ts
  ProductListPage.ts
  ProductDetailPage.ts
  CheckoutPage.ts
  CartPage.ts

2. Selectors Are Private

Element selectors are implementation details that should not leak into tests:

class LoginPage {
  // Private — only used inside this class
  #emailInput = '[data-testid="email"]';
  #passwordInput = '[data-testid="password"]';
  #submitBtn = '[data-testid="login-submit"]';
  #errorMessage = '.login-error';

  constructor(page) {
    this.page = page;
  }
}

3. Public Methods Represent User Actions

Methods should be named after what the user does, not what the code does:

class LoginPage {
  // Good — user-centric method names
  async login(email, password) { /* ... */ }
  async forgotPassword(email) { /* ... */ }
  async loginWithGoogle() { /* ... */ }

  // Bad — implementation-centric names
  async fillEmailAndClickSubmit() { /* ... */ }
  async clickGoogleButton() { /* ... */ }
}

4. No Assertions in Page Objects

Page objects perform actions and return data. Tests make assertions:

// Page object — returns data, no assertions
class DashboardPage {
  get welcomeMessage() {
    return this.page.locator('.welcome-msg');
  }

  async getUserName() {
    return await this.page.textContent('.user-name');
  }

  async getNotificationCount() {
    const text = await this.page.textContent('.notification-badge');
    return parseInt(text);
  }
}

// Test — makes assertions
test('dashboard shows user name', async ({ page }) => {
  const dashboard = new DashboardPage(page);
  await expect(dashboard.welcomeMessage).toContainText('Admin');
  const count = await dashboard.getNotificationCount();
  expect(count).toBeGreaterThan(0);
});

Complete Page Object Example

// pages/LoginPage.ts
export class LoginPage {
  #emailInput = '[data-testid="email"]';
  #passwordInput = '[data-testid="password"]';
  #submitBtn = '[data-testid="login-submit"]';
  #errorMessage = '[data-testid="login-error"]';
  #googleOAuth = '[data-testid="google-login"]';

  constructor(page) {
    this.page = page;
  }

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

  async login(email, password) {
    await this.page.fill(this.#emailInput, email);
    await this.page.fill(this.#passwordInput, password);
    await this.page.click(this.#submitBtn);
  }

  async loginWithGoogle() {
    await this.page.click(this.#googleOAuth);
  }

  get errorMessage() {
    return this.page.locator(this.#errorMessage);
  }

  async isLoaded() {
    await this.page.waitForSelector(this.#emailInput);
  }
}

Method Chaining

Method chaining allows fluent test syntax by returning page objects from navigation methods:

class LoginPage {
  async login(email, password) {
    await this.page.fill(this.#emailInput, email);
    await this.page.fill(this.#passwordInput, password);
    await this.page.click(this.#submitBtn);
    return new DashboardPage(this.page); // Return next page
  }
}

class DashboardPage {
  async navigateToProfile() {
    await this.page.click('#profile-link');
    return new ProfilePage(this.page);
  }
}

// Fluent test
test('user navigates to profile', async ({ page }) => {
  const loginPage = new LoginPage(page);
  await loginPage.goto();
  const dashboard = await loginPage.login('admin@test.com', 'secret');
  const profile = await dashboard.navigateToProfile();
  await expect(profile.nameField).toHaveValue('Admin User');
});

Component Objects

For UI elements that appear on multiple pages (navigation bar, footer, modals), create component objects:

// components/NavBar.ts
class NavBar {
  constructor(page) {
    this.page = page;
  }

  async searchFor(query) {
    await this.page.fill('#search', query);
    await this.page.press('#search', 'Enter');
  }

  async navigateTo(section) {
    await this.page.click(`nav a[href="/${section}"]`);
  }

  async logout() {
    await this.page.click('#user-menu');
    await this.page.click('#logout');
  }
}

// Compose into page objects
class DashboardPage {
  constructor(page) {
    this.page = page;
    this.navBar = new NavBar(page); // Composition
  }
}

Advanced POM Patterns

Base Page Class

class BasePage {
  constructor(page) {
    this.page = page;
    this.navBar = new NavBar(page);
    this.footer = new Footer(page);
  }

  async goto(path) {
    await this.page.goto(path);
    await this.waitForPageLoad();
  }

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

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

  async takeScreenshot(name) {
    await this.page.screenshot({ path: `screenshots/${name}.png` });
  }
}

Handling Dynamic Elements

class ProductListPage extends BasePage {
  // Dynamic selector based on product name
  productCard(name) {
    return this.page.locator(`.product-card:has-text("${name}")`);
  }

  async addToCart(productName) {
    const card = this.productCard(productName);
    await card.locator('.add-to-cart-btn').click();
  }

  async getPrice(productName) {
    const card = this.productCard(productName);
    const priceText = await card.locator('.price').textContent();
    return parseFloat(priceText.replace('$', ''));
  }

  async getProductCount() {
    return await this.page.locator('.product-card').count();
  }
}

Handling Waits and Loading States

class CheckoutPage extends BasePage {
  async submitPayment(cardNumber) {
    await this.page.fill('#card-number', cardNumber);
    await this.page.fill('#expiry', '12/28');
    await this.page.fill('#cvv', '123');

    // Wait for the payment to process
    const [response] = await Promise.all([
      this.page.waitForResponse(resp =>
        resp.url().includes('/api/payment') && resp.status() === 200
      ),
      this.page.click('#pay-button')
    ]);

    return new OrderConfirmationPage(this.page);
  }
}

POM with Playwright Fixtures

Playwright supports POM natively through fixtures:

// fixtures.ts
import { test as base } 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));
  }
});

// test file
test('user sees dashboard', async ({ loginPage, dashboardPage }) => {
  await loginPage.goto();
  await loginPage.login('admin@test.com', 'secret');
  await expect(dashboardPage.welcomeMessage).toBeVisible();
});

POM Anti-Patterns

1. God Page Object

A page object with 50+ methods is too large. Split into smaller, focused objects.

2. Assertions in Page Objects

Keep assertions in tests. Page objects return data; tests verify it.

3. Exposing Selectors

Tests should never reference selectors directly. If they do, the encapsulation is broken.

4. Shared Mutable State

Page objects should not store test-specific state. Each test creates fresh page object instances.

Exercise: Build a Complete POM

Create page objects for an e-commerce checkout flow:

  1. LoginPage — goto(), login(), loginWithGoogle(), errorMessage
  2. ProductListPage — searchProduct(), filterByCategory(), addToCart(), getProductCount()
  3. CartPage — getItems(), updateQuantity(), removeItem(), getTotal(), proceedToCheckout()
  4. CheckoutPage — enterShipping(), enterPayment(), placeOrder()
  5. OrderConfirmationPage — getOrderNumber(), getTotal(), getEstimatedDelivery()
  6. NavBar (component) — search(), navigateTo(), cartCount(), logout()

Write 3 test cases that use these page objects.

Key Takeaways

  • POM separates page details from test logic — one update point per UI change
  • Keep selectors private, expose user-action methods
  • No assertions in page objects — return data, let tests assert
  • Use component objects for shared UI elements (nav, footer, modals)
  • Method chaining makes tests fluent and readable
  • Playwright fixtures integrate naturally with POM