automationpage-objectarchitectureplaywrightseleniumqa

Page Object Model: UI Test Architecture So You Don't Drown in Maintenance

The first ten automated tests are easy to write: find an element by a selector right in the test, click, assert. Then a designer changes the login button — and it turns out that selector is copy-pasted across 40 tests, and you have to fix every one. That’s how maintenance hell is born. Page Object Model (POM) is the pattern that cures it.

The problem without POM

When locators and steps are scattered across tests:

  • Duplication. The same #login-btn in dozens of files.
  • Fragility. The UI changes — edits in many places, you miss one → a red run.
  • Unreadability. A test is a wall of find().click().type(), not a scenario.

The goal of POM is for a test to read like a scenario, while the details of “how to find and click” live in one place.

Page Object

The idea is simple: a class per page (or component) that encapsulates its locators and actions. The test doesn’t know about selectors — it calls methods.

class LoginPage:
    def __init__(self, page):
        self.page = page
        self.user = page.locator("#username")
        self.pwd = page.locator("#password")
        self.submit = page.get_by_role("button", name="Log in")

    def login(self, u, p):
        self.user.fill(u)
        self.pwd.fill(p)
        self.submit.click()
        return DashboardPage(self.page)   # return the next page
def test_login(page):
    dashboard = LoginPage(page).login("user", "pass")
    assert dashboard.greeting.is_visible()

The login markup changes — you fix one class, all tests survive.

What belongs in a Page Object, and what doesn’t

  • Belongs: element locators and actions (login(), add_to_cart(), open_menu()), waiting for the page to be ready.
  • Debatable, but classically — doesn’t belong: assertions. Keep checks in the tests (a Page Object is about interaction, the test is about verification). A PO can return data/state, and the test writes the assert. (Some teams add “check” methods like is_loaded() — that’s fine, but keep the business assertions in the test.)
  • Doesn’t belong: test data (logins/passwords come from fixtures/test data), the logic of a specific test, hardcoded environment.

Component Objects

Not everything is a “page.” A header, a modal, a table, a product card repeat across many screens. Extract them into component objects and reuse them, and assemble pages from components. Less duplication, clearer structure.

Fluent and loadable

  • Fluent: a navigation method returns the next Page Object (login()DashboardPage). The test reads as a chain and doesn’t manually construct pages.
  • Loadable / readiness wait: the Page Object itself waits for the page to load (a key element is visible) — the test doesn’t sprinkle sleep. (See the separate write-up on waits.)

Anti-patterns

  • God Object — one “page” of 1000 lines with everything. Split it into components.
  • Assertions inside the Page Object — blurs responsibility; the PO starts to “know” about the test’s expectations.
  • Fragile XPath inside the PO (/div[3]/span[2]) — encapsulation won’t save you from bad selectors; use getByRole/data-testid.
  • Duplicate locators — the same element described in two places.
  • Test logic in the PO — conditional “if this then that” checks tailored to a case.
  • A wrapper for the wrapper’s sake — a method that just forwards a single click, adding no value.

Playwright and the modern approach

Playwright encourages POM (there’s a page-class example in the docs) on top of its built-in resilient locator/getByRole. POM is often set up as fixtures — the test receives a ready loginPage. Same idea: locators and actions in the class, checks in the test.

When POM is overkill

Two or three tests on a small project — POM only adds ceremony. The pattern pays off as the suite grows and things get reused. Start simple, introduce Page Objects when you feel duplication and maintenance pain.

A good Page Object checklist

  • A class per page/component; the test doesn’t see selectors.
  • Resilient locators (getByRole/data-testid), not XPath chains.
  • Methods are domain actions (login, checkout), not click_button_3.
  • Assertions in the tests; the PO returns state.
  • Navigation returns the next PO; readiness waiting inside.
  • Reusable blocks are component objects.
  • No God Object, no test data or case logic inside.

In short

  • Locators in tests = duplication + maintenance hell; POM cures it.
  • Page Object = a class per page: encapsulates locators and actions, the test calls methods.
  • Keep assertions in tests, not in the PO; test data lives outside.
  • Reusable blocks are Component Objects; navigation is fluent; readiness waiting lives in the PO.
  • Avoid God Objects and fragile XPath; locators are getByRole/data-testid.
  • For two tests POM is overkill — introduce it as you grow.