End-to-end tests that directly interact with the browser using raw selectors and inline actions quickly become hard to maintain. When the UI changes, the same selector may need to be updated in dozens of test files.
The Page Object Model (POM) pattern solves this by encapsulating page interactions in dedicated classes. Each class represents a page or component, exposing methods that describe user actions rather than low-level DOM queries. Playwright has first-class support for this pattern via its page fixture.
A Page Object is a TypeScript class that takes a Playwright Page instance and exposes higher-level methods. Locators should be defined as class properties rather than inline strings.
// login.spec.tstest('user can log in', async ({ page }) => {await page.goto('/login');await page.locator('input[name="email"]').fill('user@example.com');await page.locator('input[name="password"]').fill('secret');await page.locator('button[type="submit"]').click();await expect(page.locator('h1')).toHaveText('Dashboard');});// another-test.spec.tstest('redirects to dashboard after login', async ({ page }) => {await page.goto('/login');await page.locator('input[name="email"]').fill('admin@example.com');await page.locator('input[name="password"]').fill('secret');await page.locator('button[type="submit"]').click();// ... duplicated selectors});
Figure: Bad example - Selectors are duplicated; a rename of name="email" breaks every test
// pages/login-page.tsimport { type Locator, type Page } from '@playwright/test';export class LoginPage {readonly emailInput: Locator;readonly passwordInput: Locator;readonly submitButton: Locator;constructor(private readonly page: Page) {this.emailInput = page.getByLabel('Email');this.passwordInput = page.getByLabel('Password');this.submitButton = page.getByRole('button', { name: 'Sign in' });}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();}}
// login.spec.tsimport { test, expect } from '@playwright/test';import { LoginPage } from '../pages/login-page';test('user can log in', async ({ page }) => {const loginPage = new LoginPage(page);await loginPage.goto();await loginPage.login('user@example.com', 'secret');await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();});
Figure: Good example - Tests are readable; selector changes only need to be made in one place
Playwright recommends using role-based locators (getByRole, getByLabel, getByText) over CSS selectors. They are more resilient to DOM changes and better reflect how users perceive the UI.
page.getByRole('button', { name: 'Submit' }) over page.locator('button.submit-btn')page.getByLabel('Email') over page.locator('input[name="email"]')For tests that share the same setup, use Playwright fixtures to inject page objects automatically instead of constructing them in every test.
// fixtures.tsimport { test as base } from '@playwright/test';import { LoginPage } from './pages/login-page';export const test = base.extend<{ loginPage: LoginPage }>({loginPage: async ({ page }, use) => {await use(new LoginPage(page));},});
This keeps tests concise and ensures the page object is always constructed consistently.