Mid-Level · Design Pattern

Page Object Model

Stop duplicating selectors. Encapsulate every page's structure and behaviour in one dedicated class so your tests describe what they do, not how they do it.

Mid-Level ISTQB CTAL-TAE v2.0 — K3 Apply ~12 min read + exercise

1 The Hook — Why This Matters

In 2022, a Wellington SaaS company refactored their login form. A developer changed the submit button from id="login-btn" to data-testid="login-submit" as part of an accessibility push. It was a one-line change in the frontend.

Forty-three automation tests broke overnight. Selectors were scattered across seventeen different test files. Three QA engineers spent two days hunting down every #login-btn reference. Two tests were missed and started failing in CI the following week. The team nearly missed a client demo.

If the selectors had lived in one place, the fix would have taken five minutes. The Page Object Model (POM) is that one place. It is the difference between maintainable automation and a brittle mess that collapses every time the UI breathes.

2 The Rule — The One-Sentence Version

Encapsulate every page's structure and behaviour in a dedicated class so tests describe intent, not implementation.

When a test says loginPage.login("alice", "password"), nobody needs to know which selector found the password field. If the field changes from an ID to a label-based locator, only the page object changes. Every test using it stays green.

3 The Analogy — Think Of It Like...

Analogy

A restaurant menu.

You order "fish and chips." You do not walk into the kitchen and tell the chef which fryer to use, how long to cook the fillet, or where the salt is kept. The menu hides the kitchen's internal layout. If the chef buys a new fryer, your order stays the same. POM is the menu. The test is the customer. The DOM is the kitchen.

4 Watch Me Do It — Step by Step

Here is a real Playwright example for a New Zealand payroll login page. Follow these steps every time you automate a new page.

  1. Identify a reusable page or component The login page is used by every test that needs an authenticated session. Prime candidate for a page object.
  2. Create a class that owns the locators Store every selector as a property. Use stable locators like labels and roles, not brittle CSS paths.
  3. Add interaction methods Wrap common sequences like login() into single method calls. Return page objects after navigation.
  4. Return page objects for flow continuation After login, return a DashboardPage so the test can chain actions fluently.
  5. Write the test using only page object methods The test should contain zero raw selectors. Zero.
Python / Playwright — LoginPage
class LoginPage:
    def __init__(self, page):
        self.page = page
        self.username = page.get_by_label("Email")
        self.password = page.get_by_label("Password")
        self.submit   = page.get_by_role("button", name="Sign in")

    def goto(self):
        self.page.goto("https://payroll.example.co.nz/login")
        return self

    def login(self, user: str, pw: str):
        self.username.fill(user)
        self.password.fill(pw)
        self.submit.click()
        return DashboardPage(self.page)
Python / Playwright — Test using the page object
def test_admin_can_view_payslips(page):
    login = LoginPage(page)
    login.goto().login("admin@example.co.nz", "SecurePass123!")

    dashboard = DashboardPage(page)
    expect(dashboard.payslips_link).to_be_visible()
Python / Playwright — Component-based POM for a dashboard
class HeaderComponent:
    def __init__(self, page):
        self.page = page
        self.logo = page.get_by_role("link", name="Payroll")
        self.user_menu = page.get_by_role("button", name="User menu")

    def logout(self):
        self.user_menu.click()
        self.page.get_by_text("Logout").click()
        return LoginPage(self.page)

class DashboardPage:
    def __init__(self, page):
        self.page = page
        self.header = HeaderComponent(page)
        self.payslips_table = page.locator("table[data-testid='payslips']")
        self.export_btn = page.get_by_role("button", name="Export CSV")

    def export_payslips(self):
        self.export_btn.click()
        return self

# Test using components
def test_export_and_logout(page):
    dashboard = DashboardPage(page)
    dashboard.export_payslips()
    login = dashboard.header.logout()
    expect(login.username).to_be_visible()
Pro tip: Break large page objects into smaller component objects. A dashboard with thirty different sections becomes unmaintainable; thirty component objects of three methods each is elegant.

5 When to Use It / When NOT to Use It

✅ Use POM when...

  • You have multiple tests touching the same page
  • The UI changes frequently but flows stay stable
  • You work in a team where tests are shared
  • The project will live longer than three months
  • You need onboarding docs that read like English

❌ Don't use POM when...

  • The site is a single-page marketing microsite
  • You are testing APIs only (POM is a UI pattern)
  • You are rapid-prototyping and flows are still unknown
  • The app is a component-heavy SPA (try Screenplay)
  • The overhead of classes exceeds the test count

Before you design page objects, ask:

  • Will this page or screen be used by more than one test?
  • Are the UI interactions stable, or does the page change frequently?
  • Can you keep a page object small enough to understand in one glance (under 50 lines)?
  • Is your team comfortable writing and maintaining object-oriented test code?

6 Common Mistakes — Don't Do This

🚫 Putting assertions inside page objects

I used to think: If the login page checks it is on the right URL, the test does not have to.
Actually: Page objects handle how to interact. Tests handle what to verify. Mixing them creates page objects that break when test requirements change, and tests that hide their intent. Keep assertions in tests.

🚫 God page objects with 50+ locators

I used to think: One class per "section of the app" keeps things simple.
Actually: A DashboardPage with fifty locators for every widget, chart, and sidebar link is unmaintainable. Break it into component objects: SidebarComponent, PayrollTableComponent. Single Responsibility applies to test code too.

🚫 Exposing raw WebDriver or WebElement

I used to think: Returning the underlying WebElement gives tests flexibility.
Actually: It lets every test write its own low-level logic, defeating the point of encapsulation. Return page objects, booleans, or strings. Never leak the underlying driver or element to the test layer.

When this technique fails

POM fails when you embed too much business logic in page objects (they become hard to change), when you have so many page objects that maintenance becomes a burden, or when you mix assertions into page objects so that tests become opaque. Keep page objects simple: they describe how, tests describe what.

7 Now You Try — Interview Warm-Up

🎯 Interactive Exercise

Refactor this spaghetti test into POM:

def test_order(page):
    page.goto("/shop")
    page.locator("#product-12").click()
    page.locator("[data-testid='add-to-cart']").click()
    page.locator(".cart-icon").click()
    page.locator("#checkout-btn").click()
    assert page.url == "/checkout"

Design two page objects and rewrite the test before revealing the answer.

Suggested design:

class ShopPage:
    def __init__(self, page): self.page = page
    def goto(self): self.page.goto("/shop"); return self
    def add_product_to_cart(self, testid: str):
        self.page.locator(f"[data-testid='{testid}']").click()
        self.page.locator("[data-testid='add-to-cart']").click()
        return self
    def open_cart(self):
        self.page.locator(".cart-icon").click()
        return CheckoutPage(self.page)

class CheckoutPage:
    def __init__(self, page): self.page = page
    def start_checkout(self):
        self.page.locator("#checkout-btn").click()
        return self

def test_order(page):
    shop = ShopPage(page).goto()
    shop.add_product_to_cart("product-12")
    checkout = shop.open_cart().start_checkout()
    assert checkout.page.url == "/checkout"

Tip: Notice how test_order no longer knows about #product-12 or .cart-icon. If the cart icon changes to a button with text "Cart", only ShopPage changes.

8 Self-Check — Can You Actually Do This?

Click each question to reveal the answer. If you got all three, you're ready to practice.

Q1. Why should page objects return other page objects after navigation?

It lets tests chain actions fluently (login.goto().login()) and ensures the test always has the right abstraction for the current page state. It also prevents stale references because the new object is created after the navigation completes.

Q2. What is the single biggest sign that a page object is doing too much?

It contains more than one "screen's worth" of locators or methods, or it mixes unrelated concerns like navigation, assertions, and data validation. If you cannot describe the class in one sentence, split it.

Q3. Should a page object ever contain an assert statement?

No. Assertions belong in tests. Page objects should expose methods that return state (e.g., is_logged_in() returning a boolean) so the test decides what to assert. This separation keeps page objects reusable across positive and negative test cases.

9 Interview Prep — What They'll Ask

Q1. "How does POM differ from the Screenplay Pattern?"

POM models pages as objects. Screenplay models actors and their tasks. POM is simpler and widely adopted. Screenplay scales better for complex component-heavy SPAs because it decouples who performs an action from where it happens. In NZ, most teams use POM; Screenplay appears in enterprise roles at Xero or BNZ.

Q2. "Our UI changed and fifty tests broke. How would POM have helped?"

With POM, the selector change is isolated to one class. Only that class needs updating. Without POM, the same selector is duplicated across every test that touches that element, causing a cascading failure. I would also audit whether we are using stable locators like data-testid or accessible roles rather than CSS paths.

Q3. "How do you handle shared components like a header that appears on every page?"

Extract a HeaderComponent or NavigationComponent class. Each page object that needs it instantiates or exposes the component as a property. This avoids duplicating header locators inside every page object while still keeping the component reusable.

Q4. "What is the trade-off of using POM in a small project?"

Boilerplate. Every page needs a class. For a five-test suite on a static site, the overhead can exceed the value. My rule: if I find myself copying a selector more than once, it is time to introduce a page object. Start simple and refactor when duplication appears.