QA Engineer Skills 2026QA-2026Page Object Model

Page Object Model

The Page Object Model (POM) is the most important design pattern in browser test automation. It separates "what to test" from "how to interact with the page," making tests readable and resilient to UI changes. The pattern applies equally to Selenium and Playwright, but Playwright's API makes page objects simpler and more powerful.


The Problem POM Solves

Without POM, locators and interactions are scattered across test files:

// BAD: locators duplicated across tests
test('login success', async ({ page }) => {
  await page.locator('input[name="email"]').fill('user@test.com');
  await page.locator('input[name="password"]').fill('pass123');
  await page.locator('button.login-btn').click();
  await expect(page.locator('h1.welcome')).toHaveText('Welcome');
});

test('login failure', async ({ page }) => {
  await page.locator('input[name="email"]').fill('bad@test.com');
  await page.locator('input[name="password"]').fill('wrong');
  await page.locator('button.login-btn').click();
  await expect(page.locator('.error-message')).toBeVisible();
});

If the email field selector changes, you must update every test. In a suite with 200 tests, this is a maintenance nightmare.


POM in Playwright (TypeScript)

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

export class LoginPage {
  private readonly emailInput: Locator;
  private readonly passwordInput: Locator;
  private readonly submitButton: Locator;
  private readonly errorMessage: Locator;

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

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

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

  async getErrorMessage(): Promise<string> {
    return await this.errorMessage.textContent() ?? '';
  }
}

Tests Using POM

// tests/login.spec.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/login.page';

test('successful login redirects to dashboard', async ({ page }) => {
  const loginPage = new LoginPage(page);
  await loginPage.goto();
  await loginPage.login('user@test.com', 'pass123');
  await expect(page).toHaveURL(/.*dashboard/);
});

test('invalid credentials show error', async ({ page }) => {
  const loginPage = new LoginPage(page);
  await loginPage.goto();
  await loginPage.login('bad@test.com', 'wrong');
  expect(await loginPage.getErrorMessage()).toContain('Invalid');
});

If the email field changes from <input> with a label to a custom component, you update one line in LoginPage — zero test files change.


POM Principles

1. One Class Per Page or Major Component

Each page or significant UI section gets its own class. Do not create one giant AppPage class.

2. Locators Live in the Page Object, Never in the Test

// BAD: locator in test
await page.locator('#submit').click();

// GOOD: locator in page object
await checkoutPage.submitOrder();

3. Methods Represent User Actions, Not UI Mechanics

Name methods after what the user does, not what the code does:

// GOOD: describes user intent
await loginPage.login(email, password);
await productPage.addToCart();
await checkoutPage.applyCoupon('SAVE10');

// BAD: describes UI mechanics
await loginPage.fillEmailAndPasswordAndClickSubmit(email, password);

4. No Assertions Inside Page Objects

Page objects describe what the page can do. Tests decide what to verify.

// BAD: assertion inside page object
async login(email: string, password: string) {
  await this.emailInput.fill(email);
  await this.submitButton.click();
  await expect(this.page).toHaveURL(/dashboard/); // don't do this
}

// GOOD: page object provides actions, test makes assertions
async login(email: string, password: string) {
  await this.emailInput.fill(email);
  await this.passwordInput.fill(password);
  await this.submitButton.click();
}

POM in Playwright (Python)

# pages/login_page.py
class LoginPage:
    def __init__(self, page):
        self.page = page
        self.email_input = page.get_by_label("Email")
        self.password_input = page.get_by_label("Password")
        self.submit_button = page.get_by_role("button", name="Sign in")
        self.error_message = page.get_by_role("alert")

    def goto(self):
        self.page.goto("/login")

    def login(self, email: str, password: str):
        self.email_input.fill(email)
        self.password_input.fill(password)
        self.submit_button.click()

    def get_error_message(self) -> str:
        return self.error_message.text_content() or ""

Component Objects

Reusable UI components that appear across multiple pages:

class HeaderComponent {
  constructor(private page: Page) {}

  async search(query: string) {
    await this.page.getByRole('searchbox').fill(query);
    await this.page.getByRole('searchbox').press('Enter');
  }

  async getCartCount(): Promise<number> {
    const text = await this.page.getByTestId('cart-count').textContent();
    return parseInt(text ?? '0');
  }
}

class ProductPage {
  readonly header: HeaderComponent;

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

  async addToCart() {
    await this.page.getByRole('button', { name: 'Add to Cart' }).click();
  }
}

Key Takeaways

  • POM separates locators and interactions (page objects) from verification (tests)
  • One class per page; locators in the constructor; methods represent user actions
  • No assertions inside page objects — tests decide what to verify
  • Playwright's locator API makes page objects simpler than Selenium equivalents (no explicit waits in every method)
  • Use component objects for reusable UI elements (header, footer, modals)
  • If a locator changes, update one place — not every test