Skip to content
End To End Tester

The Page Object Model

Encapsulating a screen behind a class so tests describe intent rather than selectors — what it fixes, the god-object failure mode, and modern alternatives.

2 min read · updated 19 September 2026

A page object wraps a screen in a class, so that tests talk about what the user is doing and one file knows where the buttons are.

typescript
// Without: the test knows about the DOM
await page.locator('#email').fill('[email protected]');
await page.locator('#password').fill('hunter2');
await page.locator('.btn.btn-primary[type=submit]').click();
await page.waitForSelector('.dashboard-header');
typescript
// With: the test knows about the product
await signInPage.signInAs(alice);
await expect(dashboard.heading).toBeVisible();

The second survives a CSS refactor, a component library migration and a change to how sign-in works. Multiply by three hundred tests and that is the whole argument.

#A page object in 2026

typescript
// TypeScript, Playwright. Locators, not element handles: they are lazy,
// so there is nothing to go stale and nothing to wait for explicitly.
import { type Page, type Locator, expect } from '@playwright/test';

export class CheckoutPage {
  readonly cardNumber: Locator;
  readonly payButton: Locator;
  readonly confirmation: Locator;

  constructor(private readonly page: Page) {
    this.cardNumber = page.getByLabel('Card number');
    this.payButton = page.getByRole('button', { name: /^Pay / });
    this.confirmation = page.getByRole('heading', { name: 'Order confirmed' });
  }

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

  /** One method per thing a user does, named in the user's words. */
  async payWith(card: { number: string; expiry: string; cvc: string }) {
    await this.cardNumber.fill(card.number);
    await this.page.getByLabel('Expiry').fill(card.expiry);
    await this.page.getByLabel('CVC').fill(card.cvc);
    await this.payButton.click();
  }
}
csharp
// C#, Selenium. The classical form: explicit waits live here, not in tests.
public sealed class CheckoutPage
{
    private readonly IWebDriver _driver;
    private readonly WebDriverWait _wait;

    public CheckoutPage(IWebDriver driver)
    {
        _driver = driver;
        _wait = new WebDriverWait(driver, TimeSpan.FromSeconds(10));
    }

    public void PayWith(Card card)
    {
        _wait.Until(d => d.FindElement(By.Id("card-number"))).SendKeys(card.Number);
        _driver.FindElement(By.Id("expiry")).SendKeys(card.Expiry);
        _driver.FindElement(By.Id("cvc")).SendKeys(card.Cvc);
        _driver.FindElement(By.CssSelector("[data-test=pay]")).Click();
    }

    public bool IsConfirmed =>
        _wait.Until(d => d.FindElements(By.CssSelector("[data-test=confirmation]")).Count > 0);
}

Note how much of the Selenium version is waiting. That code is the main thing Playwright removed, and it is why modern page objects are so much smaller.

#The god-object failure mode

The usual way this pattern rots:

typescript
// 900 lines, 60 methods, used by 200 tests
class DashboardPage {
  async openFilters() {}
  async setDateRange() {}
  async exportCsv() {}
  async openRowMenu() {}
  async assertRowCount() {}
  async openSettings() {}      // a different page, honestly
  // ...
}

Every test imports it, so every change to it risks every test, and nobody can tell which methods are still used. The fixes:

Model components, not screens. A DataGrid, a DateRangePicker, a NavBar — each used on several pages, each small.

typescript
export class OrdersPage {
  readonly grid: DataGrid;
  readonly dateRange: DateRangePicker;

  constructor(page: Page) {
    this.grid = new DataGrid(page.getByRole('table', { name: 'Orders' }));
    this.dateRange = new DateRangePicker(page.getByRole('group', { name: 'Date range' }));
  }
}

Return the next page from a navigation. await signIn.submit() returns a DashboardPage, so the flow is typed and a wrong assumption fails to compile rather than at runtime.

Do not add a method for a single test. One caller means the code belongs in the test.

#Selectors: the part that actually decides maintainability

A page object is only as durable as what it points at. In order of preference:

  1. Role and accessible namegetByRole('button', { name: 'Pay' }). Survives restyling, describes the user's view, and fails when accessibility breaks, which is a bonus.
  2. Label textgetByLabel('Card number'). Same argument for inputs.
  3. A dedicated test attributedata-test="pay". Explicit contract between the app and the suite. Use it where roles are ambiguous.
  4. CSS structure.card > div:nth-child(2) input. Breaks on the next redesign. Avoid.

See DOM testing for why the first two are more than a style preference.

#Where the pattern does not fit

The page object model assumes the UI is organised into pages. For an application that is one canvas with modes — a design tool, a map, a spreadsheet — the abstraction stops paying and you are better off with task-level helpers.

That intuition is what the screenplay pattern generalises: model actors performing tasks rather than pages holding elements. It is more machinery than most suites need, and for large suites with many user types it composes considerably better.

#The rule of thumb

A page object should be readable in one screen, and a test that uses it should be readable by someone who has never seen the application. If either is false, the abstraction is in the wrong place.

Common questions

Is the page object model still relevant with Playwright?
Yes, though less of it is needed. Playwright's locators and auto-waiting remove the waiting and staleness code that used to fill page objects, so what remains is a thin layer of intent — often a handful of methods per screen rather than a class of fifty.
Should page objects contain assertions?
The classical answer is no — a page object models the page and the test asserts. In practice, a small number of assertion helpers that express a domain condition (expectOrderConfirmed) reads better than the alternative and does not do any harm. Assertions about page internals, on the other hand, belong in the test.
What is the difference between a page object and a component object?
Scope. A page object models a whole screen; a component object models a reusable piece (a nav bar, a date picker, a data grid) that appears on many screens. Modern suites tend to be mostly component objects with thin page objects composing them.

Runnable samples for this page

last test results ↗

Working tests, not fragments — they run in CI on every push to 8exgh/endtoendtester-samples.

Was this page useful?