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 POM | With POM | Impact |
|---|---|---|
| Selectors scattered across 200 tests | Selectors in 1 class | Refactor UI = edit 1 file |
page.locator('#email') in every test | loginPage.login(...) | Tests read like user stories |
| Duplicated setup | Reusable methods | Faster to write new tests |
| No shared assertions | Business assertions on the object | Fewer bugs slip through |
2. Your first page object
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);
}
}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:
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(); }
}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
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';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
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
If a page class exceeds ~200 lines, split it into components.
Only business rule assertions belong on the object (like "user is logged in"). Test-specific ones belong in the spec.
Never return raw Locators if the test then chains .click(). Expose an action instead — that's the whole point.
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
Extract LoginPage
Pick a test with 3+ login calls. Move all locators + actions into
pages/LoginPage.ts. - 2
Introduce a Header component
Every authenticated page has the same top nav. Model it once, compose into 2 pages.
- 3
Wire a fixture
Create
fixtures.tsthat injects both pages. Update your specs to import from it instead of@playwright/test. - 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.