Locators: finding elements the robust way
A browser test has to find things — the email field to fill, the sign-in button to click, the row to check. How you find them decides whether your suite is robust or a maintenance nightmare. Find elements the wrong way and every small markup change breaks a dozen tests; find them the right way and your tests survive redesigns and read like a description of what a user does. This lesson is locators — Playwright's way of finding elements, and how to do it so tests do not rot.
What a locator is
A locator describes how to find an element on the page. In Playwright you create one and then act on it:
await page.getByLabel('Email').fill('admin@agentpay.test');
await page.getByRole('button', { name: 'Sign in' }).click();
A locator is lazy and auto-retrying: it does not find the element when you create it, but when you act on it — and Playwright waits for the element to be there and ready first (the actions lesson). That is why you rarely need manual waits.
Prefer user-facing locators
The single most important idea in this lesson: find elements the way a user perceives them, not by internal implementation details. Playwright's recommended locators, in order of preference:
getByRole(role, { name })— by accessibility role and visible name.getByRole('button', { name: 'Sign in' }),getByRole('heading', { name: 'Transactions' }). This is the best default: it is how a user and a screen reader identify things, and it doubles as an accessibility check.getByLabel('Email')— a form field by its label. The robust way to target inputs, because you target the label the user reads.getByText('No transactions match these filters.')— by visible text.getByPlaceholder(...),getByRole('link', { name })— similar, user-facing.getByTestId('page-indicator')— adata-testidattribute you add deliberately for testing. Use this when there is no good user-facing handle (a status region, a specific cell) — a stable hook that does not depend on styling or structure.
Every AgentPay test uses these. The login helper is entirely user-facing:
await page.goto('/login');
await page.getByLabel('Email').fill(email);
await page.getByLabel('Password').fill(password);
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
Read it: it says exactly what a person does. That readability is not a bonus — it is what makes the test survive change.
Why not CSS selectors and XPath
The old way was CSS selectors (.btn-primary, #login-form > div:nth-child(2) > input) or XPath. Avoid
them as your default, because they bind the test to how the page is built, which changes constantly:
- A class renamed by a restyle (
.btn-primary→.button--primary) breaks the test, though nothing a user cares about changed. - A
nth-childor deep path breaks the moment a wrapperdivis added. - They say nothing about what the element is, so the test is unreadable and its failures are cryptic.
User-facing locators break only when the thing the user interacts with actually changes (the button's label, the field's label) — which is exactly when a test should need updating. That alignment is the whole point: your tests should track user-visible behaviour, not internal markup.
Locating rows, cells and lists
Real screens have repeated elements — table rows, list items — and you often need a specific one or all of them. Playwright handles this:
getByRole('row')matches every row;toHaveCount(11)asserts how many (AgentPay checks header + 10 data rows).- Filtering —
page.getByRole('row').filter({ hasText: 'failed' })narrows to rows containing text. .first(),.last(),.nth(n)— pick one from many.- Scoping —
page.locator('tbody tr td:nth-child(6)')reads a specific column across rows (AgentPay reads the status column to check a filter). Here a CSS locator is reasonable, because column position is a stable, meaningful structure — the exception that proves the "prefer user-facing" rule: use CSS when the structure itself is what you mean. - Strictness — if a locator matches more than one element and you try to act on it, Playwright errors rather than guessing. This is a feature: it forces you to be specific and catches ambiguous locators early.
A habit: locators, then act
Structure every interaction as find, then do, with user-facing locators:
await page.getByLabel('Status').selectOption('failed'); // find the select, choose
await page.getByRole('button', { name: 'Next' }).click(); // find the button, click
await expect(page.getByTestId('page-indicator')).toContainText('Page 2'); // find, assert
If you find yourself reaching for a brittle CSS path, first ask whether the element has a role, a label, or
visible text you could target instead — and if it genuinely does not, add a data-testid to the app. A
small change to the app to make it testable is almost always better than a fragile locator in the test.
Check your work
A locator describes how to find an element; it is lazy and auto-retrying, so Playwright waits for readiness when you act — which is why manual waits are rarely needed.
Prefer user-facing locators, in order: getByRole(role,{name}) (best default — how users/screen readers
see it, doubles as an a11y check), getByLabel (form fields), getByText, getByPlaceholder, and
getByTestId (a deliberate stable hook when there is no user-facing handle). AgentPay's login is entirely
these.
Avoid CSS/XPath as the default — they bind to how the page is built (classes, nth-child, wrappers),
so they break on restyles and refactors that users never notice, and they are unreadable. User-facing
locators break only when user-visible behaviour changes — exactly when a test should update.
Repeated elements — getByRole('row') + toHaveCount, .filter({ hasText }), .first/.nth, and
scoped CSS for stable structure (a column position). Strictness: a multi-match locator errors rather than
guessing — a feature.
Habit: find (user-facing) then act; if tempted by a brittle path, add a data-testid to the app
instead.
Practice
- Write locators to reach the AgentPay Status filter, the Next button, and the Transactions heading — all user-facing.
- Replace a CSS selector like
#login buttonwithgetByRole('button', { name: 'Sign in' })and explain why it is more robust. - Use
getByRole('row')andtoHaveCountto assert the transactions table shows a header plus ten rows. - Filter the rows to only those with status "failed" and count them.
- Find one element with a deliberately ambiguous locator, watch Playwright's strictness error, then make it specific.
- Identify one place in AgentPay with no good user-facing handle and justify adding a
data-testid.
Official documentation
- Playwright — Locators — The locator API and the recommended user-facing locators.
- Playwright — Other locators & best practices — Why to prefer role/label/text over CSS and XPath.
- Testing Library — Which query should I use? — The same user-facing priority, explained.
Next: actions and web-first assertions, and why you never sleep().
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship