The Page Object Model

The Page Object Model (POM) is a design pattern for UI test automation. Each page or screen gets a class, a page object, that offers what a user can do there as methods, such as search(term) or placeOrder(address), and hides how it is done: locators, clicks, typing and waits. Tests call the methods and keep the assertions. When the "Place order" button gets a new label, one line in CheckoutPage changes instead of every test that places an order.

Despite the name, Martin Fowler notes, page objects are usually built for the significant parts of a page rather than for each whole page; classes for such parts are often called component objects. Screenplay is an alternative way to organise the same code.

Why it matters

Without this structure, every test repeats the same locators and steps. When a button is renamed or a form gains a step, dozens of tests fail although the product works, and someone spends a day editing tests instead of testing.

Page objects make an interface change a one-class fix and let a test read as the scenario it checks. They do not make a bad locator good: a fragile locator in a page object is still fragile, only now in one place.

What belongs in a page object, and what stays out

A page object wraps a page, or part of one, with an API in the application's terms. It is created per test from the test's browser handle, such as the Playwright page fixture from the previous lesson, and is the only code that knows how its screen is built. The Selenium documentation states rules that fit any tool:

  • Public methods are the services the page offers, named after user intent: placeOrder(), not clickBlueButton().
  • Internals stay inside: locators, frames, the order of clicks and waits. A test never needs a selector.
  • No assertions: a method returns data or an element, and the test decides what is correct. Fowler weighs both views and also favours keeping assertions out; checks that many tests repeat can go into shared assertion helpers. The one check that belongs inside is that the right page has loaded, made when the object is created. A JavaScript constructor cannot await, so Playwright page objects make it in the method that reaches the page, as placeOrder() does below.
  • Navigation returns the next page object: cart.checkout() returns a CheckoutPage, so the return types document the flow.
  • Different results of one action are different methods: signIn() returns the account page, signInExpectingError() the sign-in page.

Component objects: one class for a repeated part

Many parts of a screen are not pages: a header with search and a cart count, a row in an orders table, a date picker, a dialog. The Selenium documentation calls their classes page component objects; Fowler's example is an album list that contains album objects. Page objects compose them: CheckoutPage has a header, and OrdersPage returns an OrderRow for an order number.

The key technique is the root: a component object receives the locator of its own container and searches only inside it, so one class serves every row, and order('ORD-2026-000184').cancel() cannot click the neighbouring row's button. Playwright narrows a locator with filter({ hasText }) and searches inside it with getByRole; Selenium calls findElement on the parent element.

A UI suite with page objects in layers: tests hold the intent and the assertions, only page and component objects know the locators and call the driver, so a renamed button is fixed in one class.

Calls go down the stack; a page object may also call the library directly

  1. 1Teststests/The scenario in user terms and every assertion: checkout.placeOrder('Home'), then expect(…)

    Changes when a requirement changes

  2. 2Page objectspages/One class per page: methods named after what a user does, returning data, a locator or the next page object; no assertions

    Changes when a page or flow changes: one file, not every test

  3. 3Component objectscomponents/Header, order row, date picker: locators relative to the component’s own root

    Changes when a shared part changes, for every page that shows it

  4. 4Browser automation libraryPlaywright · Selenium WebDriver · CypressFinds elements, clicks, types and waits for the application

    Changes when you upgrade or switch the tool

From Tests to Browser automation library:not allowed: a locator, click or wait written in a test

// components/order-row.ts: one class for every row of the orders table
import type { Locator } from '@playwright/test';

export class OrderRow {
  constructor(private readonly root: Locator) {}    // the row's own element

  status(): Locator {
    return this.root.getByTestId('order-status');
  }

  async cancel(): Promise<void> {
    await this.root.getByRole('button', { name: 'Cancel order' }).click();
  }
}

// pages/orders-page.ts: the page composes the component
import type { Page } from '@playwright/test';
import { OrderRow } from '../components/order-row';

export class OrdersPage {
  constructor(private readonly page: Page) {}

  order(orderNumber: string): OrderRow {
    const row = this.page.getByRole('row').filter({ hasText: orderNumber });
    return new OrderRow(row);
  }
}

// in a test
const ordersPage = new OrdersPage(page);
const row = ordersPage.order('ORD-2026-000184');
await row.cancel();
await expect(row.status()).toHaveText('Cancelled');

Try it: HTML Formatter

Worked example: refactoring a checkout test

An online store has twelve tests that pass through checkout, each repeating the steps of the first test below with good, user-facing locators.

After the refactoring, page objects hold the steps, a Header component holds the cart count, and the test states only the scenario and the expected outcome. CheckoutPage models the two outcomes of submitting as two methods sharing a private submit().

  • "Place order" is renamed "Pay now": before, 12 tests change; after, one line in CheckoutPage.
  • The cart count gets a new test id or moves elsewhere in the header: only Header.cartCount() changes, and no test.
// BEFORE: tests/checkout.spec.ts (these steps are copied into 12 tests)
test('customer pays with a saved address', async ({ page }) => {
  await page.goto('/cart');
  await page.getByRole('button', { name: 'Checkout' }).click();
  await page.getByRole('radio', { name: 'Home' }).check();
  await page.getByRole('button', { name: 'Place order' }).click();
  await expect(page.getByRole('heading', { level: 1 }))
    .toHaveText('Thank you for your order');
  await expect(page.getByRole('banner').getByTestId('cart-count')).toHaveText('0');
});

// AFTER: pages/checkout-page.ts (CartPage, ConfirmationPage and the
// Header component are written the same way; imports omitted)
export class CheckoutPage {
  readonly header: Header;

  constructor(private readonly page: Page) {
    this.header = new Header(page);
  }

  async placeOrder(address: string): Promise<ConfirmationPage> {
    await this.submit(address);
    await this.page.waitForURL('**/order/confirmation');   // the page-loaded check
    return new ConfirmationPage(this.page);
  }

  async placeOrderExpectingError(address: string): Promise<CheckoutPage> {
    await this.submit(address);
    return this;                      // stays on checkout, which shows the error
  }

  errorMessage(): Locator {
    return this.page.getByRole('alert');
  }

  private async submit(address: string): Promise<void> {
    await this.page.getByRole('radio', { name: address }).check();
    await this.page.getByRole('button', { name: 'Place order' }).click();
  }
}

// AFTER: tests/checkout.spec.ts
test('customer pays with a saved address', async ({ page }) => {
  const cart = await new CartPage(page).open();
  const checkout = await cart.checkout();
  const confirmation = await checkout.placeOrder('Home');
  await expect(confirmation.heading()).toHaveText('Thank you for your order');
  await expect(confirmation.header.cartCount()).toHaveText('0');
});

Try it: Text Diff Try it: JavaScript Formatter

The Screenplay pattern: actors, tasks and questions

Page objects organise test code around screens; the Screenplay pattern organises it around the people who use the system and their goals. Serenity BDD for Java and Serenity/JS implement it; its concepts, in Serenity/JS terms, are below.

Because an actor can have several abilities, one scenario can set up an order through the API and check it in the browser.

  • Actors: people or external systems that interact with the system under test, such as Layla, a returning customer.
  • Abilities: thin wrappers around the libraries an actor uses, such as Playwright for the browser or an HTTP client for a REST API.
  • Interactions: low-level activities through one interface, such as clicking a button or sending a request.
  • Tasks: sequences of activities that form a business step, such as PlaceOrder built from opening the cart, choosing an address and submitting.
  • Questions: retrieve information, such as the order status on the page or in the API response; the test asserts on the answer.

Anti-patterns that turn page objects into a burden

Each of these makes the suite harder to change.

  • Assertions in page objects, such as verifyOrderTotal(): the test no longer shows what it expects, and tests that expect different things cannot share the method.
  • The god page: a BasePage of hundreds of lines that every page inherits, or one class for a whole single-page app. Prefer small classes and composition (a page has a header) to deep inheritance.
  • Exposing the steps: tests click and fill a page object's elements one by one, so the flow lives in the test again. Returning a locator for a retrying assertion, as Playwright's examples do, is fine.
  • Fixed sleeps in methods: a waitForTimeout(3000) inside placeOrder() slows every test that calls it and still fails on a slow day.
  • Flag-driven methods: checkout(true, false, 'Home') hides several flows behind one name.
  • Shared state: a static driver or one page object for the whole suite breaks parallel runs; create page objects per test or through a fixture.

Common mistakes

  • Writing page objects for every screen up front instead of adding classes and methods as tests need them.
  • Naming methods after mechanics, such as clickSubmitButton(), instead of what the user achieves.
  • Expecting the pattern to fix brittle tests: it moves a positional XPath or a fixed sleep into one place but does not improve it.
  • Creating test data by clicking through page objects, such as signing up a customer in every test, when an API call in a fixture is faster.

The QA perspective

Page objects are shared code: review them with developers, and expect a pull request that changes markup to update the page or component object too. Agree on conventions early and record them in the repository: method names in user language, return types, no assertions and preferred locators, and ask developers for accessible names or test ids where the page lacks them.

When a UI test fails, the failing line in the stack trace, inside a method such as CheckoutPage.placeOrder, shows where the journey broke; quote it in the bug report. If a small UI change still forces edits in many files, the structure has leaked: raise it in the retrospective instead of patching test by test.

Practice exercise

Refactor a search-and-cart test into page and component objects

Your task

The test below searches for a product, adds it to the cart and checks the cart. The search box and the cart count are in the header on every page; the results show one card per product. Design the page and component objects, then rewrite the test without any locator.

Then say which files change if "Add to cart" becomes "Add to basket", and if the cart count moves out of the header.

Material
test('adds a found product to the cart', async ({ page }) => {
  await page.goto('/');
  const header = page.getByRole('banner');
  await header.getByRole('searchbox', { name: 'Search products' }).fill('kettle');
  await header.getByRole('button', { name: 'Search' }).click();
  const card = page.getByTestId('product-card').filter({ hasText: 'Steel Kettle' });
  await expect(card.getByTestId('price')).toHaveText('89.00 SAR');
  await card.getByRole('button', { name: 'Add to cart' }).click();
  await expect(header.getByTestId('cart-count')).toHaveText('1');
  await header.getByRole('link', { name: 'Cart' }).click();
  const line = page.getByTestId('cart-line').filter({ hasText: 'Steel Kettle' });
  await expect(line).toContainText('89.00 SAR');
});
Steps
  1. Mark every locator and the page or repeated part it belongs to.
  2. Decide the classes: a page object per page, a component object per repeated part.
  3. List each class's methods by user intent, with return types: data, a locator to assert on, or the next page object.
  4. Rewrite the test with those methods, keeping every assertion in it, and answer the two change questions.
Show the model answer
Model answer

Classes: a Header component (root: the banner): search(term) → SearchResultsPage, cartCount() → locator, openCart() → CartPage. HomePage: open() → HomePage, and header. SearchResultsPage: header and product(name) → ProductCard. A ProductCard component (root: the card filtered by name): price() → locator, and addToCart(), which returns nothing because the page stays. CartPage: line(name) → locator.

The test becomes a chain of intent: const home = await new HomePage(page).open();, const results = await home.header.search('kettle');, const kettle = results.product('Steel Kettle');, await expect(kettle.price()).toHaveText('89.00 SAR');, await kettle.addToCart();, await expect(results.header.cartCount()).toHaveText('1');, const cart = await results.header.openCart(); and await expect(cart.line('Steel Kettle')).toContainText('89.00 SAR');. The three assertions and their expected values stay in the test, and no locator does.

Renaming the button changes one locator in ProductCard.addToCart() and no test. A count that leaves the header no longer belongs in Header: move cartCount() to the component that now holds it, such as a new SideMenu, and update that call in the tests that read the count; the locator still lives in one file.

Frequently asked questions

Is the Page Object Model only for Selenium?

No. Fowler notes that it applies to any UI technology, and teams use it with Playwright, Cypress, mobile tools such as Appium and desktop UI tests. Some teams use plain helper functions or the framework's custom commands instead; what matters is that tests state intent and one place knows each screen.

Should a page object return locators or values?

It depends on the tool. With retrying assertions such as Playwright's expect(locator).toHaveText(), return a locator so the assertion can wait for the page; a value read once, such as await locator.textContent(), races with the page. With Selenium, methods usually return values after an explicit wait. Either way, the test does the checking.

Should I use Screenplay instead of page objects?

Start with page and component objects: they are simpler and enough for most suites. Screenplay pays off in large suites with many user roles, or where scenarios combine interfaces, such as the API for setup and the browser for the check; its price is more, smaller classes and a steeper start. Both separate intent from interaction, so moving later is possible.

References

  • Page object models — SeleniumSelenium ProjectThe page object pattern: one class per page or component that hides locators and exposes the actions a test needs, reducing duplication.
  • Page Object (Martin Fowler)martinfowler.com · 2013The page object pattern: wrap a page in an application-specific API so tests do not touch raw HTML, and keep assertions in the tests rather than in the page object.
  • Locators — PlaywrightMicrosoft (Playwright)How Playwright finds elements, why locators re-resolve and auto-wait, and why role-based locators are recommended first.
  • Best Practices — PlaywrightMicrosoft (Playwright)Test user-visible behaviour, keep tests isolated, prefer resilient locators and web-first assertions, and debug with traces.

Practise with Mutqan tools

Beautify JavaScript and TypeScript code with consistent indentation and spacing.

Formatting

Compare two texts line by line or word by word and highlight differences.

Formatting

Beautify HTML with proper indentation of nested elements, preserving pre, script and style blocks.

Formatting

Go deeper

Your progress on this topic

Part of the QA Roadmap