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