SoftwareTestPilot
Module 05 · Lab 1
Intermediate
13 min read

Page Object Model in Playwright — From Spaghetti to Scalable Tests

Encapsulate selectors, centralize test data, and scale your Playwright suite to hundreds of tests with the Page Object Model pattern.

1. What is POM?

The Page Object Model is a pattern where each page (or major component) in your app gets its own class. The class exposes high-level actions like login() or addToCart() and hides selectors and low-level Playwright calls behind the API.

Result: tests read like product requirements, and a UI change touches one file instead of fifty.

2. Folder structure

A typical Playwright POM project lays out like this:

my-suite/
├── pages/        # Page object classes (LoginPage, DashboardPage, ...)
├── tests/        # *.spec.ts test files
├── fixtures/     # test.extend() fixtures (authed page, seeded data)
├── utils/        # API helpers, date helpers, generators
└── config/       # env URLs, users, feature flags

3. First page object — LoginPage

Start small. A page object takes the Playwright page in its constructor and exposes one method per user intent.

import type { Page } from '@playwright/test';

export class LoginPage {
  constructor(private page: Page) {}

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

  async login(username: string, password: string) {
    await this.page.getByLabel('Email').fill(username);
    await this.page.getByLabel('Password').fill(password);
    await this.page.getByRole('button', { name: 'Sign in' }).click();
  }
}
// tests/login.spec.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';

test('user can sign in', async ({ page }) => {
  const login = new LoginPage(page);
  await login.goto();
  await login.login('qa@example.com', 'secret');
  await expect(page).toHaveURL(/dashboard/);
});

4. Composition

Page objects compose. A DashboardPage can depend on a LoginPage so tests don't repeat the login boilerplate.

import type { Page } from '@playwright/test';
import { LoginPage } from './LoginPage';

export class DashboardPage {
  constructor(private page: Page) {}

  static async loginAndOpen(page: Page, user: string, pass: string) {
    const login = new LoginPage(page);
    await login.goto();
    await login.login(user, pass);
    return new DashboardPage(page);
  }

  async openProject(name: string) {
    await this.page.getByRole('link', { name }).click();
  }
}

5. Anti-patterns

  • God-classes. One POM with 80 methods is a smell. Split by surface area — header, sidebar, settings tab — into smaller component objects.
  • Leaking selectors. If page.locator('.btn-primary') appears in a test file, the POM has failed its job. Selectors live inside the class, only.
  • Hard-coded test data. Don't bake 'qa@example.com' into a POM. Pass it in — keep page objects pure UI plumbing.
  • Returning locators from POMs. Expose actions (submitForm()) and queries (getErrorText()), not raw locator chains.

6. Hands-on task

Take 3 existing E2E tests that each duplicate login + navigation steps. Refactor them into a LoginPage and a DashboardPage. Goal: zero page.locator() calls inside .spec.ts files.

test('opens billing', async ({ page }) => {
  const dash = await DashboardPage.loginAndOpen(page, 'qa@example.com', 'secret');
  await dash.openProject('Billing');
  await expect(page.getByRole('heading', { name: 'Billing' })).toBeVisible();
});

7. What's next

Next, move into Module 06: API testing with Playwright — drive your backend directly to seed state, assert contracts, and skip slow UI flows.

Up next in the learning path

Module 06: API testing with Playwright