Page Object Model
The Page Object Model wraps every page or component behind a class that exposes business actions instead of raw selectors. Locators live in one place, tests read like user stories, and a UI change fixes one file instead of hundreds of selectors.
What is this?
The Page Object Model (POM) is a design pattern for UI automation. Each page, modal or significant component becomes a class that owns its locators and exposes high-level methods like login() or addToCart(). Tests call those methods and never touch raw selectors. When markup changes, you update the page object - not every test - which is why POM is the default architecture in Playwright and Selenium suites.
Why do you need it?
- Selectors live in one place, so a single UI change is a one-file fix instead of a project-wide grep.
- Tests read like the product's language (checkoutAsGuest()) instead of CSS soup, which makes intent obvious.
- Pages are reusable across dozens of tests and across projects (web, API, mobile) through the same abstraction.
- Teams can refactor locators without touching test logic, decoupling UI churn from test maintenance.
- Page objects double as living documentation of what the page can actually do.
Mental model
A restaurant menu
A page object
You order the 'ribeye' from the menu, not the recipe with forty ingredients and an oven temperature. The kitchen is free to change how it cooks the steak; the waiter's order stays 'ribeye'. Page objects give the same contract between tests and the UI.
How it works
- Create one class per page or component, named after the page (LoginPage, CheckoutPage).
- Define private locators as fields and public methods for user-visible actions and state queries.
- Tests construct or receive a page object, then chain its methods to drive the flow.
- Locators resolve lazily at action time, so pages can be instantiated before the DOM exists.
- UI changes update the page object only; tests and helpers that call its methods stay untouched.
How tests interact with the application through page objects.
Key commands
npm create playwright@latestScaffold a Playwright project with test config, example specs and browsers.
npx playwright test login.spec.tsRun just the login spec file, useful while developing the page object.
npx playwright test --uiOpen the Playwright UI to watch, step through and time-travel tests.
npx playwright test --debugRun tests step-by-step with the inspector and locator picker.
npx playwright test --project=chromium --headedRun headed in Chromium to see what the browser is doing.
npx playwright codegen https://forge.dev/loginRecord clicks and actions and generate page-object-friendly snippets.
Examples
Playwright page object in TypeScript
import { Page, expect } from "@playwright/test" export class LoginPage { constructor(private readonly page: Page) {} private readonly emailInput = this.page.getByTestId("email") private readonly passwordInput = this.page.getByTestId("password") private readonly submitButton = this.page.getByRole("button", { name: "Sign in" }) async open() { await this.page.goto("/login") } async signIn(email: string, password: string) { await this.emailInput.fill(email) await this.passwordInput.fill(password) await this.submitButton.click() } async expectSignInError() { await expect(this.page.getByText("Invalid credentials")).toBeVisible() }} test("rejects bad credentials", async ({ page }) => { const login = new LoginPage(page) await login.open() await login.signIn("dev@forge.io", "wrong-password") await login.expectSignInError()})All locators are private fields resolved lazily by Playwright at action time. The test never sees a selector - it drives login() and asserts through expectSignInError(). If the error text or button label changes, only LoginPage needs updating, and the test file remains untouched. getByTestId and getByRole are resilient to class renames.
In production
- Large suites share a page-object layer across web, API and visual regression projects.
- Component objects (NavBar, ProductCard) compose into page objects, keeping flows readable and DRY.
- Locators based on data-testid and ARIA roles survive CSS refactors and framework migrations.
- Teams version page objects as a separate package so multiple test suites reuse one source of truth.
Best practices
- Name locators for behavior, not style: getByRole('button', { name: 'Save' }) beats a .btn-primary class selector.
- Expose business actions (submitOrder()), not micro-actions (clickButton(), typeText()) that leak markup into tests.
- Add data-testid attributes in the app codebase so tests do not depend on presentation classes.
- Keep page objects stateless: pass data in as method arguments instead of storing it in fields.
- Return page objects or fragments from navigation methods to enable fluent chaining (checkoutPage = cart.checkout()).
Common mistakes
- Making page objects expose locators publicly, which spreads selectors back into tests.
- Creating one giant mega-page object per application instead of per page/component, causing coupling.
- Adding hundreds of getters nobody uses - page objects should model real actions, not every element.
- Hard-coding data inside page objects, so the same page object cannot be reused across test data variations.
Common issues
Timeout exceeded while waiting for element getByTestId('email')Locator targets a data-testid that no longer exists after a UI change.
Update the page object's locator to the new attribute or a stable role; use codegen to capture it.
Error: strict mode violation: getByRole resolved to 2 elementsA locator matches multiple nodes (two 'Save' buttons on screen).
Make the locator more specific with .filter({ hasText: ... }) or a unique data-testid.
Cannot read properties of undefined (reading 'click')Page object constructed with an undefined/null page reference in the test.
Pass the fixture page into the constructor (new LoginPage(page)) inside the test.
Debugging tips
- Use npx playwright codegen to capture a fresh locator while investigating a selector failure.
- Add assertions inside page objects sparingly, and prefer returning state so tests decide what to assert.
- Log locator resolution failures: Playwright prints the selector and a snapshot of the DOM for diagnosis.
- When a locator breaks, inspect the element's data-testid or accessible name before editing locators.
Interview checkpoint
Hands-on lab
- Scaffold a Playwright project
- Create a LoginPage page object with private locators
- Drive the flow through business methods
- Write specs that reuse the page object
Build a Page Object for a Login Flow
40 min ยท intermediate
Quick quiz
What is the main benefit of keeping locators inside a page object class?
Page objects should expose their locators publicly so tests can reuse them.
Your login page object finds two elements matching 'Sign in' on the page. What is the best fix?
Cheat sheet
class LoginPage { ... }One class per page/componentgetByTestId / getByRoleStable, semantic locatorsawait page.goto('/login')Navigate inside page objectnew LoginPage(page)Pass fixture into constructorprivate readonly locatorKeep selectors privatedata-testid="submit"Add test hooks in app code