TechByteByByte

Part 20: Page Object Model

Design page objects that keep selectors and workflows reusable and readable.

A page object keeps reusable knowledge about one page or component, while the test describes the user story.

test intent → page object → Playwright actions

Series: Playwright Zero to Expert | Demo app used throughout this series: saucedemo.com

You’ve now written enough SauceDemo tests across this series to notice something: the same locators and the same login steps keep reappearing, file after file, nearly identically. This part addresses that directly — not as a stylistic nicety, but as a genuine, load-bearing architectural pattern that most real Playwright frameworks are built around.


The Problem, Concretely

Imagine ten different test files, each independently containing something like:

await page.getByPlaceholder("Username").fill("standard_user");
await page.getByPlaceholder("Password").fill("secret_sauce");
await page.getByRole("button", { name: "Login" }).click();

Now imagine SauceDemo’s frontend team changes the username field’s placeholder text from “Username” to “Enter your username.” Every single one of those ten files, independently, now has a broken locator — and you have to find and fix all ten, one by one.

This is precisely the problem your original spec’s analogy names directly: instead of every employee memorizing every step to operate a machine, provide them with a well-organized control panel. Recall from Part 1.1 that id was a genuinely more stable choice than a shared class for exactly the same underlying reason — this part applies that same instinct at the level of test architecture, not just individual locators.

Analogy: The Factory Machine Control Panel Imagine operating a massive industrial production machine:

  • Without POM: Every worker reaches directly into the gears, turning raw valves, throwing mechanical gears, and pulling hot levers manually. If the engineers adjust the physical position of a lever inside the machine by two inches (a placeholder text change in HTML), the workers reach for the old spot, get burned, and the assembly line shuts down (tests break).
  • With POM: You build an external control panel on the outside of the machine. The control panel has simple, clearly labeled buttons: “Login” and “Add to Cart”. The worker only presses the button on the panel. The panel handles the internal gear movements. If the internal levers are moved, the maintenance team only adjusts the wiring behind the panel button in one place. The workers continue pressing the same button without interruption.

📊 Visual Flowchart: Page Object Model Composition & Inheritance

Here is the structural hierarchy showing how BasePage, Page Objects, and Component Objects organize page elements:

graph TD
    BasePage["BasePage<br>(Common setup / waitForPageLoad)"] -->|Inherits| InventoryPage["InventoryPage<br>(App-specific products list)"]
    BasePage -->|Inherits| LoginPage["LoginPage<br>(App-specific login fields)"]

InventoryPage -->|Composition: 'has a'| CartBadge["CartBadge Component<br>(Shopping cart item count badge)"]

TestFile["Test File: add-to-cart.spec.ts"] -.->|Imports & Instantiates| InventoryPage

The Page Object Model (POM)

solves this by centralizing all knowledge of a given page’s structure — its locators and the actions you can perform on it — into a single, reusable class, imported and used everywhere that page is needed, instead of duplicated across test files.


Basic POM

// pages/LoginPage.ts
import { Page, Locator, expect } from "@playwright/test";

export class LoginPage {
  readonly page: Page;
  readonly usernameInput: Locator;
  readonly passwordInput: Locator;
  readonly loginButton: Locator;
  readonly errorMessage: Locator;

  constructor(page: Page) {
    this.page = page;
    this.usernameInput = page.getByPlaceholder("Username");
    this.passwordInput = page.getByPlaceholder("Password");
    this.loginButton = page.getByRole("button", { name: "Login" });
    this.errorMessage = page.getByTestId("error");
  }

  async goto() {
    await this.page.goto("/");
  }

  async login(username: string, password: string) {
    await this.usernameInput.fill(username);
    await this.passwordInput.fill(password);
    await this.loginButton.click();
  }

  async expectErrorMessage(message: string) {
    await expect(this.errorMessage).toContainText(message);
  }
}
// login.spec.ts
import { test, expect } from "@playwright/test";
import { LoginPage } from "./pages/LoginPage";

test("user can login with valid credentials", async ({ page }) => {
  const loginPage = new LoginPage(page);

  await loginPage.goto();
  await loginPage.login("standard_user", "secret_sauce");

  await expect(page.getByText("Products")).toBeVisible();
});

test("locked out user sees an error message", async ({ page }) => {
  const loginPage = new LoginPage(page);

  await loginPage.goto();
  await loginPage.login("locked_out_user", "secret_sauce");

  await loginPage.expectErrorMessage("Sorry, this user has been locked out");
});

Notice what this test file no longer contains at all: no raw locators, no direct calls to .fill() or .click() on individual fields. The test reads almost like plain English describing intent — “log in with these credentials,” “expect this error message” — while LoginPage handles every actual detail of how that happens. If SauceDemo’s placeholder text changes tomorrow, exactly one file needs updating: LoginPage.ts. Every test using it is automatically fixed, without being touched at all.


Reusable Methods and Component Objects

Real pages often share components across multiple pages — a header with a cart icon, a footer, a navigation menu. It’s worth extracting these into their own, separate classes, rather than duplicating them inside every full-page class:

// components/CartBadge.ts
import { Page, expect } from "@playwright/test";

export class CartBadge {
  constructor(private page: Page) {}

  async expectCount(count: number) {
    if (count === 0) {
      await expect(this.page.locator(".shopping_cart_badge")).toBeHidden();
    } else {
      await expect(this.page.locator(".shopping_cart_badge")).toHaveText(
        String(count),
      );
    }
  }
}
// pages/InventoryPage.ts
import { Page, Locator } from "@playwright/test";
import { CartBadge } from "../components/CartBadge";

export class InventoryPage {
  readonly page: Page;
  readonly cartBadge: CartBadge;

  constructor(page: Page) {
    this.page = page;
    this.cartBadge = new CartBadge(page); // composition — InventoryPage "has a" CartBadge
  }

  async addToCart(productName: string) {
    await this.page
      .locator(".inventory_item")
      .filter({ hasText: productName })
      .getByRole("button", { name: "Add to cart" })
      .click();
  }
}
test("adding an item updates the cart badge", async ({ page }) => {
  const inventoryPage = new InventoryPage(page);
  await inventoryPage.addToCart("Sauce Labs Backpack");
  await inventoryPage.cartBadge.expectCount(1);
});

This composition pattern — a page class containing smaller, focused component classes, rather than one enormous class trying to represent an entire page’s every detail — is exactly how real production frameworks scale POM to dozens or hundreds of pages without any single file becoming unmanageable.


Advanced POM — a Base Page Class

Once you have many page classes, a shared base page class avoids repeating genuinely common logic across every single one:

// pages/BasePage.ts
import { Page } from "@playwright/test";

export class BasePage {
  constructor(protected page: Page) {}

  async waitForPageLoad() {
    await this.page.waitForLoadState("domcontentloaded");
  }
}
export class InventoryPage extends BasePage {
  // inherits waitForPageLoad() automatically, and adds its own inventory-specific logic
}

POM Anti-Patterns

It’s worth being just as direct about how POM gets misused as about how it should be used correctly, since both matter equally for a real interview conversation.

Putting assertions everywhere inside page objects, rather than in tests.

A page object generally should expose the means to check something (a locator, or a helper like expectErrorMessage above, which is genuinely fine since it’s reusable, common verification logic), but burying every single assertion a test might ever want to make deep inside page object methods can make tests harder to read — a test file that’s just a sequence of vague method calls, with no visible assertions at all, obscures what’s actually being verified. A reasonable middle ground: genuinely common, repeated assertions (like checking the cart badge count) belong in the page/component object; assertions specific to one particular test’s unique intent belong in the test itself.

Making page objects too large, mirroring an entire complex page in one giant class.

This is exactly why the component-object composition pattern above exists — breaking a large page into smaller, focused pieces (a header component, a cart component, a product-list component) rather than one unmanageable InventoryPage class with fifty methods.

Over-engineering POM for a genuinely small project.

A five-test proof-of-concept project probably doesn’t need a full page-object hierarchy with base classes and component composition — that’s real, unnecessary overhead for a project that will never grow large enough to need it. POM earns its complexity cost specifically as a suite grows large enough that duplication across test files becomes a genuine, recurring maintenance problem — not automatically, on principle, from day one of every project regardless of scale.

Alternatives to POM, Briefly

POM isn’t the only pattern for organizing test code, and it’s worth knowing at least one alternative exists, since this is a genuine, live discussion in the QA community and a fair question in a senior-level interview.

The Screenplay Pattern takes a different approach — instead of organizing code around pages, it organizes around actors performing tasks using abilities, which some teams find scales better for very complex applications with many different user roles and cross-page workflows.

It’s a meaningfully different mental model, genuinely worth researching further once POM itself feels comfortable — but for the vast majority of QA automation work, and everything the rest of this series builds on, POM remains the dominant, most widely understood, and most immediately practical pattern to master first.


How It Works in a Real Test Run

A page object hides page-specific interaction details behind task-oriented methods. Tests should express intent—login as a user, add a named product, complete checkout—while locators and low-level actions remain near the component they describe.

Do not turn the page object into a second test runner. Assertions may live in tests or focused component abstractions, but giant base classes, hidden waits, mutable global state, and one method per click make failures harder to understand.

Interview Questions

Q: What specific, concrete problem does the Page Object Model solve?

Ans: It solves the duplication problem where the same locators and interaction logic for a given page get repeated across many test files. Without POM, a single UI change (like a locator no longer matching) requires finding and fixing every duplicated instance across the whole suite. With POM, that same page’s structure and behavior is centralized in one class, so a UI change requires updating exactly one place, and every test using that page object is automatically fixed.

Q: In the InventoryPage example using a CartBadge component, why is composition (a page “has a” component) used instead of just putting all the cart badge logic directly inside InventoryPage?

Ans: Because the cart badge (or similar components like a header or footer) often appears across multiple different pages, not just one. Extracting it into its own reusable component class means that logic is written once and shared everywhere it’s needed, rather than being duplicated inside every page class that happens to include it — and it keeps each individual page class focused and appropriately sized rather than growing unmanageably large.

Q: What’s a common anti-pattern involving assertions inside page objects, and what’s a reasonable middle-ground approach?

Ans: Burying every assertion deep inside page object methods can make test files hard to read, since the actual verification a test performs becomes invisible, hidden behind vague method calls. A reasonable middle ground is keeping genuinely common, repeated assertions (like checking a cart badge’s count) inside the relevant page or component object as a reusable helper, while keeping assertions specific to one particular test’s unique intent directly and visibly inside the test itself.

Q: When might Page Object Model be considered over-engineering rather than a genuine improvement?

Ans: For a very small project — a handful of tests that aren’t expected to grow significantly — building out a full page-object hierarchy, base classes, and component composition can add real overhead and complexity without a corresponding benefit, since the duplication problem POM solves may never actually become a meaningful issue at that small scale. POM’s value grows specifically as a suite grows large enough that duplication across many test files becomes a genuine, recurring maintenance cost.

Q: What does the Screenplay Pattern offer as an alternative to POM, at a conceptual level?

Ans: Rather than organizing test code around individual pages, the Screenplay Pattern organizes around actors performing tasks using defined abilities — a different mental model that some teams find scales better for very complex applications involving many distinct user roles and workflows spanning multiple pages. It’s a meaningfully different architecture, though POM remains the more widely used and immediately practical starting point for most QA automation work.

Q: A page object’s login() method internally calls expect(...).toBeVisible() to confirm the login succeeded before returning. Is this a reasonable design choice? What would you consider?

Ans: It can be a reasonable choice if every single caller of login() genuinely needs that same confirmation and would otherwise have to repeat the same assertion themselves — in that case, centralizing it avoids real duplication. I’d be more cautious if some callers specifically want to call login() with credentials expected to fail, in which case a hardcoded success assertion inside the method itself would incorrectly break those tests; a more flexible design might have login() simply perform the action and let each individual test assert on the specific outcome it actually expects, whether success or failure.


Exercises — Part 20

Understand: Explain, in your own words, exactly what breaks (and where you’d need to make fixes) in a test suite without POM versus one using POM, when a single locator on a shared page changes.

Simple Practice: Build a LoginPage class following this part’s pattern, and refactor at least three tests you’ve written earlier in this series (using different SauceDemo login scenarios — valid user, locked-out user, empty fields) to use it instead of repeating raw locators.

Real-World Scenario: Design a CheckoutPage class for SauceDemo’s multi-step checkout flow (information entry, overview, confirmation), including a component object for anything genuinely reusable across other pages (like the cart badge). Write one full test using it, verifying a successful checkout end to end.

Challenge: Take a page object you’ve built in this part and deliberately identify one assertion currently living inside it. Argue, in writing, whether it belongs there or should be moved into the test itself, using this part’s “common and reusable” versus “specific to one test’s intent” reasoning to justify your decision either way.


Next: Part 21 — Test Data Management

— hardcoded vs. dynamic data, Faker.js, test data isolation, and handling secrets responsibly.

Author
TechByteByByte Editorial Team
Reviewed by
TechByteByByte Admin
Published
Last reviewed