Executive Definition
The Page Object Model, or POM, is a design pattern for UI automation in which every screen or major component of an application is represented by a class. That class owns the selectors for its elements, exposes methods that perform user-level actions, and returns either the next page object or a domain value. Tests never speak directly to the DOM — they call methods on page objects, and the page objects speak to the driver.
POM emerged from Selenium projects that had accumulated hundreds of test files, each with hardcoded CSS selectors. When designers renamed a class or restructured a component, every test that touched it broke. The fix was mechanical but painful: search-and-replace across the whole suite. POM removes that pain by giving each selector exactly one home. When the DOM changes, you edit one file. Every test that used the affected page object keeps working.
The pattern also raises the semantic level of tests. Instead of driver.findElement(By.css('#login-btn')).click(), tests read loginPage.submitCredentials(user, pass). That difference is not cosmetic. When a test fails, the stack trace points to a named user action, not an anonymous DOM operation, which makes failure triage dramatically faster.
Modern POM in 2026 looks different from the 2015 Java-Selenium version. Playwright and Cypress ship first-class fixture systems that let page objects be injected as constructor arguments; TypeScript catches selector typos at compile time; and the trend is toward small component objects (Header, Modal, Cart) composed inside larger page objects, rather than one giant class per URL.
The pattern is not universal. For very small suites (fewer than 20 tests) POM adds ceremony without a payoff, and for highly stateful flows a Screenplay-style actor model can be clearer. But for any team maintaining a growing E2E suite, POM remains the default answer because it optimises the metric that actually hurts: time spent updating tests after the UI changes.
Architecture & Production Code
A well-structured POM project keeps three layers strictly separated: tests, page objects, and low-level helpers. Crossing those layers is where suites go bad.
┌─────────────────────────────────────────────┐
│ tests/ │
│ └── checkout.spec.ts │
│ expect(cartPage.total).toBe('$42'); │
└─────────────────────────────┬───────────────┘
│ imports
▼
┌─────────────────────────────────────────────┐
│ pages/ │
│ ├── LoginPage.ts (submitCredentials) │
│ ├── CartPage.ts (addItem, get total) │
│ └── CheckoutPage.ts (payWith, confirm) │
└─────────────────────────────┬───────────────┘
│ uses
▼
┌─────────────────────────────────────────────┐
│ driver / framework (Playwright / Selenium) │
│ page.locator(...), page.click(...) │
└─────────────────────────────────────────────┘The tests layer contains only assertions and orchestration. It never calls page.locator directly. If you find yourself importing @playwright/test types other than expect into a spec file, you are probably leaking driver detail up a layer.
The pages layer contains selectors, action methods, and getters for observable state. Each page object owns its locators as private fields and exposes intent — 'submitCredentials', 'addItem', 'proceedToCheckout' — not clicks and types. Action methods return the next page object, allowing tests to chain: loginPage.submitCredentials(...).then(dashboard => dashboard.openCart()).
Composition beats inheritance. A modal or navigation bar that appears on many pages should be its own object, not a base class. Compose it inside each page object that needs it. Deep inheritance hierarchies are the second most common way POM suites become unmaintainable — after god-classes with 900 lines of selectors.
// pages/LoginPage.ts
import type { Locator, Page } from "@playwright/test";
import { DashboardPage } from "./DashboardPage";
export class LoginPage {
private readonly emailInput: Locator;
private readonly passwordInput: Locator;
private readonly submitButton: Locator;
private readonly errorBanner: Locator;
constructor(private readonly page: Page) {
this.emailInput = page.getByLabel("Email");
this.passwordInput = page.getByLabel("Password");
this.submitButton = page.getByRole("button", { name: "Sign in" });
this.errorBanner = page.getByRole("alert");
}
async goto() {
await this.page.goto("/login");
}
async submitCredentials(email: string, password: string): Promise<DashboardPage> {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.submitButton.click();
await this.page.waitForURL("/dashboard");
return new DashboardPage(this.page);
}
get errorMessage() {
return this.errorBanner.innerText();
}
}
// tests/login.spec.ts
import { test, expect } from "@playwright/test";
import { LoginPage } from "../pages/LoginPage";
test("valid credentials take the user to the dashboard", async ({ page }) => {
const login = new LoginPage(page);
await login.goto();
const dashboard = await login.submitCredentials("qa@example.com", "s3cret!");
await expect(dashboard.welcomeBanner).toContainText("Welcome back");
});POM vs Screenplay vs Raw Selectors
| Aspect | Page Object Model | Screenplay | Raw selectors in tests |
|---|---|---|---|
| Selector location | Page object class | Task / Question class | Inline in test |
| Abstraction unit | Page or component | Actor and ability | None |
| Learning curve | Low | Medium-high | None |
| Refactor cost after UI change | Low (1 file) | Low (1 file) | High (every test) |
| Best for suite size | 20+ tests | 100+ tests, complex flows | Under 20 tests / prototypes |
| Tool alignment | Playwright, Selenium, Cypress | Serenity BDD, Playwright | Any |
POM is the sensible default for most teams. Screenplay pays off when your flows are highly stateful and involve multiple actors with different permissions. Raw selectors are fine for a spike but should be refactored the moment the suite crosses about 20 tests.
Production Debugging Scenarios
Three POM anti-patterns account for most maintenance pain. Watch for them during code review.
Page object leaks driver types to the test
- Symptom
- Test file imports Locator or Page from Playwright and calls .click() directly.
- Root cause
- A page-object method returned a raw locator instead of performing the action itself.
- Fix
- Make action methods void (or return the next page object). Expose observable state as string or number getters, never as raw locators.
Selectors duplicated across two page objects
- Symptom
- A header search bar has selectors in both HomePage and SearchPage. A redesign breaks half the suite.
- Root cause
- The header should have been its own component object composed into every page.
- Fix
- Extract HeaderComponent with its own selectors and methods. Instantiate it inside each page object's constructor.
Action methods return void when the flow continues on a new page
- Symptom
- Tests call loginPage.submit() then immediately call methods on a page object they had to construct manually.
- Root cause
- Chaining was skipped, so the test now knows both LoginPage and DashboardPage lifecycles.
- Fix
- Return the next page object from every navigational action: return new DashboardPage(this.page). Tests read as a fluent flow.
Practice this concept in a real QA interview
Run a live mock with our AI Interview Coach, tune your resume with the ATS Resume Reviewer, and screen live listings on the QA Jobs Radar.