Skip to content
QA Workflow Assistant

Blog

Page Object Model in Test Automation: Patterns That Scale

Learn the Page Object Model for Selenium and Playwright: scalable patterns, composition vs inheritance, Page Factory pitfalls, and when POM is the wrong fit.

QA Workflow Assistant9 min read
  • page-object-model
  • test-automation
  • selenium
  • playwright
  • qa

The Page Object Model (POM) is still one of the most useful patterns in UI test automation—when teams treat it as a thin adapter over the UI, not as a second application framework. This guide covers what page object model means in practice, how to structure it in Selenium and Playwright, composition versus inheritance, Page Factory pitfalls, and when you should skip POM entirely.

If you are still deciding where UI automation fits in the wider strategy, keep the automation testing guide nearby. Tool-specific context lives in the Playwright testing tutorial, Selenium WebDriver tutorial, Cypress end-to-end testing, and Java Selenium tutorial.

What the Page Object Model is

In the Page Object Model, each meaningful screen (or major fragment) becomes a class or module. Selectors and low-level interactions live there. Tests call readable methods like loginAs(user) or submitOrder() and assert outcomes—without sprinkling CSS and XPath through every test file.

Goals:

  • Localize UI change — when a button selector changes, you update one place.
  • Keep tests readable — tests read like scenarios, not DOM archaeology.
  • Share flows safely — login, navigation, and common widgets without copy-paste.

POM is not a requirement to mirror every React component 1:1. Model the UI the way testers think about it: Login page, Cart page, Checkout step, Admin users table—not ButtonPrimary.tsx.

Why teams adopt POM

Without some abstraction, suites rot into locator soup. With too much abstraction, suites rot into indirection soup. Good page object model usage sits in the middle: enough structure to absorb churn, not so much that onboarding requires a map.

Pair POM with clear cases. A page method should map cleanly to steps you would write in a QA test case template. If you cannot name the method after a human-readable action, the abstraction may be wrong.

POM also helps hiring and handoffs. A new SDET can open CheckoutPage and see which fields and buttons the suite depends on, instead of grepping twenty specs for the same CSS string. That visibility matters when UI refactors land in a busy release week.

Selectors that belong in page objects

Centralize selectors, but choose durable ones:

  • Prefer data-testid / data-test hooks agreed with frontend.
  • Prefer roles and accessible names when they are stable (Playwright getByRole with an accessible name option) rather than brittle CSS chains.
  • Avoid long absolute XPath and positional CSS like div > div:nth-child(3).
  • Avoid tying tests to visual copy that marketing changes weekly—unless the copy is the requirement under test.

When a selector must be ugly (third-party widget), isolate it in one component object and document the risk in a comment. Do not spread that XPath across the suite.

Core rules that keep POM honest

  1. Pages know selectors; tests know intent.
  2. Methods return void, data, or the next page object—pick a convention and stick to it.
  3. Assertions usually live in tests (or a thin assertion helper), not buried inside every page method—except for wait-for-ready checks.
  4. No business rules in pages — pages click and read; domain logic belongs in services or test helpers.
  5. One reason to change — LoginPage changes when login UI changes, not when pricing rules change.

Selenium Page Object example (Java)

package pages;
 
import org.openqa.selenium.By;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.support.ui.ExpectedConditions;
import org.openqa.selenium.support.ui.WebDriverWait;
 
import java.time.Duration;
 
public class LoginPage {
    private final WebDriver driver;
    private final WebDriverWait wait;
 
    private final By email = By.id("email");
    private final By password = By.id("password");
    private final By submit = By.cssSelector("button[type='submit']");
    private final By errorBanner = By.cssSelector("[data-testid='login-error']");
 
    public LoginPage(WebDriver driver) {
        this.driver = driver;
        this.wait = new WebDriverWait(driver, Duration.ofSeconds(10));
    }
 
    public LoginPage open(String baseUrl) {
        driver.get(baseUrl + "/login");
        wait.until(ExpectedConditions.visibilityOfElementLocated(email));
        return this;
    }
 
    public DashboardPage loginAs(String userEmail, String userPassword) {
        wait.until(ExpectedConditions.elementToBeClickable(email)).clear();
        driver.findElement(email).sendKeys(userEmail);
        driver.findElement(password).sendKeys(userPassword);
        driver.findElement(submit).click();
        return new DashboardPage(driver);
    }
 
    public String errorMessage() {
        return wait.until(ExpectedConditions.visibilityOfElementLocated(errorBanner)).getText();
    }
}
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertTrue;
 
class LoginTest {
    @Test
    void validUserReachesDashboard() {
        LoginPage login = new LoginPage(driver).open(baseUrl);
        DashboardPage dashboard = login.loginAs("qa@example.com", "correct-horse");
        assertTrue(dashboard.isLoaded());
    }
}

Keep waits inside page methods for readiness; keep product assertions in the test. For wait mechanics, see Selenium explicit waits in the broader automation testing guide cluster.

Playwright Page Object example (TypeScript)

import { expect, type Locator, type Page } from "@playwright/test";
 
export class LoginPage {
  readonly page: Page;
  readonly email: Locator;
  readonly password: Locator;
  readonly submit: Locator;
  readonly errorBanner: Locator;
 
  constructor(page: Page) {
    this.page = page;
    this.email = page.getByTestId("email");
    this.password = page.getByTestId("password");
    this.submit = page.getByRole("button", { name: "Sign in" });
    this.errorBanner = page.getByTestId("login-error");
  }
 
  async open() {
    await this.page.goto("/login");
    await expect(this.email).toBeVisible();
  }
 
  async loginAs(userEmail: string, userPassword: string) {
    await this.email.fill(userEmail);
    await this.password.fill(userPassword);
    await this.submit.click();
  }
}
import { test, expect } from "@playwright/test";
import { LoginPage } from "../pages/LoginPage";
 
test("valid user reaches dashboard", async ({ page }) => {
  const login = new LoginPage(page);
  await login.open();
  await login.loginAs("qa@example.com", "correct-horse");
  await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible();
});

Playwright’s locators already retry; page objects should not reintroduce hard sleeps. Prefer getByRole and getByTestId so the page object model stays aligned with accessible, testable UI.

Composition vs inheritance

Inheritance feels tidy on day one (BasePageAuthenticatedPageSettingsPage) and painful on day ninety.

Prefer composition:

  • Small components: NavBar, Toast, ConfirmModal, DataTable.
  • Pages hold those pieces and expose task-level methods.
  • Shared behavior is a helper or component object, not a deep class tree.
export class NavBar {
  constructor(private readonly page: Page) {}
  async goToCart() {
    await this.page.getByRole("link", { name: "Cart" }).click();
  }
}
 
export class ShopPage {
  readonly nav: NavBar;
  constructor(private readonly page: Page) {
    this.nav = new NavBar(page);
  }
}

Use inheritance sparingly: a thin BasePage with waitForReady() or screenshot helpers can be fine. Avoid “god” base pages that know every cookie banner and locale switcher for the whole site.

Components and fragments

Modern SPAs are not a stack of full page reloads. Model fragments when they appear in many places:

  • Header / nav
  • Filters drawer
  • Pagination
  • Modal dialogs

Name them after UX language, not framework internals. A FilterPanel used on Orders and Customers beats duplicating twenty filter locators.

Fluent returns and journey style

Some teams like fluent APIs:

new LoginPage(driver)
    .open(baseUrl)
    .loginAs(email, password)
    .openSettings()
    .enableDarkMode();

This can read well for linear journeys. Risks:

  • Long chains hide where failures occur.
  • Methods that always return this encourage dumping half the app into one class.

Compromise: fluent within a page, explicit new page objects when the screen changes.

Page Factory pitfalls (Selenium)

Selenium’s Page Factory (@FindBy + PageFactory.initElements) looks elegant and often becomes a trap:

  • Stale element issues — cached element fields go stale after navigation/DOM refresh; lazy wrappers help but surprise juniors.
  • Hidden waits — people assume annotation magic replaces explicit conditions.
  • Harder reviews — locators scatter across annotations and methods.
  • False structure — a class full of @FindBy fields is not yet a good API.

If your team uses Page Factory, keep fields as By or use fresh lookups in methods, and still apply explicit waits. Many Java teams are happier with plain By locators and clear methods—see patterns in the Java Selenium tutorial.

Naming and public API design

Good method names mirror how to write QA test cases:

  • loginAs(email, password)
  • addItemToCart(sku, qty)
  • expectEmptyCartMessage() — if you allow assertion helpers
  • Avoid clickDiv3() and fillFieldA()

Expose tasks, not clicks, unless a test truly needs a low-level escape hatch. Private methods can wrap clicks; public methods should sound like steps a human would write.

POM and flaky tests

Page objects do not fix races by themselves. They help when:

  • Ready-state waits live in open() / waitUntilLoaded().
  • Selectors prefer stable data-testid / roles over brittle CSS chains.
  • Navigation methods wait for the destination marker, not a fixed sleep.

They hurt when every method has Thread.sleep or when pages assert aggressively and swallow useful failure context. For triage habits, use your flaky tests playbook.

When NOT to use POM

Skip or heavily lighten POM when:

  • You have five smoke tests and two screens—simple functions may be enough.
  • You are doing a one-off migration script, not a long-lived suite.
  • The “page” is a pure API-driven headless check—use API clients instead.
  • Your tool already pushes a different pattern you are fluent in (for example some Cypress patterns with custom commands)—do not force Java-style classes for fashion.
  • The team will not review or own the abstraction layer.

Also avoid POM theater: generating empty page classes for every route with no methods, or wrapping a single locator in three layers of inheritance.

Cypress teams sometimes prefer custom commands and app actions over classic page classes. That can still follow POM ideas (centralize selectors) without copying Selenium folder layouts. See Cypress end-to-end testing.

Folder layout that scales

pages/
  auth/LoginPage.ts
  shop/CartPage.ts
  shop/CheckoutPage.ts
  components/NavBar.ts
  components/Toast.ts
tests/
  smoke/login.spec.ts
  regression/checkout.spec.ts

Keep tests from importing raw selectors. Keep pages from importing test runners’ expect APIs excessively—borderline assertion helpers are okay if the team agrees.

Review checklist for page objects

When reviewing a PR, ask:

  • Did a selector change localize to one page/component?
  • Can a new engineer understand the test without opening DevTools first?
  • Are waits intentional and condition-based?
  • Is there business logic that belongs in a fixture or API setup?
  • Do method names match the written cases?

Traceability still matters: map automated journeys back to structured cases from your QA test case template.

Working with dynamic lists and tables

Tables and repeating cards tempt people to put index math in every test. Prefer page helpers:

  • rowForOrder(orderId) returns a row component
  • filterByStatus("Open") performs the UI filter and waits for results
  • cellText(orderId, "Total") reads one value

That keeps tests about business identity (orderId) instead of tr[2]. The same idea applies to menus and tabs: navigate by visible name, then wait for the panel marker.

Migrating without a rewrite

If you inherit locator soup:

  1. Pick the top three flaky or frequently edited flows.
  2. Extract page objects only for those screens.
  3. Ban new raw selectors in tests via review (or lint where feasible).
  4. Delete dead methods when flows change—empty APIs are noise.

A partial POM that covers login, nav, and checkout already pays for itself. Perfection is optional; localization of churn is not.

FAQ

Is Page Object Model outdated?

No. The idea—hide selectors behind task APIs—remains sound. What ages poorly is deep inheritance, Page Factory cargo cults, and mega-pages. Prefer composition and stable locators.

POM vs Screenplay / AppActions?

Screenplay and app-action styles push interactions toward user tasks and abilities. You can adopt those ideas while still grouping selectors by screen. Choose the lightest pattern your team will maintain.

Should page objects include assertions?

Light readiness checks yes (expect(email).toBeVisible() on open). Heavy business assertions usually belong in tests so failures read as scenario failures, not cryptic page internals.

Do I need POM with Playwright fixtures?

Fixtures and page objects complement each other: fixtures provide page, auth state, and test data; page objects encapsulate UI. You do not need to pick only one.

How do I migrate a locator-soup suite?

Extract the hottest screens first (login, nav, checkout). Do not boil the ocean. Delete unused methods ruthlessly.

Conclusion

Page Object Model scales when it stays boring: clear pages, composed components, condition-based waits, and tests that read like cases. Use Selenium or Playwright idioms, avoid Page Factory superstition, and skip POM when the suite is tiny or API-first.

For the wider automation strategy, return to the automation testing guide. When you need structured cases before you encode them as page flows, QA Workflow Assistant helps generate test cases from Jira stories—see Docs and Pricing.

Stay ahead in QA

Get practical QA guides, Jira & Xray tutorials, testing checklists, AI testing insights, and occasional product updates.

No spam. Unsubscribe anytime.