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.
// 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');// 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, 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();
}
}// 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:
// 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.
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:
- Role and accessible name —
getByRole('button', { name: 'Pay' }). Survives restyling, describes the user's view, and fails when accessibility breaks, which is a bonus. - Label text —
getByLabel('Card number'). Same argument for inputs. - A dedicated test attribute —
data-test="pay". Explicit contract between the app and the suite. Use it where roles are ambiguous. - 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 ↗- TypeScript (Playwright)
browser/tests/practices/page-object-model
Working tests, not fragments — they run in CI on every push to 8exgh/endtoendtester-samples.
Was this page useful?
Related topics
- The Screenplay PatternActors, abilities, tasks and questions — a compositional alternative to page objects for suites with many user types and deep flows.
- Selenium WebDriverThe W3C standard for browser automation — where it still wins, the waiting model that decides whether a suite is stable, and Grid for scale.
- PlaywrightThe default browser automation tool in 2026 — auto-waiting locators, tracing, sharding and fixtures — with the configuration that matters and the mistakes that still cause flakiness.
- End-to-End TestingWhat belongs in an end-to-end suite and what does not, how many journeys are enough, and the practices that keep a browser suite from becoming the thing everyone ignores.
- DOM TestingTesting the layer every front-end framework produces — queries by role and accessible name, event simulation, jsdom's limits, and why selector choice decides maintainability.
- Android App TestingJUnit and Robolectric for local tests, Espresso and Compose for instrumented UI, and the emulator strategy that keeps an Android suite affordable in CI.
- Desktop Application TestingAutomating WPF, WinUI, WinForms, Electron and native desktop apps — the accessibility trees, the tooling, and why desktop UI automation is harder than the web.
- iOS App TestingXCTest, Swift Testing, XCUITest and the accessibility identifiers that make UI automation possible — plus what to run on a simulator and what needs a device.