Designing Page Objects That Survive Refactors
A page object is meant to be the shock absorber between your tests and your markup: the UI changes, one file changes, ninety specs keep passing. In practice most page objects amplify change instead of absorbing it, because they encode the DOM structure rather than the user’s intent, and because they slowly accumulate assertions, waits and business rules until they are a second implementation of the application. This guide is for engineers maintaining a Playwright suite of any size who are tired of a button rename breaking thirty tests. It covers what belongs in a helper and what does not, why role-based locators survive refactors that CSS selectors do not, how to compose helpers instead of inheriting them, and how to prove the design holds by deliberately restructuring a component. The examples use Playwright 1.4x and TypeScript, and they slot into the layered layout described in end-to-end test architecture.
Root Cause Analysis
Page objects break for one of three reasons, and only one of them is about markup. The first is coupling to structure: a locator written as .card > div:nth-child(2) .btn-primary describes where a button sits in a tree, so any layout change invalidates it even though the button still says the same thing and does the same job. The second is responsibility creep: a helper that asserts, waits, retries and decides becomes a dependency your specs cannot reason about, and a change in one flow silently alters the behaviour of unrelated tests. The third is inheritance: a BasePage with protected members and four levels of subclasses means a change at the root can break leaves that nobody remembered existed.
Underneath all three is a single mistake — treating the page object as a model of the page rather than a vocabulary for the user’s intent. A user does not click the second div in the third card; they click “Add to basket”. Naming and locating by intent is what makes a helper survive a refactor, because refactors change structure far more often than they change what a control is called or what role it plays. That is also why the same principle shows up one tier down in Testing Library best practices: query the way a user perceives the interface, not the way the framework happens to render it.
Reproducible Setup
Start from a plain Playwright install and a suite that has the problem, so the improvement is measurable rather than theoretical.
npm install -D @playwright/test
npx playwright install chromium
// playwright.config.ts — testIdAttribute aligns with the app's own convention
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './e2e/specs',
use: { baseURL: 'http://localhost:3000', testIdAttribute: 'data-testid' },
});
// e2e/specs/basket.spec.ts — the version that breaks on every redesign
import { test, expect } from '@playwright/test';
test('adds an item', async ({ page }) => {
await page.goto('/products/42');
await page.locator('.product-card > div.actions > button.primary').click();
await expect(page.locator('#basket .count')).toHaveText('1');
});
Three separate structural facts are baked into that spec: the class of the card, the nesting of the actions container, and the id of the basket badge. Any of the three can change in a purely visual refactor.
Implementation
Step 1 — Locate by role and accessible name. Playwright’s role locators read the accessibility tree, which is derived from semantics rather than layout. A button keeps its role and its name across a class rename, a wrapper div, a CSS-in-JS migration or a move to a different position in the DOM.
// e2e/helpers/basket-page.ts
import type { Page } from '@playwright/test';
export const basketPage = (page: Page) => ({
addToBasket: () => page.getByRole('button', { name: 'Add to basket' }),
itemCount: () => page.getByRole('status', { name: 'Basket items' }),
removeItem: (title: string) =>
page.getByRole('listitem').filter({ hasText: title }).getByRole('button', { name: 'Remove' }),
});
Step 2 — Return locators, never results. A helper method that returns a locator is lazy: the query runs when the spec acts on it, so Playwright’s auto-waiting still applies and the spec keeps control of timing. A method that returns text or a boolean has already resolved, losing both.
// good: the spec decides what to do and when
await expect(basket.itemCount()).toHaveText('1');
// bad: resolved too early, no auto-wait, no useful failure message
const count = await basket.getItemCountText();
expect(count).toBe('1');
Step 3 — Keep assertions out of the helper. The moment a helper asserts, a failure points at the helper instead of the behaviour that broke, and two specs that want slightly different expectations end up with two nearly identical helper methods. Actions and locators in; expectations out.
// e2e/helpers/basket-page.ts — actions may orchestrate, but never assert
export const basketPage = (page: Page) => ({
goto: () => page.goto('/basket'),
addToBasket: () => page.getByRole('button', { name: 'Add to basket' }),
applyPromo: async (code: string) => {
await page.getByLabel('Promo code').fill(code);
await page.getByRole('button', { name: 'Apply' }).click();
},
});
Step 4 — Compose helpers instead of inheriting them. Shared behaviour such as a cookie banner or a global search belongs in its own small helper that pages include, not in a base class they extend. Composition keeps the dependency graph flat and lets a page opt out of something it does not have.
// e2e/helpers/shell.ts — shared chrome as its own vocabulary
import type { Page } from '@playwright/test';
export const shell = (page: Page) => ({
dismissCookies: () => page.getByRole('button', { name: 'Accept all' }).click(),
search: (term: string) => page.getByRole('searchbox', { name: 'Search' }).fill(term),
});
// e2e/helpers/product-page.ts — includes what it needs
import { shell } from './shell';
export const productPage = (page: Page) => ({
...shell(page),
title: () => page.getByRole('heading', { level: 1 }),
addToBasket: () => page.getByRole('button', { name: 'Add to basket' }),
});
Step 5 — Reach for a test id only where semantics genuinely run out. Some elements have no meaningful role or name: a chart canvas, a decorative container you need to scope a query to, a row in a virtualised list. Use data-testid there, deliberately, and treat each one as a small debt rather than the default tool.
// scoping by test id, then querying by role inside it
const chartPanel = page.getByTestId('revenue-chart');
await expect(chartPanel.getByRole('img', { name: /revenue/i })).toBeVisible();
Step 6 — Name helper methods after user intent. basket.addToBasket() will still make sense after three redesigns; basket.clickPrimaryButtonInActionsRow() is stale the moment the layout moves. The name is part of the interface and deserves the same care as the locator.
Verification
The test for a page object is a refactor. Change the markup in a way that is purely structural — wrap the button in a new flex container, rename the CSS class, move the basket badge to the other side of the header — and run the suite without touching any spec. If it stays green, the abstraction is doing its job.
# after a purely structural markup change, no test files edited
npx playwright test e2e/specs --reporter=line
# Running 24 tests using 4 workers
# 24 passed (18.7s)
A second, cheaper check is a repository grep. Any locator string that appears in a spec file is a leak past the helper layer, and any locator that appears in more than one helper is a shared control that wants its own small helper.
# specs should contain no raw selectors at all
grep -rnE "page\.(locator|\\\$)\(" e2e/specs | wc -l # expect 0
grep -rn "Add to basket" e2e | sort | uniq -c # expect exactly 1 definition
Troubleshooting
Symptom: getByRole('button', { name: 'Save' }) resolves to two elements. Diagnosis: the accessible name is ambiguous because a dialog and the page behind it both offer a Save button. Fix: scope the query rather than making the locator more specific — page.getByRole('dialog').getByRole('button', { name: 'Save' }). Scoping keeps the durable locator and expresses the real intent, which is the Save button in this dialog.
Symptom: the role locator finds nothing, though the control is clearly visible. Diagnosis: the control is not semantically what it looks like — a div with a click handler has no button role, and an icon-only button has no accessible name. Fix: fix the application, not the test. Adding the correct element or an aria-label improves the product for assistive technology users and makes the control locatable, which is the same argument made in testing ARIA roles with Testing Library.
Symptom: helpers keep growing conditionals such as if (await banner.isVisible()). Diagnosis: the helper is absorbing environmental variation — a cookie banner that appears only on a fresh profile, a tour that shows once. Fix: remove the variation at its source by seeding the dismissal into storage state during setup, so no test has to branch on it.
FAQ
Do I need page object classes, or are plain functions enough?
Plain functions returning an object of locators are enough for almost every suite, and they avoid the two failure modes classes attract: inheritance chains and mutable state held between steps. A factory function takes the page once and returns a small vocabulary, which is all the pattern was ever for. Reach for a class only if you genuinely need per-instance state, and even then prefer a closure first.
Should a page object wait for the page to be ready?
No — Playwright’s locators already wait for the element to be actionable, so an explicit readiness wait in the helper usually duplicates that and occasionally hides a real slowness regression. If a page has a genuine loading gate, express it as a locator the spec can assert on, such as a getByRole('progressbar') that must detach. Keeping the wait visible in the spec keeps the timing story honest.
How do I handle components that render the same control many times?
Scope first, then locate: find the row, card or region by something stable about it, and query by role inside that scope. page.getByRole('listitem').filter({ hasText: 'Blue mug' }).getByRole('button', { name: 'Remove' }) reads exactly like the user’s intent and survives reordering, because it identifies the row by its content rather than its index.
Is data-testid an anti-pattern?
It is a pragmatic tool that becomes an anti-pattern when it is the default. A suite built entirely on test ids passes even when the interface is unusable by keyboard or screen reader, because it never touches semantics. Use roles and names first, keep test ids for elements that genuinely have neither, and you get durable locators and an accessibility check as a side effect.
Related
- Back to End-to-End Test Architecture
- Reusing authenticated state across Playwright tests — remove the login flow the helpers would otherwise have to model.
- Isolating end-to-end tests with per-worker data — the data half of a durable suite.
- Choosing queries that reflect user behaviour — the same locator discipline at the component tier.