Back to Playwright & UI Automation
Q7Question 7 of 7

Implementing the Page Object Model, its benefits, and its limitations

Key Concepts
  • POM separates test logic (what) from page interaction (how), with locators as hidden implementation details inside the page object
  • Reusability: many tests share the same page methods, so login or transfer logic is written once
  • Maintainability: a changed locator is fixed in one place and every spec using it is instantly corrected
  • Bloated page classes are the main limitation: decompose into component objects for shared UI parts like headers and date pickers
  • Fixtures inject ready-to-use page objects and handle setup, keeping page objects focused purely on interaction
  • Keep business assertions in specs, not inside page objects, so page objects stay reusable across tests expecting different outcomes

Definition

The Page Object Model puts a page's locators and action methods in one class so tests express intent and a changed locator is fixed once, and you keep it healthy by extracting component objects, fixtures, and helpers to prevent bloated, duplicated page classes.
  • Three-way separation – Locators inside the page object, interaction methods wrapping actions, and test logic in specs calling those methods.
  • Reusability – Many tests share the same page methods. Login is written once and reused everywhere.
  • Maintainability – A changed locator is fixed in exactly one place. Every spec using it is instantly corrected.
  • Readability – Specs read as user intent: login, makeTransfer, assertBalance.
  • Limitations – Bloated page classes and duplication. Fix by extracting component objects, fixtures, and helpers.

At the National Bank of India, instead of every test knowing the exact buttons and boxes on the transfer screen, you appoint one expert clerk, the Transfer page object, who alone knows where every control is. Tests just ask the clerk to "make a transfer." If the bank rearranges the screen, you retrain only that one clerk. The danger is one clerk responsible for an enormous screen; you fix that by giving shared parts their own specialist clerks.

Understanding the Concepts

What is the three-way separation?

The locators live inside the page object, defined once as stable selectors. The interaction methods wrap actions into meaningful operations. The test logic calls these methods and makes assertions.

TYPESCRIPT
// pages/TransferPage.ts - LOCATORS + INTERACTION
import { Page, Locator } from '@playwright/test';

export class TransferPage {
  readonly fromAccount: Locator;
  readonly toAccount: Locator;
  readonly amount: Locator;
  readonly transferButton: Locator;
  readonly successMessage: Locator;
  readonly errorMessage: Locator;

  constructor(private page: Page) {
    this.fromAccount = page.getByLabel('From Account');
    this.toAccount = page.getByLabel('To Account');
    this.amount = page.getByLabel('Amount');
    this.transferButton = page.getByRole('button', { name: 'Transfer' });
    this.successMessage = page.getByText('Transfer Successful');
    this.errorMessage = page.getByRole('alert');
  }

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

  async makeTransfer(from: string, to: string, amt: string) {
    await this.fromAccount.selectOption(from);
    await this.toAccount.fill(to);
    await this.amount.fill(amt);
    await this.transferButton.click();
  }
}
TYPESCRIPT
// tests/transfer.spec.ts - TEST LOGIC
import { test, expect } from '@playwright/test';
import { TransferPage } from '../pages/TransferPage';

test('Successful transfer shows confirmation', async ({ page }) => {
  const transfer = new TransferPage(page);
  await transfer.navigate();
  await transfer.makeTransfer('Savings ACC-001', 'ACC-002', '5000');
  await expect(transfer.successMessage).toBeVisible();
});

test('Insufficient balance shows error', async ({ page }) => {
  const transfer = new TransferPage(page);
  await transfer.navigate();
  await transfer.makeTransfer('Savings ACC-001', 'ACC-002', '9999999');
  await expect(transfer.errorMessage).toContainText('Insufficient');
});

What does POM buy you?

Three concrete benefits follow from the separation.

DIAGRAM
| Benefit | How It Works | Example |
|---|---|---|
| Reusability | Many tests call the same methods | Both tests above use makeTransfer |
| Maintainability | Changed locator fixed in one place | Rename 'Transfer' button -> edit TransferPage only |
| Readability | Specs read as user intent | "navigate, makeTransfer, expect success" |

How do you avoid bloated page classes?

Decompose large pages into component objects. Shared parts like a header, search widget, or date picker become their own focused classes.

TYPESCRIPT
// components/HeaderComponent.ts
import { Page, Locator } from '@playwright/test';

export class HeaderComponent {
  readonly accountName: Locator;
  readonly logoutButton: Locator;
  readonly notificationBell: Locator;

  constructor(page: Page) {
    this.accountName = page.getByTestId('header-account-name');
    this.logoutButton = page.getByRole('button', { name: 'Logout' });
    this.notificationBell = page.getByRole('button', { name: 'Notifications' });
  }

  async logout() { await this.logoutButton.click(); }
}

// pages/DashboardPage.ts - COMPOSES the header
export class DashboardPage {
  readonly header: HeaderComponent;
  readonly balanceCard: Locator;

  constructor(private page: Page) {
    this.header = new HeaderComponent(page);
    this.balanceCard = page.getByTestId('balance-card');
  }
}

How do fixtures keep page objects clean?

Fixtures inject ready-to-use page objects and handle setup, keeping page objects focused purely on interaction.

TYPESCRIPT
// fixtures/bankFixtures.ts
import { test as base } from '@playwright/test';
import { TransferPage } from '../pages/TransferPage';

export const test = base.extend<{ transferPage: TransferPage }>({
  transferPage: async ({ page }, use) => {
    const tp = new TransferPage(page);
    await tp.navigate();
    await use(tp);
  },
});
TYPESCRIPT
// Spec uses the fixture: clean and focused
import { test, expect } from '../fixtures/bankFixtures';

test('Transfer via fixture', async ({ transferPage }) => {
  await transferPage.makeTransfer('Savings ACC-001', 'ACC-002', '5000');
  await expect(transferPage.successMessage).toBeVisible();
});

Should assertions go inside page objects?

Keep business assertions in specs, not page objects. Page objects should be reusable by tests expecting different outcomes.

TYPESCRIPT
// WRONG: assertion inside page object
async makeTransferAndExpectSuccess(from: string, to: string, amt: string) {
  await this.makeTransfer(from, to, amt);
  // This assertion does not belong here
  await expect(this.successMessage).toBeVisible();
}

// CORRECT: page object does interaction, spec does assertion
// Page: makeTransfer(from, to, amt)
// Spec: await expect(transfer.successMessage).toBeVisible();
// Spec: await expect(transfer.errorMessage).toContainText('Insufficient');

How do you extract shared duplication?

Shared interaction logic gets extracted into component objects, utility helpers, or base classes.

TYPESCRIPT
// Base page with common navigation
export class BasePage {
  readonly header: HeaderComponent;

  constructor(protected page: Page) {
    this.header = new HeaderComponent(page);
  }

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

// TransferPage extends BasePage
export class TransferPage extends BasePage {
  // Inherits header and waitForPageLoad
  constructor(page: Page) {
    super(page);
  }
}

What are common interview follow-up questions?

  • "How deep should a page object's methods go?" Each method should represent one user action or a natural sequence. Do not make methods too granular or too coarse.
  • "Can a page object return another page object?" Yes. A login method can return a DashboardPage, modeling the navigation flow.
  • "What if a page is shared between two apps?" Extract the shared page object into a common package that both test suites import.

What is the full example with all patterns?

A complete example showing specs, page objects, components, and fixtures working together.

TYPESCRIPT
// Full example: spec -> fixture -> page object -> component
import { test, expect } from '../fixtures/bankFixtures';

test('Dashboard shows correct balance after transfer', async ({
  transferPage, dashboardPage, api
}) => {
  await api.seedAccount('ACC-001', 50000);
  await transferPage.navigate();
  await transferPage.makeTransfer('ACC-001', 'ACC-002', '5000');
  await expect(transferPage.successMessage).toBeVisible();

  await dashboardPage.navigate();
  await expect(dashboardPage.balanceCard).toContainText('45,000');
  await dashboardPage.header.logout();
});

Visual Overview

DIAGRAM
  Page Object Model: Three-Way Separation
  ─────────────────────────────────────────

  ┌──────────────────────────────────┐
  │  TEST SPEC (what)                │
  │  transfer.spec.ts                │
  │                                  │
  │  1. navigate()                   │
  │  2. makeTransfer('A', 'B', '5k') │
  │  3. expect(success).toBeVisible()│
  └──────────────┬───────────────────┘
                 │ calls
  ┌──────────────▼───────────────────┐
  │  PAGE OBJECT (how)               │
  │  TransferPage.ts                 │
  │                                  │
  │  navigate()                      │
  │  makeTransfer(from, to, amount)  │
  │  - selectOption(fromAccount)     │
  │  - fill(toAccount)               │
  │  - fill(amount)                  │
  │  - click(transferButton)         │
  └──────────────┬───────────────────┘
                 │ holds
  ┌──────────────▼───────────────────┐
  │  LOCATORS (hidden detail)        │
  │  getByLabel('From Account')      │
  │  getByLabel('To Account')        │
  │  getByRole('button', 'Transfer') │
  │  getByText('Transfer Successful')│
  └──────────────────────────────────┘

  Locator changes -> fix ONE page object
  Every spec keeps working

Key Takeaway

I implement POM as a clean three-way separation: locators inside the page object, interaction methods wrapping actions, and test logic in specs calling those methods. The spec says what, the page object says how, and the locator is a hidden implementation detail.

The benefits are reusability (many tests share the same page methods), maintainability (a changed locator is fixed in one place), and readability (specs read as user intent). The limitations are bloated page classes and duplication. I fix those by decomposing into component objects, delegating setup to fixtures, extracting shared logic into helpers or base classes, and keeping business assertions in specs so page objects stay reusable.

At the National Bank of India, when a release restyled the transfer screen, I fixed locators in one TransferPage object and every spec passed without edits. I also refactored a god page object into composed component objects, cutting its size and enabling reuse across pages.

Remember one sentence: POM separates what from how, centralizes locators for single-point maintenance, and stays healthy when you decompose into components, delegate setup to fixtures, and keep assertions in specs.

---

Share
Use ← → arrow keys to navigate