Page Object Model: The Design Pattern That Saves Your Test Suite From Chaos
When your selectors are copy-pasted across 50 test files, one UI change becomes a week of fixes. POM solves this permanently.

You write a test. It passes. You write ten more. They all pass. Then the design team renames a button and you spend three days hunting down every test that referenced that selector. Sound familiar?
This is selector sprawl — the slow-motion collapse of test suites that grow without structure. The Page Object Model (POM) exists specifically to prevent it.
What is the Page Object Model?
The Page Object Model is a design pattern that encapsulates UI interactions into dedicated classes, keeping selectors and page-specific logic in one place so tests stay readable and resilient to UI changes.
Instead of scattering page.locator('#submit-btn') across every test file, you define a CheckoutPage class with a submitOrder() method. Your tests call the method. They never touch the selector directly.
When the selector changes, you update it in exactly one place. All tests continue working.
Why Selector Sprawl Destroys Test Suites
According to the 2025 SmartBear State of Software Quality Report, 62% of QA teams cite test maintenance as their top time drain — more than writing new tests or investigating failures.
The root cause is almost always the same pattern:
- Tests directly reference CSS selectors, XPaths, or text content
- The same selector appears in 10, 20, or 50 test files
- A UI change invalidates all of them simultaneously
- No single developer knows which tests are affected without running the full suite
POM breaks this cycle by making the page object the single source of truth for every selector on a given page.
Before POM: A Brittle Test
Here is a typical Playwright test without any abstraction. This pattern is extremely common in early-stage test suites.
// tests/checkout.spec.ts — WITHOUT Page Object Model
import { test, expect } from '@playwright/test';
test('user can complete checkout', async ({ page }) => {
await page.goto('/products');
await page.locator('.product-card').first().click();
await page.locator('#add-to-cart-btn').click();
await page.locator('[data-testid="cart-icon"]').click();
await page.locator('input[name="email"]').fill('user@example.com');
await page.locator('input[name="card-number"]').fill('4111111111111111');
await page.locator('button.submit-order').click();
await expect(page.locator('.order-confirmation')).toBeVisible();
});
test('guest user sees checkout form', async ({ page }) => {
await page.goto('/products');
await page.locator('.product-card').first().click();
await page.locator('#add-to-cart-btn').click(); // same selector, duplicated
await page.locator('[data-testid="cart-icon"]').click(); // same selector, duplicated
await expect(page.locator('input[name="email"]')).toBeVisible();
});If #add-to-cart-btn becomes .add-to-cart, both tests break. And these are just two tests — imagine fifty.
How to Structure Page Objects in Playwright with TypeScript
A well-structured Playwright page object centralizes locators as class properties and exposes user-intent methods that hide implementation details from tests.
Here is the same checkout flow refactored using POM:
// pages/ProductPage.ts
import { Page, Locator } from '@playwright/test';
export class ProductPage {
readonly page: Page;
readonly productCard: Locator;
readonly addToCartButton: Locator;
readonly cartIcon: Locator;
constructor(page: Page) {
this.page = page;
this.productCard = page.locator('.product-card');
this.addToCartButton = page.locator('#add-to-cart-btn');
this.cartIcon = page.locator('[data-testid="cart-icon"]');
}
async goto() {
await this.page.goto('/products');
}
async selectFirstProduct() {
await this.productCard.first().click();
}
async addToCart() {
await this.addToCartButton.click();
}
async openCart() {
await this.cartIcon.click();
}
}// pages/CheckoutPage.ts
import { Page, Locator } from '@playwright/test';
export class CheckoutPage {
readonly page: Page;
readonly emailInput: Locator;
readonly cardNumberInput: Locator;
readonly submitButton: Locator;
readonly confirmationMessage: Locator;
constructor(page: Page) {
this.page = page;
this.emailInput = page.locator('input[name="email"]');
this.cardNumberInput = page.locator('input[name="card-number"]');
this.submitButton = page.locator('button.submit-order');
this.confirmationMessage = page.locator('.order-confirmation');
}
async fillEmail(email: string) {
await this.emailInput.fill(email);
}
async fillCardNumber(number: string) {
await this.cardNumberInput.fill(number);
}
async submitOrder() {
await this.submitButton.click();
}
async isConfirmationVisible() {
return this.confirmationMessage.isVisible();
}
}// tests/checkout.spec.ts — WITH Page Object Model
import { test, expect } from '@playwright/test';
import { ProductPage } from '../pages/ProductPage';
import { CheckoutPage } from '../pages/CheckoutPage';
test('user can complete checkout', async ({ page }) => {
const productPage = new ProductPage(page);
const checkoutPage = new CheckoutPage(page);
await productPage.goto();
await productPage.selectFirstProduct();
await productPage.addToCart();
await productPage.openCart();
await checkoutPage.fillEmail('user@example.com');
await checkoutPage.fillCardNumber('4111111111111111');
await checkoutPage.submitOrder();
await expect(checkoutPage.confirmationMessage).toBeVisible();
});
test('guest user sees checkout form', async ({ page }) => {
const productPage = new ProductPage(page);
const checkoutPage = new CheckoutPage(page);
await productPage.goto();
await productPage.selectFirstProduct();
await productPage.addToCart();
await productPage.openCart();
await expect(checkoutPage.emailInput).toBeVisible();
});Now when #add-to-cart-btn changes, you update one line in ProductPage.ts. Both tests continue passing.
Before vs. After: The Structural Difference
| Dimension | Without POM | With POM |
|---|---|---|
| Selector location | Scattered across every test file | One page class per UI page |
| UI change impact | All tests referencing the selector break | Update one file, all tests pass |
| Test readability | Implementation details in every test | Business actions (submitOrder()) |
| Onboarding new testers | Must learn selectors for every page | Learn page object API once |
| Code duplication | High — same selectors copy-pasted | None — single source of truth |
Common POM Anti-Patterns to Avoid
POM introduces its own failure modes. Teams that adopt it without discipline often replace selector sprawl with a different set of problems.
Anti-Pattern 1: Assertions Inside Page Objects
Page objects should describe how to interact with a page, not what should be true about it. When you put expect() calls inside a page object, you mix interaction logic with test logic. Tests become harder to debug because failures appear to come from the page object, not the test.
// ❌ Wrong — assertion inside page object
async submitOrder() {
await this.submitButton.click();
await expect(this.confirmationMessage).toBeVisible(); // don't do this
}
// ✅ Correct — assertion in the test
async submitOrder() {
await this.submitButton.click();
}
// In test:
await checkoutPage.submitOrder();
await expect(checkoutPage.confirmationMessage).toBeVisible();Anti-Pattern 2: The God Page Object
A single AppPage class containing every selector in the application defeats the organizational purpose of POM. Create one page object per logical page or major component. If a class exceeds 150-200 lines, split it.
Anti-Pattern 3: Instantiating Page Objects Inside Other Page Objects
Avoid constructing page objects from within other page objects. This creates hidden coupling. Instead, instantiate all page objects at the test level or use Playwright fixtures to inject them.
Anti-Pattern 4: Exposing Raw Locators When Methods Would Do
If your test accesses checkoutPage.submitButton.click() directly, you've gained little over raw selectors. The page object should expose actions (submitOrder()), not implementation details. Reserve direct locator access for assertions only.
How POM Fits Into a Broader Test Architecture
Page objects are one layer in a complete test architecture. They work best alongside two complementary patterns: fixtures and helpers.
- Fixtures — Playwright's fixture system injects page objects into tests automatically, eliminating boilerplate instantiation. Define a fixture once, use it in every test that needs it.
- Helpers / Utilities — Cross-page workflows (like logging in before a test) belong in helper functions, not page objects. Helpers can use multiple page objects to complete multi-step flows.
- Page Objects — Represent a single page or component. Expose locators (for assertions) and action methods (for interactions). No test logic, no assertions, no cross-page flows.
// fixtures/index.ts — Playwright fixture for page objects
import { test as base } from '@playwright/test';
import { ProductPage } from '../pages/ProductPage';
import { CheckoutPage } from '../pages/CheckoutPage';
type Pages = {
productPage: ProductPage;
checkoutPage: CheckoutPage;
};
export const test = base.extend<Pages>({
productPage: async ({ page }, use) => {
await use(new ProductPage(page));
},
checkoutPage: async ({ page }, use) => {
await use(new CheckoutPage(page));
},
});
export { expect } from '@playwright/test';// tests/checkout.spec.ts — Using fixtures (cleanest form)
import { test, expect } from '../fixtures';
test('user can complete checkout', async ({ productPage, checkoutPage }) => {
await productPage.goto();
await productPage.selectFirstProduct();
await productPage.addToCart();
await productPage.openCart();
await checkoutPage.fillEmail('user@example.com');
await checkoutPage.fillCardNumber('4111111111111111');
await checkoutPage.submitOrder();
await expect(checkoutPage.confirmationMessage).toBeVisible();
});This is the pattern that scales. Tests read like plain English. All technical details live in page objects. Fixtures handle dependency injection.
POM in Selenium vs. Playwright: Key Differences
The POM concept applies equally to Selenium and Playwright, but the implementation differs in ways that affect maintenance.
| Feature | Selenium (Java/Python) | Playwright (TypeScript) |
|---|---|---|
| Locator definition | @FindBy annotations or manual By | page.locator() with auto-waiting |
| Waiting strategy | Manual explicit/implicit waits required | Auto-wait built into every interaction |
| PageFactory | Available, initializes annotated fields | Not needed — locators lazy by default |
| Type safety | Strong in Java, less in Python | Full TypeScript inference and autocomplete |
Playwright's auto-waiting eliminates an entire category of bugs common in Selenium page objects: race conditions caused by locating elements before they're ready. According to Playwright's documentation benchmarks, auto-waiting reduces timing-related failures by approximately 80% compared to manual wait strategies.
When to Refactor Existing Tests to POM
You do not need to rewrite everything at once. Refactor incrementally using these signals as triggers:
- A single UI change breaks 3 or more test files — the selector has sprawled enough to warrant extraction
- A new team member needs more than half a day to write their first test — cognitive overhead is too high
- You find yourself copying a block of locator setup into a new test file
- Test failures are reported in "navigation" or "setup" steps rather than actual assertions
- Your CI suite has tests that are marked as
skipbecause "they need updating after the redesign"
A good refactoring strategy: pick the most-referenced page in your app (usually login or navigation) and extract it into a page object first. Update existing tests to use it. The immediate payoff demonstrates value and builds team confidence.
Key Takeaways
- POM centralizes selectors — one page class per UI page means UI changes require a single edit, not a suite-wide search-and-replace.
- Methods over raw locators — expose user-intent actions (
submitOrder()) in tests, keep implementation details in page objects. - Never put assertions in page objects — assertions belong in tests. Page objects describe interactions, not expectations.
- Playwright fixtures are the cleanest delivery mechanism — inject page objects as fixture parameters to eliminate constructor boilerplate across every test.
- Refactor incrementally — start with the most-reused pages (login, nav), measure the reduction in breakage, then expand.
- POM is one layer, not the whole architecture — combine with helpers for cross-page workflows and fixtures for dependency injection to build a test suite that scales to hundreds of tests without becoming unmanageable.
Ready to strengthen your test automation?
Desplega.ai helps QA teams build robust test automation frameworks with modern testing practices. Whether you're starting from scratch or improving existing pipelines, we provide the tools and expertise to catch bugs before production.
Start Your Testing TransformationFrequently Asked Questions
What is the Page Object Model in test automation?
The Page Object Model is a design pattern that creates a class for each UI page, centralizing selectors and interactions so tests reference methods instead of raw locators.
Does Page Object Model work with Playwright and Selenium?
Yes. POM is framework-agnostic and works with Playwright, Selenium, Cypress, and WebdriverIO. Playwright with TypeScript is the most common modern implementation.
When should I refactor tests to use Page Object Model?
Refactor when you have 3+ tests sharing the same selectors, a UI change breaks more than 2 test files, or onboarding a new tester takes more than a day to understand the suite.
What are the most common Page Object Model anti-patterns?
The top three: adding assertions inside page objects (mixing concerns), creating one giant God page object, and duplicating page objects per test instead of sharing them.
How much does Page Object Model reduce test maintenance time?
Teams report 60-70% reduction in maintenance time after adopting POM. A single selector update in the page object propagates to all tests automatically.
Related Posts
Hot Module Replacement: Why Your Dev Server Restarts Are Killing Your Flow State | desplega.ai
Stop losing 2-3 hours daily to dev server restarts. Master HMR configuration in Vite and Next.js to maintain flow state, preserve component state, and boost coding velocity by 80%.
The Flaky Test Tax: Why Your Engineering Team is Secretly Burning Cash | desplega.ai
Discover how flaky tests create a hidden operational tax that costs CTOs millions in wasted compute, developer time, and delayed releases. Calculate your flakiness cost today.
The QA Death Spiral: When Your Test Suite Becomes Your Product | desplega.ai
An executive guide to recognizing when quality initiatives consume engineering capacity. Learn to identify test suite bloat, balance coverage vs velocity, and implement pragmatic quality gates.