Lesson 3 of 12 · Automation Frameworks
Playwright Locators
Find elements the way users do: getByRole and the other built-in locators, filtering and chaining, strictness, lists, frames, and when CSS or XPath is still justified.
intermediate 8 min read Updated
You will learn to
- Choose the right built-in locator using Playwright's recommended priority
- Use accessible roles and names to write resilient getByRole locators
- Narrow locators with filter, chaining, and, or, and nth
- Diagnose strict mode violations and fix brittle selectors
Prerequisites
- Playwright: Getting Started
Most flaky or high-maintenance UI tests share one root cause: they find elements in ways that break when the page changes for reasons unrelated to behaviour. A designer renames a CSS class, a developer wraps a button in another div, and fifty tests fail even though the product works.
Playwright’s answer is to locate elements the way users and assistive technologies perceive them: by role, accessible name, label and visible text. This lesson teaches you to write locators that survive refactors, read clearly in code review, and quietly push your team towards a more accessible product. It builds on Playwright: Getting Started.
What a locator is
A Locator is a description of how to find elements, not a reference to an element found at one moment. It is evaluated every time you act or assert on it:
const submit = page.getByRole('button', { name: 'Submit' }); // nothing happens yet
await submit.click(); // finds the button now, waits until actionable, clicks
await expect(submit).toBeDisabled(); // finds it again, retries until disabled
Because locators are lazy and re-evaluated, they work naturally with pages that re-render: there is no stale element problem of the kind Selenium users know from StaleElementReferenceException.
The built-in locators, in priority order
Playwright’s documentation recommends preferring user-facing attributes and explicit test ids over implementation details. A practical order:
| Priority | Locator | Use for | Example |
|---|---|---|---|
| 1 | getByRole |
Almost everything interactive or structural | page.getByRole('button', { name: 'Sign in' }) |
| 2 | getByLabel |
Form fields with a label | page.getByLabel('Email address') |
| 3 | getByPlaceholder |
Inputs with no label but a placeholder | page.getByPlaceholder('Search products') |
| 4 | getByText |
Non-interactive text content | page.getByText('Order confirmed') |
| 5 | getByAltText |
Images | page.getByAltText('Company logo') |
| 6 | getByTitle |
Elements with a title attribute |
page.getByTitle('Close dialog') |
| 7 | getByTestId |
When nothing user-facing is stable or unique | page.getByTestId('cart-total') |
| 8 | locator('css=...') / XPath |
Last resort | page.locator('.legacy-widget > span') |
getByRole in depth
getByRole finds elements by their ARIA role and accessible name, the same information a screen reader announces. Native HTML elements have implicit roles, so you rarely need explicit role attributes:
| HTML | Implicit role |
|---|---|
<button>, <input type="submit"> |
button |
<a href="..."> |
link |
<input type="text">, <textarea> |
textbox |
<input type="checkbox"> |
checkbox |
<input type="radio"> |
radio |
<select> |
combobox (single) or listbox (multiple/with size) |
<h1> to <h6> |
heading |
<ul>, <ol> / <li> |
list / listitem |
<table>, <tr>, <td>, <th> |
table, row, cell, columnheader |
<dialog> |
dialog |
<nav> |
navigation |
The accessible name usually comes from the element’s text content, an associated <label>, aria-label or aria-labelledby.
page.getByRole('heading', { name: 'Your cart', level: 2 });
page.getByRole('link', { name: 'Privacy policy' });
page.getByRole('textbox', { name: 'Email' });
page.getByRole('checkbox', { name: 'Remember me' });
page.getByRole('row', { name: /Order #1001/ });
// State options
page.getByRole('button', { name: 'Save', disabled: false });
page.getByRole('tab', { selected: true });
page.getByRole('checkbox', { checked: true });
Name matching
By default name is a case-insensitive substring match. That means { name: 'Save' } also matches “Save draft” and “Save and close”, which can cause ambiguity. Tighten it when needed:
page.getByRole('button', { name: 'Save', exact: true }); // exact, case-sensitive match
page.getByRole('button', { name: /^Save$/ }); // regex for full control
Locators as an accessibility check
If you cannot write a sensible getByRole locator for a control, a screen reader user probably cannot identify it either. An icon-only button with no accessible name forces you onto getByTestId, and that is worth raising as an accessibility defect, not just working around. Role-based locators are not a substitute for real accessibility testing, but they catch a useful class of problems for free.
Text, label and test id locators
// getByLabel works with <label for>, wrapping <label>, aria-label and aria-labelledby
await page.getByLabel('Password').fill('s3cret-Pass');
// getByText: substring and case-insensitive by default, normalises whitespace
await expect(page.getByText('Welcome back')).toBeVisible();
await expect(page.getByText('Welcome back, Ana', { exact: true })).toBeVisible();
// getByTestId uses data-testid by default
await expect(page.getByTestId('cart-total')).toHaveText('$59.98');
If your application uses a different attribute such as data-qa, change it once in the config rather than writing CSS selectors:
// playwright.config.ts
export default defineConfig({
use: { testIdAttribute: 'data-qa' },
});
Test ids are explicit contracts between developers and testers: stable, unique and unaffected by copy changes. They are a good choice for elements without a meaningful role or text, such as a container or a computed value. The trade-off is that they do not verify anything the user experiences.
Strictness: one element, or an error
Actions on a locator are strict: if the locator matches more than one element, the action throws a strict mode violation instead of silently picking one.
Error: locator.click: Error: strict mode violation: getByRole('button', { name: 'Delete' }) resolved to 3 elements
This is a feature. It tells you the locator is ambiguous, which would otherwise lead to clicking the wrong row. Fix it by narrowing the locator, not by reaching for .first().
Narrowing: chaining and filtering
Chaining
Calling a locator method on another locator searches inside it:
const cart = page.getByRole('region', { name: 'Shopping cart' });
await cart.getByRole('button', { name: 'Checkout' }).click();
const dialog = page.getByRole('dialog', { name: 'Confirm deletion' });
await dialog.getByRole('button', { name: 'Delete' }).click();
filter
filter keeps only the matches that satisfy a condition. It is the standard way to target one row in a list or table:
const row = page.getByRole('row').filter({ hasText: 'ana@example.com' });
await row.getByRole('button', { name: 'Delete' }).click();
// Filter by a child locator rather than text
const product = page.getByRole('listitem').filter({
has: page.getByRole('heading', { name: 'Blue Mug' }),
});
await product.getByRole('button', { name: 'Add to cart' }).click();
// Exclude: hasNotText and hasNot
const unpaid = page.getByRole('row').filter({ hasNotText: 'Paid' });
// Only visible matches
const visibleItems = page.getByRole('listitem').filter({ visible: true });
and, or
// Both conditions on the same element
const subscribe = page.getByRole('button').and(page.getByTitle('Subscribe'));
// Either one, useful when one of two dialogs may appear
const newEmail = page.getByRole('button', { name: 'New' });
const dialog = page.getByText('Confirm security settings');
await expect(newEmail.or(dialog).first()).toBeVisible();
Position: nth, first, last
page.getByRole('listitem').first();
page.getByRole('listitem').last();
page.getByRole('listitem').nth(2); // zero-based: the third item
Working with lists
To assert on a whole list, assert on the locator directly instead of looping:
const items = page.getByRole('listitem');
await expect(items).toHaveCount(3);
await expect(items).toHaveText(['Buy milk', 'Walk dog', 'Pay bills']); // exact order
await expect(items).toContainText(['milk', 'dog']); // subset, in order
When you genuinely need to iterate, use all() only after the list has settled, because all() does not wait for elements to appear:
await expect(items).toHaveCount(3);
for (const item of await items.all()) {
await expect(item).toBeVisible();
}
Frames and shadow DOM
Elements inside an <iframe> need a frame locator:
const payment = page.frameLocator('iframe[title="Secure payment"]');
await payment.getByLabel('Card number').fill('4242 4242 4242 4242');
Open shadow DOM is pierced automatically by Playwright’s built-in locators and CSS, so web components usually need no special handling. XPath does not pierce shadow roots.
When CSS and XPath are still justified
page.locator() accepts CSS and XPath. They are sometimes the right tool: legacy markup with no roles, labels or test ids that you cannot change, or third-party widgets. Keep them short and anchored to stable attributes:
page.locator('[data-row-id="1001"]'); // stable attribute: acceptable
page.locator('#checkout-form input[name="zip"]');
page.locator('div > div:nth-child(3) > span.sc-AxjAm'); // brittle: generated class and structure
page.locator('//html/body/div[2]/div/div[3]/button'); // brittle absolute XPath
Test selectors against sample markup in the XPath and CSS Tester.
Practice exercise
Given this markup, write a locator for each target without CSS or XPath:
<nav aria-label="Main">
<a href="/">Home</a> <a href="/orders">Orders</a>
</nav>
<h1>Orders</h1>
<label>Search orders <input type="search" /></label>
<table>
<tr><th>Order</th><th>Status</th><th></th></tr>
<tr><td>#1001</td><td>Paid</td><td><button>View</button></td></tr>
<tr><td>#1002</td><td>Pending</td><td><button>View</button></td></tr>
</table>
<button aria-label="Close banner">×</button>
- The “Orders” link in the main navigation.
- The search input.
- The View button for order #1002.
- The close banner button.
- All rows with status Pending.
Then try similar challenges in the Playwright Lab. A good set of answers uses getByRole for most targets, scopes the link to the navigation, and targets the row with filter rather than nth.
Show a model answer
// 1. The heading is not a link, but scoping to the navigation landmark also guards
// against other "Orders" links elsewhere on the page (a footer, a breadcrumb)
page.getByRole('navigation', { name: 'Main' }).getByRole('link', { name: 'Orders' });
// 2. The wrapping label gives the input its accessible name
page.getByLabel('Search orders');
// or: page.getByRole('searchbox', { name: 'Search orders' })
// 3. Filter the row by text, then find the button inside it
page.getByRole('row').filter({ hasText: '#1002' }).getByRole('button', { name: 'View' });
// 4. aria-label provides the accessible name
page.getByRole('button', { name: 'Close banner' });
// 5. Rows containing the text Pending
page.getByRole('row').filter({ hasText: 'Pending' });Why it is good: none of these depend on structure, classes or position, so reordering rows or restyling the table will not break them. Note type="search" gives the input the searchbox role, not textbox.
Key takeaways
- Locators are lazy descriptions re-evaluated on every action and assertion, so stale elements are not a problem.
- Prefer
getByRolewith an accessible name, then label, placeholder and text; usegetByTestIdwhen nothing user-facing is stable. - Strict mode throws on ambiguous matches. Fix it with chaining and
filter, not with.first(). - Assert on lists with
toHaveCountandtoHaveText([...])instead of manual loops. - CSS and XPath are a last resort; anchor them to stable attributes, never to generated classes or absolute paths.
Key terms
Finished this lesson?
Progress is saved in this browser. No account needed.