Skip to content
SDETRole
SDETRole

Playwright

Playwright Locators: A Practical Guide to getByRole, getByTestId and Friends

How to choose, filter and chain Playwright locators the way the docs recommend, why strictness is your friend, and how to debug locators with codegen and UI mode.

By SDETRole Editorial Team Published 10 min read
Table of contents

Most flaky or brittle Playwright suites share a root cause that has nothing to do with timing: the locators describe the DOM, not the user interface. A selector like div.card:nth-child(3) > button.btn-primary tells you where an element happened to sit when the test was written. It tells you nothing about what the element is. The moment a designer wraps the card in another container, the test breaks while the feature still works.

Playwright’s locator API is built to push you in the other direction: find elements the way a user or assistive technology would. This guide walks through the recommended priority order, how accessible names actually work, how to filter and chain without falling back to positional selectors, and how to debug a locator that refuses to match.

What a locator is (and is not)

A Locator is a lazy description of how to find an element. Creating one does not touch the page:

const submit = page.getByRole('button', { name: 'Submit order' });
// Nothing has been queried yet.

await submit.click();
// Now Playwright resolves the locator, waits for it to be actionable, then clicks.

Every time you act on a locator or assert against it, Playwright re-resolves it against the current DOM. That is why locators do not go stale the way Selenium WebElement references do, and why you can define them once (in a page object, for example) and reuse them across navigations and re-renders.

It also explains why page.$() and page.$$() (element handles) are discouraged in modern test code. Handles point at a specific DOM node, so a re-render leaves you holding a detached node. Locators avoid that class of bug entirely.

The Playwright documentation recommends prioritising user-facing attributes and explicit contracts over DOM structure. In practice, the order looks like this:

  1. page.getByRole(): elements by ARIA role and accessible name. This is the first choice for buttons, links, checkboxes, headings, textboxes, dialogs, rows and most interactive controls.
  2. page.getByLabel(): form controls by their associated label text, aria-labelledby or aria-label.
  3. page.getByPlaceholder(): inputs by placeholder, when there is no label (which is itself worth raising as an accessibility bug).
  4. page.getByText(): non-interactive elements by visible text, such as a toast or an empty-state message.
  5. page.getByAltText(): images and other elements with an alt attribute.
  6. page.getByTitle(): elements by their title attribute.
  7. page.getByTestId(): elements by a dedicated test attribute when nothing user-facing is stable or unique enough.

CSS and XPath through page.locator() remain available, but treat them as an escape hatch rather than a default.

Why getByRole comes first

A role locator encodes two facts about the element: what kind of thing it is and what it is called. Both are things a user perceives. If a refactor changes a button into a styled div without a role, the role locator fails, and it should, because keyboard and screen reader users just lost a button. You get a cheap accessibility regression check as a side effect of normal functional tests.

await page.getByRole('link', { name: 'Pricing' }).click();
await page.getByRole('checkbox', { name: 'Remember me' }).check();
await expect(page.getByRole('heading', { name: 'Your basket', level: 1 })).toBeVisible();
await page.getByRole('row', { name: /INV-2041/ }).getByRole('button', { name: 'Download' }).click();

getByRole accepts more than a name. Useful options include checked, disabled, expanded, pressed, selected, level (for headings) and includeHidden. These let you target state without reading classes:

const menuToggle = page.getByRole('button', { name: 'Account', expanded: false });
await menuToggle.click();
await expect(page.getByRole('button', { name: 'Account', expanded: true })).toBeVisible();

When getByTestId is the right call

Test ids are not a failure. They are an explicit contract between the application and the test suite, and they are the best option when:

  • Visible text is translated, A/B tested or generated from content you do not control.
  • Several elements are legitimately identical to a user, such as repeated “Edit” icons where context is only visual.
  • You are testing a canvas-heavy or highly custom widget with no meaningful semantics.

By default getByTestId reads data-testid. If your application uses another attribute, configure it once:

// playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  use: {
    testIdAttribute: 'data-qa',
  },
});

The cost of test ids is that they do not catch accessibility or copy regressions, and they need developer buy-in to stay stable. Use them deliberately, not as the default for everything.

Accessible names: what getByRole actually matches

The name option matches the element’s accessible name, which the browser computes from several sources. Simplified, the precedence is:

  1. aria-labelledby (text of the referenced elements)
  2. aria-label
  3. Native labelling: an associated label for form controls, alt for images, the text content for buttons, links and headings
  4. title as a last resort

So for <button aria-label="Close dialog">X</button> the accessible name is “Close dialog”, not “X”. This catches people out when a test written against visible text fails:

// Fails: the accessible name is "Close dialog"
await page.getByRole('button', { name: 'X' }).click();

// Passes
await page.getByRole('button', { name: 'Close dialog' }).click();

Matching rules

By default, a string name is matched case-insensitively and as a substring. That is convenient but can be too loose:

// Matches "Save", "Save draft" and "Save and close"
page.getByRole('button', { name: 'Save' });

// Exact, case-sensitive, whole-string match
page.getByRole('button', { name: 'Save', exact: true });

// Regular expressions give full control
page.getByRole('button', { name: /^save$/i });

The same exact option exists on getByText, getByLabel, getByPlaceholder, getByAltText and getByTitle. Note that getByText normalises whitespace even when exact is set.

Implicit roles you need to know

Many elements have an implicit role, so you rarely need explicit role attributes. A few that matter in tests:

Element Implicit role
button, input type="submit" button
a with href link
input type="text", textarea textbox
input type="checkbox" checkbox
select (single) combobox
h1 to h6 heading
table rows and cells row, cell, columnheader
dialog dialog

An a element without href has no link role. If getByRole('link') cannot find something that looks like a link, check the markup before reaching for CSS.

Filtering and chaining

Real pages have repetition: product cards, table rows, list items. The instinct is to reach for nth-child or an index. Playwright gives you better tools.

Chaining narrows the search scope

Calling a locator method on another locator searches inside it:

const checkout = page.getByRole('region', { name: 'Order summary' });
await checkout.getByRole('button', { name: 'Apply coupon' }).click();

filter() selects among siblings

locator.filter() keeps only the matches that satisfy a condition. The options are hasText, hasNotText, has, hasNot and, in recent versions, visible:

const rows = page.getByRole('row');

// The row that mentions this order number
const order = rows.filter({ hasText: 'INV-2041' });
await order.getByRole('button', { name: 'Refund' }).click();

// Product cards that contain an "Out of stock" badge
const unavailable = page
  .getByTestId('product-card')
  .filter({ has: page.getByText('Out of stock') });
await expect(unavailable).toHaveCount(2);

// Cards that do NOT contain it
const available = page
  .getByTestId('product-card')
  .filter({ hasNot: page.getByText('Out of stock') });

The locator passed to has or hasNot is evaluated relative to each candidate, so page.getByText('Out of stock') here means “a descendant with that text”, not “anywhere on the page”.

and() and or()

locator.and() requires both locators to match the same element. locator.or() matches either, which is handy for UI that can legitimately show one of two states:

const saveButton = page.getByRole('button').and(page.getByTitle('Save changes'));

const newEmail = page.getByRole('button', { name: 'New email' });
const dialog = page.getByText('Confirm security settings');
await expect(newEmail.or(dialog).first()).toBeVisible();

Note the .first() in the second example. If both elements appear at once, the or locator matches two elements, and the assertion would fail strict mode.

Strictness is a feature

Actions such as click(), fill() and check(), and most single-element assertions, are strict: if the locator resolves to more than one element, Playwright throws rather than guessing.

Error: strict mode violation: getByRole('button', { name: 'Delete' }) resolved to 3 elements

This error is valuable. It means your locator does not uniquely describe what you intend, and that a test using the first match would have clicked whichever element happened to render first. The fix is almost never .first(). It is to describe the element better:

// Tempting, but ambiguous about intent
await page.getByRole('button', { name: 'Delete' }).first().click();

// Clear: delete the row for this user
await page
  .getByRole('row', { name: /ada@example\.com/ })
  .getByRole('button', { name: 'Delete' })
  .click();

first(), last() and nth() are fine when order is the behaviour under test, for example “the newest comment appears at the top”. When order is incidental, they hide ambiguity.

Multi-element APIs are not strict and are intended for lists: count(), all(), allTextContents() and assertions like toHaveCount() and toHaveText([...]):

await expect(page.getByRole('listitem')).toHaveText(['Draft', 'In review', 'Published']);

What to avoid

Long XPath and CSS chains

// Brittle: encodes layout, breaks on any wrapper change
page.locator('//div[@id="app"]/div[2]/main/section[1]/div/div[3]/button');
page.locator('#app > div:nth-child(2) section .grid > div:nth-child(3) button.btn');

Both selectors depend on depth and position. Neither tells a reader what the element is. If you must use CSS, anchor it on something meaningful and short, such as form#checkout [name="postcode"].

Styling classes

Classes like .btn-primary, .mt-4 or hashed CSS-module names (.Button_root__x7f2a) change for visual reasons. Tests should not break when someone adjusts spacing.

Text inside getByText for interactive elements

page.getByText('Submit') will match the button, but also a paragraph that says “Submit your form by Friday”. For anything a user clicks or types into, prefer getByRole or getByLabel.

Manual waits around locators

Locator actions already wait for the element to be attached, visible, stable, enabled and able to receive events. Wrapping them in waitForTimeout or waitForSelector adds time without adding reliability. Use web-first assertions (await expect(locator).toBeVisible()) when you need to wait for a state.

Debugging locators

Codegen

npx playwright codegen https://example.com opens a browser and a recorder. As you interact, it generates test code using the same priority rules described above, so it is a quick way to see which locator Playwright itself would choose. Treat the output as a starting point: codegen cannot know which text is stable or which repeated element you mean.

UI mode and the locator picker

npx playwright test --ui runs tests in an interactive UI with a timeline, DOM snapshots for every action and a Pick locator button. Click it, hover any element in the snapshot, and UI mode shows the locator it would generate. You can also type a locator into the field and see which elements it highlights. This is the fastest loop for fixing a strict mode violation.

The VS Code extension offers the same picker inside the editor, and npx playwright test --debug opens the Playwright Inspector, which steps through actions and highlights the target of each one.

Asserting with intent

When a locator matches the wrong thing, a count assertion makes the failure explicit:

await expect(page.getByRole('button', { name: 'Delete' })).toHaveCount(1);

For checking a whole region’s structure, toMatchAriaSnapshot() compares the accessibility tree against a YAML-like template. It is a useful complement to individual locators when you care about the overall structure of a menu or form.

Practising the skill

Writing good locators is a pattern-recognition skill. The Playwright Lab has locator challenges that run against real HTML fixtures, and the XPath and CSS tester is useful when you are stuck with a legacy selector and want to see what it really matches. If you are preparing for interviews, locator strategy comes up constantly; see the Playwright interview questions.

Summary

  • Locators are lazy and re-resolved on every use, so they do not go stale. Prefer them over element handles.
  • Follow the priority order: getByRole, then getByLabel, getByPlaceholder, getByText, getByAltText, getByTitle, and getByTestId as an explicit contract.
  • name matches the computed accessible name, case-insensitively and as a substring unless you pass exact: true or a regex.
  • Use chaining, filter() with has and hasText, and and() and or() instead of positional selectors.
  • Treat strict mode violations as feedback about an ambiguous locator, not as something to silence with .first().
  • Debug with codegen, UI mode’s locator picker and the browser’s accessibility tree.
Share LinkedInXReddit

#playwright#locators#accessibility#typescript

SDETRole Editorial Team

Practising SDETs and QA engineers who write, test and review every SDETRole article and lab. Code samples are run against current tool versions before publishing.

Type to search across SDETRole.

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