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 flags3. 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