Resource library

QA How-To

How to Use Playwright getByRole (2026)

Learn Playwright getByRole with accessible-name matching, filters, assertions, debugging, and production-ready TypeScript examples for stable UI tests.

19 min read | 2,664 words

TL;DR

Playwright getByRole locates an element through its ARIA role and accessible name, then applies Playwright's auto-waiting to actions and assertions. A strong default is `page.getByRole('button', { name: 'Save' })`, scoped to the smallest meaningful UI container when duplicates exist.

Key Takeaways

  • Use getByRole with an accessible name as the default locator for interactive controls.
  • Match the role exposed by the accessibility tree, not the HTML tag you expect.
  • Prefer web-first locator assertions because they retry until the UI reaches the expected state.
  • Scope repeated roles to a region, dialog, row, or list item before adding positional selectors.
  • Use exact matching only when substring matching creates a real ambiguity.
  • Treat getByRole failures as useful signals about both test design and accessible markup.

Playwright getByRole is the preferred way to locate most interactive elements because it queries the user-facing accessibility model instead of coupling a test to CSS structure. In practice, page.getByRole('button', { name: 'Save' }) is readable, resilient, and close to how a screen-reader user identifies the same control.

This guide explains roles, accessible names, matching options, scoping, assertions, debugging, and maintainable component patterns. The examples use TypeScript and @playwright/test, but the locator principles apply across Playwright languages.

TL;DR

import { test, expect } from '@playwright/test';

test('saves an account profile', async ({ page }) => {
  await page.goto('/account');
  await page.getByRole('textbox', { name: 'Display name' }).fill('Mina Shah');
  await page.getByRole('button', { name: 'Save changes' }).click();
  await expect(page.getByRole('status')).toHaveText('Profile saved');
});
Decision Recommended choice Reason
Interactive control getByRole(role, { name }) Models how users identify controls
Form field getByRole or getByLabel Both use accessible semantics
Noninteractive copy getByText Text is the user-facing contract
No useful semantic identity getByTestId Stable explicit testing contract
Repeated matches Scope to a container Preserves meaning better than nth()

1. What Playwright getByRole Actually Matches

A role locator searches the accessibility representation that the browser derives from native HTML and ARIA. A <button> has the implicit role button; an <a href='/help'> has the implicit role link; an <input type='checkbox'> has the implicit role checkbox. You normally should not add redundant role attributes to those elements. Correct native HTML gives browsers, assistive technology, and Playwright the same semantic information.

The second part of a strong locator is usually the accessible name. It can come from visible text, an associated <label>, aria-label, aria-labelledby, an image alternative, or another rule in the accessible-name computation. Playwright performs that computation for you. It does not simply search textContent.

<button aria-label='Close settings'>
  <svg aria-hidden='true'><!-- icon --></svg>
</button>
await page.getByRole('button', { name: 'Close settings' }).click();

The button has no visible words, yet its role and accessible name form a precise locator. This is also why a role locator can expose accessibility defects. If an icon-only button has no name, users of assistive technology cannot identify it and the test should not hide that problem with a complex CSS selector.

Roles are exact tokens. Use button, not btn, and heading, not h2. Playwright follows ARIA role rules, including role hierarchy behavior, so querying checkbox does not automatically match a switch. Check the rendered semantics when the result surprises you.

2. Set Up a Runnable Role-Locator Test

Create a Playwright project with the official initializer, choose TypeScript, and keep the generated configuration. The commands below produce and run a complete test suite.

npm init playwright@latest
npx playwright test
npx playwright test --ui

A minimal role-based test needs only the test runner imports. The locator stays lazy: creating it does not query the DOM immediately. Playwright resolves it when an action, assertion, or query operation needs the element. That behavior matters in reactive applications because the same locator can find a freshly rendered node after a state update.

import { test, expect } from '@playwright/test';

test('signs in with valid credentials', async ({ page }) => {
  await page.goto('/login');

  await page.getByRole('textbox', { name: 'Email address' })
    .fill('qa@example.com');
  await page.getByLabel('Password')
    .fill('correct-horse-battery-staple');
  await page.getByRole('button', { name: 'Sign in' }).click();

  await expect(page.getByRole('heading', { name: 'Dashboard' }))
    .toBeVisible();
});

For password inputs, getByLabel('Password') is the correct default because input[type=password] has no implicit role. The example uses the label locator for that reason. Use the field's associated label, not an invented ARIA role, so the same markup remains understandable to users and automation. For broader setup and execution guidance, see the Playwright automation guide.

3. Understand the Accessible Name

Most confusing failures come from guessing an element's accessible name. Consider a checkout button with a visible total:

<button aria-labelledby='checkout-label checkout-total'>
  <span id='checkout-label'>Checkout</span>
  <span id='checkout-total'>$42.00</span>
</button>

Its computed name is approximately Checkout $42.00, with whitespace normalized. A substring query for Checkout matches it. An exact query for Checkout does not.

const checkout = page.getByRole('button', { name: 'Checkout' });
await expect(checkout).toBeEnabled();

const exactCheckout = page.getByRole('button', {
  name: 'Checkout $42.00',
  exact: true,
});

Name matching is case-insensitive substring matching by default when the name is a string. Set exact: true for an exact, case-sensitive normalized match. A regular expression gives explicit control over alternatives and boundaries.

await page.getByRole('button', { name: /^save( changes)?$/i }).click();

Use a regular expression when the variation is intentional, such as localized copy supplied by a known test fixture. Do not use /.*/ or a broad fragment merely to make a failing locator pass. That removes the readable contract and can select the wrong control.

Visible text and accessible name can differ. aria-label='Remove item' overrides the text of an icon button; aria-labelledby can combine several nodes; CSS-generated content and hidden nodes have specific accessibility rules. When uncertain, inspect the browser's accessibility tree or use Playwright's locator picker. Base the test on what the browser exposes, not on a visual guess.

4. Use Role Options for Precise State Matching

getByRole accepts more than name. State options can identify a control by semantic state without reading fragile classes or attributes directly. Current role locators support relevant options such as checked, disabled, expanded, includeHidden, level, name, pressed, and selected. An option only makes sense for roles that support that ARIA state.

const annualPlan = page.getByRole('radio', {
  name: 'Annual billing',
  checked: true,
});

const accountHeading = page.getByRole('heading', {
  name: 'Account settings',
  level: 2,
});

const filtersButton = page.getByRole('button', {
  name: 'Filters',
  expanded: true,
});

await expect(annualPlan).toBeVisible();
await expect(accountHeading).toBeVisible();
await filtersButton.click();

State filtering is different from asserting state. getByRole('checkbox', { checked: true }) locates a checkbox only while it is checked. If your test needs to watch a specific checkbox change from false to true, locate it by stable identity and assert the transition instead.

const updates = page.getByRole('checkbox', { name: 'Product updates' });
await expect(updates).not.toBeChecked();
await updates.check();
await expect(updates).toBeChecked();

includeHidden: true includes elements that accessibility rules normally exclude. It is rarely correct for an end-user flow, because users cannot operate hidden controls. Use it for a focused component contract only when hidden semantics are the subject of the test. disabled follows accessible disabled semantics, which can include descendants of a disabled container. Prefer toBeDisabled() when the state is what you want to verify.

5. Combine Actions with Web-First Assertions

A locator action waits for the target to satisfy actionability checks. For a click, Playwright waits for a unique element that is visible, stable, receives events, and is enabled. That removes most manual waits, but it does not prove the business outcome. Pair the action with an assertion against an observable result.

test('deletes a saved address', async ({ page }) => {
  await page.goto('/addresses');

  const card = page.getByRole('article')
    .filter({ hasText: '18 River Road' });

  await card.getByRole('button', { name: 'Delete' }).click();

  const dialog = page.getByRole('dialog', { name: 'Delete address' });
  await expect(dialog).toBeVisible();
  await dialog.getByRole('button', { name: 'Confirm delete' }).click();

  await expect(card).toHaveCount(0);
  await expect(page.getByRole('status')).toContainText('Address deleted');
});

Playwright assertions such as toBeVisible, toHaveText, toBeChecked, toBeEnabled, and toHaveCount retry until their timeout. A raw assertion like expect(await locator.textContent()).toBe('Ready') takes one snapshot and can race a UI update. Use raw values only when retrying is unnecessary or when you deliberately wrap them in expect.poll.

Avoid { force: true } as a general fix. It bypasses some actionability protection and may let a test click through an overlay that blocks real users. Diagnose the overlay, animation, disabled state, or wrong locator first. Reliable automation mirrors valid user interaction.

6. Scope and Filter Repeated Roles

A page can correctly contain many buttons named Edit. Playwright's strictness turns that ambiguity into an error instead of silently choosing one. The maintainable solution is to scope the button to a meaningful parent, such as a row, article, dialog, region, or list item.

const userRow = page.getByRole('row')
  .filter({ has: page.getByRole('cell', { name: 'Anika Rao' }) });

await userRow.getByRole('button', { name: 'Edit' }).click();

Filtering begins from each candidate, so keep the inner locator relative to that candidate. A useful mental model is: find all rows, keep the row that has the named cell, then find the Edit button inside it.

You can also start from a named landmark.

const billing = page.getByRole('region', { name: 'Billing details' });
await billing.getByRole('button', { name: 'Add payment method' }).click();

Use .and() when one element must satisfy two locator contracts and .or() when either of two UI states is valid. If an or locator can match both branches, apply .first() only after acknowledging that possibility and asserting the state you expect. Positional methods such as first(), last(), and nth() are appropriate when order itself is the requirement, such as verifying the third result in a sorted table. They are weak substitutes for missing identity because a harmless insert changes the selected element.

For more patterns that reduce flaky selection, review reliable end-to-end testing practices.

7. Playwright getByRole vs Other Locators

A good locator hierarchy starts with the contract the user perceives. Role is often first for controls, but it is not automatically best for every node.

Locator Best use Strength Main risk
getByRole Buttons, links, headings, landmarks, widgets User-facing semantics and accessible name Requires correct accessibility
getByLabel Labeled form controls Direct relationship between label and input Duplicate or missing labels
getByText Visible noninteractive copy Clear content-level intent Copy changes and repeated text
getByPlaceholder Input identified mainly by hint text Concise for simple forms Placeholder is not a label
getByTestId Dynamic or nonsemantic test contract Stable and explicit Tests can ignore user semantics
CSS locator Structural or unusual DOM contract Maximum selector control Tight coupling to markup

Use a test ID when semantics cannot uniquely describe the element or when product copy is intentionally volatile. The Playwright getByTestId guide explains naming and configuration. CSS and XPath remain available, but long descendant selectors, framework class names, and generated IDs are usually maintenance liabilities.

Role locators also improve review quality. getByRole('button', { name: 'Approve invoice' }) tells a reviewer what the user does. locator('.panel > div:nth-child(2) .btn-primary') reveals DOM arrangement but hides behavior. Readability is not cosmetic: it lets teams detect incorrect intent before a test reaches CI.

Do not turn the hierarchy into a rigid rule. If a canvas chart exposes no useful nodes, role is not a magic solution. Test its accessible fallback, validate application state, or add a deliberate test contract. Choose the locator that expresses stable product behavior with the least incidental detail.

8. Debug a Failing Playwright Role Locator

First decide whether the failure is zero matches, multiple matches, or failed actionability. Each points to a different cause. A zero-match error often means the role or accessible name is wrong, the page has not reached the expected state, the control is inside a frame, or the element is excluded from the accessibility tree. A strictness error means the locator is under-specified. An action timeout can mean the right element is covered, moving, disabled, or detached during rendering.

Run the failing test with UI mode or the inspector:

npx playwright test tests/profile.spec.ts --ui
PWDEBUG=1 npx playwright test tests/profile.spec.ts

Use locator.count() for temporary diagnosis, not as a replacement for an assertion. Add await expect(locator).toHaveCount(1) when uniqueness is an actual requirement. locator.highlight() can visually mark matches during a headed debug session. A trace preserves DOM snapshots, actions, console output, and network activity for later inspection.

import { defineConfig } from '@playwright/test';

export default defineConfig({
  use: { trace: 'retain-on-failure' },
});

The locator picker proposes locators and shows live matches. Inspect the accessibility panel in browser developer tools to confirm computed role and name. Check whether an iframe owns the control, then enter it with page.frameLocator(...). Also inspect duplicate desktop and mobile navigation trees: responsive applications sometimes keep one copy visually hidden but incorrectly exposed. Fixing the markup can improve both the test and the product.

9. Apply getByRole in Common UI Patterns

Dialogs should have a name and contain scoped controls. This prevents a confirmation button in the background from colliding with the one in the modal.

const dialog = page.getByRole('dialog', { name: 'Invite teammate' });
await expect(dialog).toBeVisible();
await dialog.getByRole('textbox', { name: 'Work email' })
  .fill('dev@example.com');
await dialog.getByRole('button', { name: 'Send invitation' }).click();

Menus need their expanded trigger and items to expose correct states.

const accountMenu = page.getByRole('button', { name: 'Account menu' });
await accountMenu.click();
await expect(accountMenu).toHaveAttribute('aria-expanded', 'true');
await page.getByRole('menuitem', { name: 'Sign out' }).click();

For tables, scope through row and cell roles. For tabs, identify tab by name and assert selected. For tree widgets and custom comboboxes, the application's ARIA implementation must follow the widget pattern. Playwright can query the semantics, but it cannot correct an invalid keyboard model.

const permissionsTab = page.getByRole('tab', { name: 'Permissions' });
await permissionsTab.click();
await expect(permissionsTab).toHaveAttribute('aria-selected', 'true');
await expect(page.getByRole('tabpanel', { name: 'Permissions' })).toBeVisible();

Role-based checks are not a complete accessibility audit. They validate a useful slice of name, role, and state while running functional flows. Add automated accessibility scans, keyboard tests, focus checks, and manual assistive-technology review to cover broader risks. The accessibility testing checklist provides a practical companion workflow.

10. Design Maintainable Role-Based Test APIs

Page objects and component objects should expose business operations while keeping locators readable. Avoid a giant class containing every selector on the page. Small objects aligned with stable regions are easier to compose and review.

import { expect, type Locator, type Page } from '@playwright/test';

export class InviteDialog {
  readonly root: Locator;
  readonly email: Locator;
  readonly send: Locator;

  constructor(page: Page) {
    this.root = page.getByRole('dialog', { name: 'Invite teammate' });
    this.email = this.root.getByRole('textbox', { name: 'Work email' });
    this.send = this.root.getByRole('button', { name: 'Send invitation' });
  }

  async invite(address: string) {
    await this.email.fill(address);
    await this.send.click();
    await expect(this.root.getByRole('status')).toHaveText('Invitation sent');
  }
}

Do not cache an ElementHandle for later use. Keep a Locator, which resolves against the current DOM. Parameterize meaningful differences, such as the row's account name, instead of exposing generic methods like clickButton(index).

Set expectations with developers as part of the component contract: interactive elements use native HTML where possible, icon controls have names, landmarks and dialogs have labels, and repeated items expose unique content. Tests then become executable examples of that contract. If visible wording changes, update the test when the user's task has changed; do not automatically hide product changes behind test IDs.

At suite level, review failed locator changes as product signals. A renamed button may be intentional, a missing name may be an accessibility regression, and a duplicate control may reveal responsive markup leaking into the accessibility tree. The right response depends on the product behavior, not only on making CI green.

Interview Questions and Answers

Q: Why is getByRole usually preferred over a CSS selector?

It expresses the element's user-facing role and accessible name, survives many structural refactors, and works with Playwright's locator retry model. It also makes intent obvious in code review. CSS remains useful when the requirement is genuinely structural or no semantic contract exists.

Q: What is the difference between visible text and accessible name?

Visible text is what is visually rendered, while the accessible name is computed from sources such as labels, text, aria-label, and aria-labelledby. They often match, but they do not have to. getByRole matches the computed accessible name.

Q: How do you handle several buttons with the same name?

Scope the locator to a meaningful container such as a dialog, row, region, or list item. Filter that container using unique content, then locate the button inside it. Use nth() only when position is itself part of the expected behavior.

Q: Does getByRole automatically wait?

The locator is lazy, and actions on it use auto-waiting and actionability checks. Web-first assertions retry as well. Auto-waiting does not replace an assertion that verifies the requested business outcome.

Q: When would you use includeHidden?

Only when the test specifically needs an element excluded by normal accessibility rules, such as a focused component-level check of a hidden state. It is generally wrong for a user journey because hidden elements are not operable by users.

Q: Can getByRole replace accessibility testing?

No. It encourages and exercises accessible semantics, but it does not validate every WCAG requirement, keyboard path, focus transition, announcement, contrast rule, or assistive-technology behavior. Use it as one layer in a broader accessibility strategy.

Q: How do role locators behave after a React or Vue rerender?

A Locator is evaluated when an action or assertion needs it, so it can resolve to the current DOM node after a framework replaces the original element. This is safer than retaining an element handle. The role, accessible name, and scope must remain valid after the rerender, and the test should wait through a web-first assertion rather than capture an early snapshot.

Q: How would you review a pull request that changes many getByRole locators?

I would compare each change with the rendered role and accessible name, then determine whether the product behavior or only the markup changed. I would reject broad regular expressions, unexplained positional selection, and new ARIA added only for automation. I would also run the affected flow with a trace and check keyboard behavior when the change touches a custom widget.

Common Mistakes

  • Guessing the role from a tag name instead of inspecting the computed accessibility tree.
  • Adding invalid or redundant ARIA solely to make a locator pass. Prefer native HTML and correct labels.
  • Using exact: true everywhere, which makes tests fail on harmless accessible-name additions.
  • Omitting the accessible name and matching every button or link on a busy page.
  • Reaching for first() after a strictness error without proving that order is the requirement.
  • Using includeHidden: true to interact with controls a real user cannot reach.
  • Replacing web-first assertions with one-time textContent() checks.
  • Using forced clicks to mask overlays, disabled controls, animations, or incorrect targeting.
  • Treating a failed role locator only as test maintenance when it may reveal broken semantics.

Conclusion

Playwright getByRole works best when role, accessible name, and scope describe the same control a user understands. Start with getByRole(role, { name }), apply semantic state filters only when they identify the target, and pair actions with retrying assertions that prove the result.

Take one brittle CSS-based flow from your suite and rewrite it around named roles and meaningful containers. Debug every mismatch through the accessibility tree. The result should be easier to read, more resistant to DOM refactoring, and more honest about the experience your interface exposes.

Interview Questions and Answers

Why do you prefer getByRole for Playwright UI tests?

It identifies elements through role and accessible name, which are close to how users understand the interface. It is less coupled to DOM structure than CSS selectors and makes the test's intent readable. A failure can also reveal an accessibility or product-contract regression.

How does Playwright calculate the name option in getByRole?

Playwright uses the browser-aligned accessible-name computation rather than raw text alone. Sources can include an associated label, content, `aria-label`, `aria-labelledby`, and alternative text. I inspect the accessibility tree when the computed name is unclear.

How would you fix a strict mode violation from getByRole?

I first confirm that multiple matches are legitimate. Then I scope to a named region, dialog, row, article, or list item and locate the control inside it. I use a positional locator only when position is part of the requirement.

What is the difference between filtering by checked state and asserting checked state?

A checked filter changes which element the locator resolves to. An assertion keeps the target's identity stable and waits for its state to become checked. For a state transition, I locate by name and use `toBeChecked()` after the action.

What auto-waiting does a role locator provide?

The locator resolves at action time, and actions wait for relevant conditions such as uniqueness, visibility, stability, event reception, and enabled state. Web-first assertions independently retry their condition. I still assert the business outcome after the action.

When is getByRole not the right locator?

It is not ideal when the target has no useful semantic identity, when the requirement is specifically structural, or when stable product copy cannot identify it. In those cases I consider a label, text, a deliberate test ID, or a concise CSS locator while keeping the test contract explicit.

Frequently Asked Questions

What does Playwright getByRole do?

It returns a Locator for elements matching an ARIA role and optional accessible properties such as name, checked state, or heading level. Actions and web-first assertions on that locator use Playwright's waiting and retry behavior.

Is Playwright getByRole case sensitive?

A string name uses case-insensitive substring matching by default. Set `exact: true` for an exact case-sensitive normalized string match, or use a regular expression for explicit matching rules.

Why does getByRole find more than one element?

The chosen role and name are not unique in the current scope. Narrow the search to a dialog, region, row, list item, or other meaningful container instead of selecting an arbitrary position.

Should I use getByRole or getByTestId?

Use getByRole when accessible semantics describe the user-facing element. Use getByTestId when semantics are insufficient, content is deliberately unstable, or a stable explicit test contract is more appropriate.

How do I find the accessible name used by getByRole?

Use Playwright's locator picker and inspect the browser accessibility tree. Remember that labels, `aria-label`, `aria-labelledby`, text content, and alternative text can contribute to the computed name.

Does getByRole work inside an iframe?

Yes, but you must first target the frame with `frameLocator` or otherwise obtain its frame context. Then call `getByRole` within that context, because a page-level locator does not cross frame boundaries.

Can getByRole locate hidden elements?

It normally follows accessibility-tree exclusion rules. The `includeHidden: true` option can include hidden matches, but it should be reserved for tests where hidden semantics are explicitly relevant.

Related Guides