Skip to content
SDETRole
SDETRole

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>
  1. The “Orders” link in the main navigation.
  2. The search input.
  3. The View button for order #1002.
  4. The close banner button.
  5. 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 getByRole with an accessible name, then label, placeholder and text; use getByTestId when nothing user-facing is stable.
  • Strict mode throws on ambiguous matches. Fix it with chaining and filter, not with .first().
  • Assert on lists with toHaveCount and toHaveText([...]) 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.

Type to search across SDETRole.

Full-text search by Pagefind · runs in your browser Open search page →