SoftwareTestPilot
Module 04 · Lesson 1intermediate 15 min read Architecture

Page Object Model in Playwright

Turn one-off scripts into a maintainable framework. Learn the modern component-based POM, TypeScript patterns, Playwright fixtures and the anti-patterns that create tomorrow's tech debt.

Quick answer

A Page Object is a class that owns the locators and actions for one page or component. Tests call loginPage.login(email, pwd); the class hides getByLabel('Email') etc. Result: change the UI, edit one file — every test keeps passing.

1. Why Page Object Model

Without POMWith POMImpact
Selectors scattered across 200 testsSelectors in 1 classRefactor UI = edit 1 file
page.locator('#email') in every testloginPage.login(...)Tests read like user stories
Duplicated setupReusable methodsFaster to write new tests
No shared assertionsBusiness assertions on the objectFewer bugs slip through

2. Your first page object

pages/LoginPage.ts
ts
import { Page, Locator, expect } from '@playwright/test';

export class LoginPage {
  readonly page: Page;
  readonly email: Locator;
  readonly password: Locator;
  readonly submit: Locator;
  readonly error: Locator;

  constructor(page: Page) {
    this.page = page;
    this.email = page.getByLabel('Email');
    this.password = page.getByLabel('Password');
    this.submit = page.getByRole('button', { name: 'Sign in' });
    this.error = page.getByRole('alert');
  }

  async goto() {
    await this.page.goto('/login');
    await expect(this.page).toHaveTitle(/sign in/i);
  }

  async login(email: string, password: string) {
    await this.email.fill(email);
    await this.password.fill(password);
    await this.submit.click();
  }

  async expectError(text: string | RegExp) {
    await expect(this.error).toContainText(text);
  }
}
tests/login.spec.ts
ts
import { test } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';

test('rejects bad password', async ({ page }) => {
  const login = new LoginPage(page);
  await login.goto();
  await login.login('a@b.co', 'wrong');
  await login.expectError(/invalid credentials/i);
});

3. Component objects — smaller is better

Modern apps have reusable components (Header, Nav, DataTable). Model them as their own classes and compose:

pages/Header.ts
ts
export class Header {
  constructor(private page: Page) {}
  readonly userMenu = this.page.getByRole('button', { name: /account/i });
  async openProfile()  { await this.userMenu.click(); await this.page.getByRole('menuitem', { name: 'Profile' }).click(); }
  async signOut()      { await this.userMenu.click(); await this.page.getByRole('menuitem', { name: 'Sign out' }).click(); }
}
pages/DashboardPage.ts
ts
import { Header } from './Header';
export class DashboardPage {
  readonly header: Header;
  constructor(private page: Page) { this.header = new Header(page); }
  readonly revenueCard = this.page.getByTestId('revenue');
}

4. POM + Playwright fixtures = zero boilerplate

fixtures.ts
ts
import { test as base } from '@playwright/test';
import { LoginPage } from './pages/LoginPage';
import { DashboardPage } from './pages/DashboardPage';

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

export const test = base.extend<Pages>({
  loginPage:     async ({ page }, use) => use(new LoginPage(page)),
  dashboardPage: async ({ page }, use) => use(new DashboardPage(page)),
});
export { expect } from '@playwright/test';
tests/dashboard.spec.ts
ts
import { test, expect } from '../fixtures';

test('shows revenue after login', async ({ loginPage, dashboardPage }) => {
  await loginPage.goto();
  await loginPage.login('demo@acme.co', 'demo');
  await expect(dashboardPage.revenueCard).toBeVisible();
});

5. Composition over inheritance

Prefer HAS-A over IS-A
A DashboardPage HAS a Header. Don't extend BasePage to inherit one. Inheritance couples pages to a fragile parent; composition lets each page pick only what it needs.

6. Anti-patterns to avoid

1000-line God Page Object

If a page class exceeds ~200 lines, split it into components.

Assertions everywhere in the class

Only business rule assertions belong on the object (like "user is logged in"). Test-specific ones belong in the spec.

Leaking Playwright APIs to the test

Never return raw Locators if the test then chains .click(). Expose an action instead — that's the whole point.

Static locators

Instantiate locators in the constructor. Don't store selectors as strings and call page.locator(sel) in every method — you lose auto-complete and refactor safety.

7. Hands-on task (30 minutes)

  1. 1

    Extract LoginPage

    Pick a test with 3+ login calls. Move all locators + actions into pages/LoginPage.ts.

  2. 2

    Introduce a Header component

    Every authenticated page has the same top nav. Model it once, compose into 2 pages.

  3. 3

    Wire a fixture

    Create fixtures.ts that injects both pages. Update your specs to import from it instead of @playwright/test.

  4. 4

    Refactor & measure

    Count lines in your specs before/after. Typical drop is 40–60%.

Next: parameterise tests over data in Module 5 — Data-Driven Testing.

Frequently asked questions

1.What is the Page Object Model?
A design pattern where each page (or component) of your app is represented by a class. The class owns the locators and exposes user-level actions like `login(email, password)` — tests never touch selectors directly.
2.Is POM still recommended in 2026?
Yes. Even with Playwright's built-in locators, POM keeps tests readable, DRY and refactor-safe. The modern twist is smaller component objects instead of monster page classes.
3.Should page objects extend a base class?
Prefer composition over inheritance. Give each page a `page: Page` field and expose small methods. Use inheritance only when 3+ pages truly share behaviour like `Header` or `Nav`.
4.Should page objects return other page objects?
Yes — for navigation. `await loginPage.submit()` can return `new DashboardPage(page)`. Makes flow tests chain naturally.
5.POM vs Playwright fixtures — do I need both?
Yes. Fixtures inject ready-to-use page objects into tests. Test: `test('...', async ({ loginPage, dashboardPage }) => { ... })`. Clean, typed, no manual `new`.
6.Should assertions live inside page objects?
Business rule assertions yes (like `assertLoggedIn()`), but keep test-specific assertions in the spec so the test reads as a story.

Related lessons