Locators and Page Objects
Why CSS-path selectors break every sprint. Locating elements the way a user finds them, test ids as a deliberate contract, and page objects that help rather than hide.
Why CSS-path selectors break every sprint. Locating elements the way a user finds them, test ids as a deliberate contract, and page objects that help rather than hide.
A designer changes the markup of one component and forty browser tests go red. Nothing is broken. The tests were coupled to the shape of the DOM rather than to what the page offers a user, and a redesign invalidated all of them at once.
That is the maintenance cost that kills browser suites, and it is avoidable. By the end of this lesson you will know how to write locators that survive a redesign, when a test id is the right answer, and how to build page objects that reduce duplication without hiding what a test is doing.
page.locator('div.container > ul:nth-child(3) > li.item > button.primary');Every part of that is an implementation detail. The wrapping div, the
position of the list, the class names, the fact that the control is a
button at that nesting depth — none of it is something a user perceives,
and all of it changes when the component is restyled or a wrapper is added.
Worse, when it breaks the failure is opaque: locator resolved to 0 elements tells you nothing about what the page now looks like.
The alternative is to identify elements the way a person does — by what the thing is and what it says. That is stable because it is the part the product is actually promising.
// 1. Role and accessible name — the default
page.getByRole('button', { name: 'Save contact' });
page.getByRole('heading', { name: 'Contacts', level: 1 });
page.getByRole('row', { name: /Ada Lovelace/ });
page.getByRole('link', { name: 'Back to list' });
// 2. Label — for form fields
page.getByLabel('Email address');
// 3. Placeholder, alt text, title — when there is no label
page.getByPlaceholder('Search contacts');
page.getByAltText('Company logo');
// 4. Text — for content, not controls
page.getByText('No contacts yet');
// 5. Test id — a deliberate contract for the untargetable
page.getByTestId('contact-row');
// 6. CSS or XPath — last resort, and a note to fix it
page.locator('.contact-row');Role first is worth understanding rather than just following. An element's role is what it is — button, link, heading, row, checkbox — and its accessible name is what assistive technology announces for it. Together they are how a screen-reader user identifies it.
Stable, and a check in its own right.
The role and the label are what the design promises, even when the markup changes underneath.
If a button becomes a div with a click handler, or loses
its label, this fails — and it should, because the page has
become unusable for some users.
Tied to a class somebody will rename.
It survives an accessibility regression happily, and breaks on a purely cosmetic refactor.
Exactly backwards from what you want.
The other habit that prevents rot: never select the nth of something. Scope to the region you mean, then find within it.
Bad — depends on row order, which is data:
await page.getByRole('button', { name: 'Delete' }).nth(2).click();Good — finds the row by its content, then the button inside it:
const row = page.getByRole('row', { name: /Ada Lovelace/ });
await row.getByRole('button', { name: 'Delete' }).click();The bad version deletes whatever happens to be third. Add a contact, change the default sort, or let another test leave a record behind, and it deletes the wrong thing — then asserts successfully that a contact was deleted, so it passes while doing something else entirely. That is worse than a failure.
The good version says what it means, survives reordering, and reads like the intent. Chaining locators is the single most useful habit in this lesson.
// The same pattern for other regions
const dialog = page.getByRole('dialog', { name: 'Confirm deletion' });
await dialog.getByRole('button', { name: 'Delete' }).click();
const nav = page.getByRole('navigation');
await nav.getByRole('link', { name: 'Settings' }).click();Sometimes there is no role and no text: a chart, a coloured status dot, a
container you need to scope to. A data-testid is the right answer, and
the framing matters — a test id is a contract, deliberately added by a
developer, stating that a test may depend on this element.
<tr data-testid="contact-row" data-contact-id="42">
<td>Ada Lovelace</td>
</tr>page.getByTestId('contact-row');Three rules keep them useful.
Name them for the thing, not for the test. contact-row, not
test-row-3. The id describes what the element is.
Put them where nothing else works. A test id on a labelled button is redundant, and it skips the accessibility check the role locator gave you for free.
Never remove one without checking. It is an interface. Deleting it breaks tests the same way deleting a public method breaks callers, and it is worth a comment in the markup saying so.
A naming scheme helps once there are more than a handful. Page, then
component, then action — login-form-submit, contacts-row-delete — keeps
ids unique, searchable, and obvious about where they live.
A page object is a class that gathers the locators and actions for one page or component, so a test reads as intent and the locators live in one place.
export class ContactsPage {
constructor(private readonly page: Page) {}
// locators, defined once
readonly newContactButton = () =>
this.page.getByRole('button', { name: 'New contact' });
readonly nameField = () => this.page.getByLabel('Name');
readonly emailField = () => this.page.getByLabel('Email');
readonly saveButton = () => this.page.getByRole('button', { name: 'Save' });
rowFor(name: string) {
return this.page.getByRole('row', { name: new RegExp(name) });
}
async goto() {
await this.page.goto('/contacts');
}
async createContact(name: string, email: string) {
await this.newContactButton().click();
await this.nameField().fill(name);
await this.emailField().fill(email);
await this.saveButton().click();
}
}test('a created contact appears in the list', async ({ page }) => {
const contacts = new ContactsPage(page);
await contacts.goto();
await contacts.createContact('Ada Lovelace', 'ada@example.com');
await expect(contacts.rowFor('Ada Lovelace')).toBeVisible();
});The test says what it does. When the form changes, ContactsPage changes
and every test using it keeps working — which is the maintenance win.
The pattern has a well-known failure mode: methods that wrap the assertions as well as the actions, until nobody can tell what a test checks.
// too much hidden — what does "verify" mean?
test('contact creation', async ({ page }) => {
const contacts = new ContactsPage(page);
await contacts.setUpAndCreateContact();
await contacts.verifyEverythingIsCorrect();
});Two rules keep page objects honest.
Page objects hold locators and actions. Tests hold assertions. A
method called verifyContactExists moves the meaning of the test into
another file. Expose the locator and let the test assert on it — as
rowFor does above.
One method, one user-level action. createContact is a coherent thing
a user does. setUpAndCreateContactAndCheckTheList is three, and the test
that calls it says nothing.
A lighter alternative is worth knowing, because it fits Playwright particularly well: instead of classes, provide page objects as fixtures, so tests receive them ready-made.
export const test = base.extend<{ contacts: ContactsPage }>({
contacts: async ({ page }, use) => {
const contacts = new ContactsPage(page);
await contacts.goto();
await use(contacts);
},
});test('a created contact appears in the list', async ({ contacts }) => {
await contacts.createContact('Ada Lovelace', 'ada@example.com');
await expect(contacts.rowFor('Ada Lovelace')).toBeVisible();
});Less ceremony, and the navigation is part of the setup rather than the test.
Page objects are overhead. Three tests against one screen do not need one — inline locators are clearer, and premature abstraction is its own maintenance cost.
The signals that you now want one:
The same locator appears in three or more tests. That is a shared dependency wanting a home.
A change to one page broke tests in several files. The coupling is already there; a page object makes it explicit and single.
A test's first eight lines are navigation and setup before anything interesting happens.
Until then, a small module of shared locator helpers is often enough:
export const contactRow = (page: Page, name: string) =>
page.getByRole('row', { name: new RegExp(name) });// Preference order — stable because it is what the page promises
page.getByRole('button', { name: 'Save contact' }); // 1. default
page.getByLabel('Email address'); // 2. form fields
page.getByPlaceholder('Search'); // 3. no label
page.getByText('No contacts yet'); // 4. content
page.getByTestId('contact-row'); // 5. deliberate contract
page.locator('.contact-row'); // 6. last resort
// Scope, never index
const row = page.getByRole('row', { name: /Ada Lovelace/ });
await row.getByRole('button', { name: 'Delete' }).click();
// .nth(2) deletes whatever happens to be third — and still passes# Why role first
# role + accessible name is how assistive technology identifies an
# element, so the locator is stable AND it fails when the page
# becomes unusable — a free check on every journey
# Test ids: a contract, not a shortcut
name them for the THING contact-row, not test-row-3
only where nothing else works not on a labelled button
never remove one without checking it is an interface
this project's convention: {page}-{component}-{action}
# Page objects
hold: locators and actions
NOT: assertions — those belong in the test
one method = one user-level action
createContact(name, email) good
setUpAndCreateContactAndCheckTheList() three things
expose locators (rowFor) so the test can assert on them
# Or provide them as fixtures — less ceremony
test.extend({ contacts: async ({ page }, use) => { ... } })
# When to introduce one
the same locator appears in 3+ tests
one page change broke tests in several files
a test's first 8 lines are setup
# until then: inline locators, or a small module of helpers
# Tooling
npx playwright codegen URL records locators in this orderLocators and structure remove most of the maintenance cost. The next lesson tackles the other complaint about browser suites head on: flaky tests — the four real causes, how to reproduce each, and why retries are a sedative rather than a cure.
Before that, search your own browser suite for nth(, first(, and raw
CSS selectors. Each one is a scheduled failure, and rewriting them as
scoped role locators is the highest-return refactor available in a browser
suite.