Resource library

QA How-To

Playwright getByRole: Examples and Best Practices

Explore Playwright getByRole examples for buttons, forms, tables, dialogs, strictness, accessible names, debugging, and stable TypeScript tests in CI.

22 min read | 2,861 words

TL;DR

Use `page.getByRole(role, { name })` for interactive controls and semantic regions, then scope duplicate matches to a row, card, dialog, or other meaningful container. Let locator actions auto-wait, and verify every workflow with a web-first assertion against a user-visible result.

Key Takeaways

  • Use role and accessible name together to express a user-facing element identity.
  • Scope duplicate actions to semantic containers instead of relying on positional locators.
  • Locate controls by stable identity, then use web-first assertions for state transitions.
  • Pair every role-based action with an observable business outcome.
  • Use getByLabel for password fields because they have no implicit role.
  • Treat role locator failures as signals about test design and accessible markup.

Playwright getByRole examples are most useful when they show more than a single button click. A production test must understand accessible names, strictness, state filters, component scoping, and the assertions that prove a user-visible result. The short answer is to start with a semantic locator such as page.getByRole('button', { name: 'Save' }), then narrow it through meaningful containers when the page has repeated controls.

This guide builds a practical locator strategy through runnable TypeScript tests. It focuses on the decisions an SDET makes in real suites: when role is the right contract, how to diagnose an unexpected match, and how to keep a readable test stable as the DOM changes.

TL;DR

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

test('updates notification preferences', async ({ page }) => {
  await page.goto('/settings/notifications');

  const emailUpdates = page.getByRole('checkbox', {
    name: 'Email product updates',
  });
  await emailUpdates.check();

  await page.getByRole('button', { name: 'Save preferences' }).click();

  await expect(emailUpdates).toBeChecked();
  await expect(page.getByRole('status')).toHaveText('Preferences saved');
});
Situation Recommended locator Why
Named interactive control getByRole(role, { name }) Expresses user-facing purpose
Labeled password input getByLabel(label) Password inputs have no implicit role
Repeated action Scope a role locator to a row, card, or dialog Avoids position-based selection
Dynamic control state Locate by identity, then assert the state Keeps the target stable during transitions
No useful semantics getByTestId(id) Creates an explicit automation contract

1. Playwright getByRole Examples Start with Semantics

A role locator queries the semantics exposed by the browser, not the tag name or CSS class that a developer happened to use. Native HTML supplies many implicit roles. A button is a button, an anchor with an href is a link, an input type='checkbox' is a checkbox, and an h2 is a heading. Playwright can therefore locate these elements without brittle selectors.

<nav aria-label="Account">
  <a href="/profile">Profile</a>
  <a href="/security">Security</a>
</nav>
<main>
  <h1>Profile</h1>
  <button type="button">Edit profile</button>
</main>
const accountNav = page.getByRole('navigation', { name: 'Account' });
await accountNav.getByRole('link', { name: 'Security' }).click();
await page.getByRole('button', { name: 'Edit profile' }).click();

The role name is an ARIA role token, so use navigation, link, or button, not a visual description such as menu or blue button. Playwright does not automatically walk the ARIA role hierarchy. For example, querying checkbox does not also match a widget with role switch.

Prefer native HTML to adding redundant roles. A <button role='button'> gains nothing, while an invalid role on a native control can confuse assistive technology. The locator should reward sound product markup. If an element looks like a button but is only a clickable div, fixing the component is usually better than teaching the test its DOM structure. For a wider locator hierarchy, see the Playwright locator strategy guide.

2. Understand Role and Accessible Name Together

Role alone is rarely specific enough on a real page. The name option matches the element's computed accessible name. That name may come from visible content, an associated label, aria-label, aria-labelledby, or image alternative text. It is not always equal to textContent.

<button aria-labelledby="remove-label item-name">
  <svg aria-hidden="true"></svg>
</button>
<span id="remove-label">Remove</span>
<span id="item-name">Noise-canceling headphones</span>
const removeItem = page.getByRole('button', {
  name: 'Remove Noise-canceling headphones',
});
await removeItem.click();

For a string, name matching is case-insensitive and substring-based by default. Add exact: true when the whole normalized name must match. A regular expression is useful for controlled variants.

await page.getByRole('button', { name: 'Save' }).click();

await page.getByRole('button', {
  name: 'Save',
  exact: true,
}).click();

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

Do not reach for an extremely broad regular expression to silence strictness. It makes the test's intent harder to review. First inspect the accessible name using the Playwright locator picker, trace viewer, or browser accessibility tools. If a visible icon-only button has no computed name, the failure is meaningful. The product has an accessibility gap, and a CSS workaround would merely hide it from the suite.

3. Runnable Button, Link, and Heading Patterns

The most common Playwright getByRole examples combine navigation, an action, and a web-first assertion. The following test is runnable inside a standard @playwright/test project. Replace baseURL navigation with the full application URL if the project does not configure one.

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

test('publishes a draft article', async ({ page }) => {
  await page.goto('/articles');

  await page.getByRole('link', { name: 'Drafts' }).click();
  await expect(page.getByRole('heading', {
    name: 'Draft articles',
    level: 1,
  })).toBeVisible();

  const row = page.getByRole('row').filter({
    has: page.getByRole('cell', { name: 'Release checklist' }),
  });

  await row.getByRole('link', { name: 'Edit' }).click();
  await page.getByRole('button', { name: 'Publish' }).click();

  const dialog = page.getByRole('dialog', { name: 'Publish article' });
  await expect(dialog).toBeVisible();
  await dialog.getByRole('button', { name: 'Confirm publish' }).click();

  await expect(page.getByRole('status')).toContainText('Article published');
});

The heading's level option distinguishes an h1 from an h2 while retaining semantic intent. The row is selected through its named cell, not an index. The confirmation button is scoped to its dialog, so a background button with similar text cannot interfere.

Notice that clicking is not treated as proof of success. Locator actions auto-wait for actionability, but they do not know the business outcome. The final status assertion retries until the success message is present or the assertion timeout expires. This pattern is more useful than a hard wait because it synchronizes on what the user actually observes.

4. Form Controls, Labels, and State Filters

Forms require a precise understanding of implicit roles. Text-like inputs usually expose textbox; number inputs expose spinbutton; checkboxes and radio inputs expose their matching roles; select elements commonly expose combobox. An input type='password' has no implicit role, so getByLabel is the appropriate user-facing locator.

test('creates a team member', async ({ page }) => {
  await page.goto('/team/new');

  await page.getByRole('textbox', { name: 'Full name' }).fill('Asha Menon');
  await page.getByRole('textbox', { name: 'Work email' })
    .fill('asha@example.com');
  await page.getByLabel('Temporary password').fill('Example-only-42!');
  await page.getByRole('combobox', { name: 'Access level' })
    .selectOption('editor');

  const sendInvite = page.getByRole('checkbox', {
    name: 'Send welcome email',
  });
  await sendInvite.check();

  await page.getByRole('button', { name: 'Create member' }).click();
  await expect(page.getByRole('heading', { name: 'Team members' }))
    .toBeVisible();
});

Role options can filter semantic state, including checked, disabled, expanded, level, pressed, and selected where those states apply. Use a state filter when the state identifies the current target. Use an assertion when the test is verifying a state transition.

const compactView = page.getByRole('button', { name: 'Compact view' });
await compactView.click();
await expect(compactView).toHaveAttribute('aria-pressed', 'true');

const activeTab = page.getByRole('tab', {
  name: 'Activity',
  selected: true,
});
await expect(activeTab).toBeVisible();

A locator filtered with pressed: true may stop matching when state changes. Keeping the identity stable and asserting the attribute or using an appropriate web-first assertion is easier to reason about during transitions.

5. Scope Duplicate Roles Without nth()

Strict locators require a single target before an action. That safety catches tests that might otherwise click the wrong Edit, Delete, or View button. When duplicates are valid, scope by product meaning before considering first(), last(), or nth().

test('removes one project collaborator', async ({ page }) => {
  await page.goto('/projects/atlas/collaborators');

  const collaborator = page.getByRole('listitem').filter({
    has: page.getByRole('heading', { name: 'Daniel Kim' }),
  });

  await collaborator.getByRole('button', { name: 'Remove' }).click();

  const dialog = page.getByRole('dialog', {
    name: 'Remove collaborator',
  });
  await expect(dialog).toContainText('Daniel Kim');
  await dialog.getByRole('button', { name: 'Remove access' }).click();

  await expect(collaborator).toHaveCount(0);
});

Meaningful containers include a named region, dialog, form, row, list item, article, or application-specific component located by a stable contract. Filters with has express a structural relationship while keeping the inner locator relative to each candidate.

Positional locators are correct when position is part of the requirement. A test for ranked search results may intentionally inspect the first item. A test for a specific customer should identify the customer. If inserting an unrelated row can redirect the action, the locator is underspecified.

When a page offers the same named action in a responsive desktop and mobile tree, investigate whether both are attached and visible. Scoping to the active navigation or asserting visibility is preferable to blindly choosing the first match. Hidden duplicate UI can be a component design concern as well as a testing concern.

6. Playwright getByRole Examples for Tables, Lists, and Dialogs

Complex widgets become simpler when the test starts at their semantic boundary. A table test can locate a row by a cell, a list test can select an item by its heading, and a dialog test can keep all modal actions inside the dialog.

test('approves a pending expense', async ({ page }) => {
  await page.goto('/expenses');

  const pendingTable = page.getByRole('table', {
    name: 'Pending expenses',
  });
  const expenseRow = pendingTable.getByRole('row').filter({
    has: page.getByRole('cell', { name: 'Conference travel' }),
  });

  await expect(expenseRow.getByRole('cell', { name: '$480.00' }))
    .toBeVisible();
  await expenseRow.getByRole('button', { name: 'Review' }).click();

  const review = page.getByRole('dialog', { name: 'Review expense' });
  await expect(review).toContainText('Conference travel');
  await review.getByRole('radio', { name: 'Approve' }).check();
  await review.getByRole('button', { name: 'Submit decision' }).click();

  await expect(expenseRow).toHaveCount(0);
  await expect(page.getByRole('status')).toHaveText('Expense approved');
});

A named table depends on accessible markup such as aria-label, aria-labelledby, or a correctly associated caption. If the table cannot be distinguished semantically, that may also affect assistive technology users.

Avoid building one enormous locator chain. Store boundaries such as pendingTable, expenseRow, and review in variables. The result reads like a workflow and makes failures easier to localize. It also supports focused assertions before a destructive action. For deeper assertion choices, use the Playwright expect assertion examples.

7. Choose getByRole vs getByText vs getByTestId

No single locator wins every situation. Select the contract that best represents stable behavior and user intent.

Locator Strongest use Example Watch for
getByRole Interactive elements and landmarks Named button, link, dialog, heading Missing or incorrect semantics
getByLabel Form controls Password, address, search field Unassociated or duplicate labels
getByText Visible noninteractive content Notice, product copy, table value Repeated or frequently edited text
getByTestId Explicit stable test hook Canvas wrapper, virtualized item Hiding accessibility problems
CSS Intentional structural contract Rare pseudo-class or DOM integration Tight coupling to implementation

Use role when a user can identify the element by semantic type and name. Use text for copy where role is not meaningful. Use a test ID when content is volatile or a nonsemantic component needs a stable automation boundary. The getByTestId examples guide shows how to design that boundary without filling every node with IDs.

A useful hybrid is to locate a repeated business component by test ID, then interact through roles inside it. This limits coupling while preserving user-facing controls.

const premiumPlan = page.getByTestId('plan-premium');
await premiumPlan.getByRole('button', { name: 'Select plan' }).click();
await expect(page.getByRole('heading', { name: 'Review subscription' }))
  .toBeVisible();

A locator hierarchy is a decision guide, not a purity rule. The final selector should remain understandable in code review and resilient to changes that do not alter product behavior.

8. Auto-Waiting, Assertions, and Timing

A Locator is a reusable query. Playwright resolves it when an action or assertion runs, which lets it survive many React, Vue, or Angular rerenders. Before a click, Playwright checks conditions such as uniqueness, visibility, stability, event reception, and enabled state. These checks remove the need for most manual waiting.

test('exports an audit report', async ({ page }) => {
  await page.goto('/audit');

  const exportButton = page.getByRole('button', { name: 'Export CSV' });
  await expect(exportButton).toBeEnabled();

  const downloadPromise = page.waitForEvent('download');
  await exportButton.click();
  const download = await downloadPromise;

  expect(download.suggestedFilename()).toMatch(/audit.*\.csv$/i);
});

The event promise is created before the click so a fast download cannot be missed. This is event synchronization, not a hard sleep. Similarly, web-first assertions such as toBeVisible(), toHaveText(), toBeChecked(), and toHaveCount() retry their conditions.

Do not use page.waitForTimeout() as normal application synchronization. A fixed delay is simultaneously too long on fast runs and too short on slow runs. Wait for a visible state, a network response when that response is the contract, a URL, a download, or another relevant event.

Use force: true sparingly. It bypasses selected actionability checks and can make automation do something a real user cannot. An overlay, animation, or disabled state deserves diagnosis. The guide to fixing Playwright timeouts provides a systematic workflow.

9. Debug Zero Matches and Strictness Failures

Start by classifying the failure. Zero matches usually indicate the wrong role, a different accessible name, an incorrect page state, a frame boundary, or accessibility-tree exclusion. Multiple matches mean the locator lacks a meaningful scope. An actionability timeout means the right node may be covered, moving, disabled, or detached during rendering.

Use Playwright UI mode or the inspector:

npx playwright test tests/account.spec.ts --ui
npx playwright test tests/account.spec.ts --debug

You can also pause a headed test at the relevant state:

await page.goto('/account');
await page.pause();

In the locator picker, inspect the suggested locator and confirm the role and computed name. Do not copy a long generated selector without understanding why the semantic locator failed. Check whether the control is in an iframe. A page locator does not cross frame boundaries, but frameLocator provides the correct context.

const paymentFrame = page.frameLocator('iframe[title="Secure payment"]');
await paymentFrame.getByRole('textbox', { name: 'Card number' })
  .fill('4242 4242 4242 4242');

For strictness, use await locator.count() temporarily to inspect candidate quantity, then improve the locator. Do not leave diagnostic branching that selects whichever candidate happens to be visible unless multiple layouts are a documented product contract. Traces are especially valuable in CI because they preserve the DOM snapshot, action log, network activity, and screenshots around the failure.

10. Encapsulate Role Locators in Maintainable Components

Page objects and component objects should expose tasks and stable boundaries, not recreate Selenium-style element fields for every node. Role locators can be initialized once because they remain lazy.

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): Promise<void> {
    await expect(this.root).toBeVisible();
    await this.email.fill(address);
    await this.send.click();
    await expect(this.root.getByRole('status'))
      .toHaveText('Invitation sent');
  }
}

A test can create the object after opening the dialog and call invite(). The component owns its semantic boundary and interaction sequence. It does not expose CSS internals or add arbitrary waits.

Avoid putting every assertion inside the page object. Assertions that define a reusable component contract belong there, while scenario-specific business expectations generally remain in the test. For example, the dialog can verify its own success status, but the test may also verify that a particular pending invitation appears in a team table.

Keep accessible names centralized only when the product vocabulary is genuinely shared. Over-abstraction can hide readable intent. A small amount of repeated text such as Save changes is cheaper than a maze of constants that prevents reviewers from understanding the flow.

11. Playwright getByRole Examples for Accessible UI Design

A role-based suite is not a complete accessibility audit, but it creates useful pressure on accessible markup. If a user-visible control lacks a name or uses the wrong semantic element, the preferred locator becomes difficult or impossible to write. Treat that friction as feedback.

Consider a custom disclosure. A sound implementation uses a native button, an accessible name, and aria-expanded linked to a controlled region.

<button
  type="button"
  aria-expanded="false"
  aria-controls="shipping-details"
>
  Shipping details
</button>
<div id="shipping-details" hidden>
  Delivery takes three business days.
</div>
const disclosure = page.getByRole('button', {
  name: 'Shipping details',
});
await expect(disclosure).toHaveAttribute('aria-expanded', 'false');
await disclosure.click();
await expect(disclosure).toHaveAttribute('aria-expanded', 'true');
await expect(page.getByText('Delivery takes three business days.'))
  .toBeVisible();

The test verifies both operable state and visible outcome. Add dedicated accessibility tooling for broader standards checks, keyboard navigation, color contrast, and assistive technology behavior. Role locators cover only the semantics the test touches.

Do not add an aria-label only to satisfy automation if it conflicts with visible wording. Accessible names are a user contract. Coordinate with design and accessibility owners so visible and computed names are predictable. The best Playwright locator is often a byproduct of the best HTML.

12. Build a Team Standard from These Examples

A locator standard should be short enough to apply during code review. Begin with user-facing semantics, require a name for common controls, scope duplicates to meaningful containers, and use a deliberate test ID when semantics cannot express stable identity. Pair every action with a business-relevant assertion.

A practical review checklist is:

  1. Does the locator say what the user sees or does?
  2. Is the role correct for the rendered element?
  3. Does the accessible name remain stable for the scenario?
  4. If there are duplicates, is the scope based on business identity?
  5. Does the assertion prove an outcome rather than only the click?
  6. Would a harmless DOM wrapper or CSS change break the test?
  7. Is a test ID hiding a product accessibility defect?

Review flaky tests using the same questions before increasing timeouts. Many timing symptoms begin with an ambiguous locator that sometimes resolves against a transient node. Strong identity reduces both false failures and false passes.

Apply the standard incrementally. When editing a brittle test, replace the weakest selector and add a meaningful outcome assertion. A full suite rewrite creates risk without immediate feedback. Small migrations, supported by trace review and stable component boundaries, produce a locator library the team can trust.

Interview Questions and Answers

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

It expresses an element's semantic role and accessible name, which are closer to user behavior than DOM structure. It also benefits from Locator auto-waiting and strictness. A CSS selector is still valid when structure is the actual contract, but framework classes and long descendant paths usually create unnecessary coupling.

Q: How is the accessible name different from visible text?

The browser computes an accessible name using rules that can include labels, content, aria-label, aria-labelledby, and alternative text. Visible text may contribute, but it is not the only source. I inspect the accessibility tree when the name is unclear instead of guessing.

Q: How do you solve a strict mode violation?

I confirm why multiple elements match, then scope to a meaningful parent such as a dialog, region, row, list item, or article. If needed, I filter that parent by a child with stable business text. I use nth() only when order itself is part of the requirement.

Q: What is the difference between a state filter and an assertion?

A state filter changes which element the locator matches at resolution time. An assertion keeps the target identity and waits for its state to reach an expectation. For a transition, I normally locate by role and name, perform the action, and assert the new state.

Q: Does getByRole perform accessibility testing?

It validates that the tested element exposes enough semantics to be located in the exercised state. That is useful but incomplete. It does not replace automated accessibility scans, keyboard testing, contrast evaluation, or assistive technology testing.

Q: What auto-waiting happens before a click?

Playwright resolves the locator and checks that the target is unique, visible, stable, able to receive events, and enabled. The exact checks depend on the action. I still assert the resulting business state because actionability does not prove success.

Q: When would you choose getByTestId instead?

I use a test ID when the target has no useful accessible identity, when product copy is intentionally volatile, or when a stable component boundary needs explicit identity. I keep user-facing roles inside that boundary where possible and assert visible outcomes.

Common Mistakes

  • Guessing a role from appearance. Inspect the semantic role the browser exposes.
  • Assuming visible text always equals accessible name. Labels and ARIA naming can change the computed value.
  • Using role without a name for a page containing many controls of the same type.
  • Fixing duplicate matches with first() when a business container could identify the target.
  • Filtering by a changing state when the test needs to observe that state transition.
  • Adding force: true or a hard wait before diagnosing actionability and rendering.
  • Treating a successful click as the final assertion.
  • Using a role locator to cross an iframe boundary without first selecting the frame.
  • Adding incorrect ARIA to make a locator work rather than fixing the underlying HTML.
  • Replacing every selector with a role even when text, label, or test ID better expresses the contract.

The best correction is usually to clarify identity and assert an observable result. Stable tests come from accurate product contracts, not from increasingly clever selectors.

Conclusion

Playwright getByRole examples become production-ready when role, accessible name, scope, timing, and outcome work together. Start with getByRole(role, { name }), rely on native semantics, scope repeated controls to meaningful containers, and use web-first assertions after actions.

Choose another locator when it expresses the requirement more honestly. Then add the approach to team review standards and improve brittle tests incrementally. The result is a suite that reads like user behavior and fails for reasons an engineer can act on.

Interview Questions and Answers

Why do you prefer getByRole in Playwright?

It identifies elements through semantics and accessible name, which closely reflect user interaction. It is more readable and less coupled to DOM structure than most CSS selectors. Its strictness also catches ambiguous element identity.

How does the name option work in getByRole?

The name is the computed accessible name, not simply raw text content. It may be derived from labels, content, ARIA naming, or alternative text. I inspect browser accessibility information when it differs from the visible wording.

How would you fix a strict mode violation?

I first determine why multiple matches are legitimate. Then I scope the locator to a semantic container or filter a component by stable business identity. I use a positional locator only when position is explicitly part of the scenario.

What is the difference between checked filtering and toBeChecked?

A checked filter selects only elements currently in that state. `toBeChecked()` identifies a stable target and retries until its state meets the expectation. The assertion is better for verifying a transition.

What does auto-waiting do for a role locator click?

Playwright resolves the locator and performs relevant checks such as uniqueness, visibility, stability, event reception, and enabled state. Those checks make the action realistic, but they do not prove its business outcome.

Is getByRole a complete accessibility test?

No. It exercises the semantic identity of elements touched by the scenario, which can expose missing names or roles. A complete strategy also needs keyboard checks, automated accessibility analysis, contrast review, and assistive technology coverage.

When is getByRole the wrong choice?

It is a weak fit when the target has no meaningful role and name, or when a stable explicit component identity is the actual contract. In those cases I choose label, text, test ID, or a concise structural locator based on the requirement.

Frequently Asked Questions

What is the best way to use Playwright getByRole?

Pass the semantic role and usually an accessible name, such as `page.getByRole('button', { name: 'Save changes' })`. If several controls match, scope the locator to a meaningful container before performing the action.

Is the name option in getByRole the visible text?

Not necessarily. Playwright uses the computed accessible name, which can come from content, a label, `aria-label`, `aria-labelledby`, or alternative text.

How do I use getByRole for an exact match?

Pass `exact: true` with a string name. Use a regular expression when you need explicit case handling, boundaries, or a small set of intentional variants.

Why does a getByRole locator match multiple elements?

The role and name are not unique in the current scope. Narrow the search to a dialog, row, region, article, or list item rather than choosing an arbitrary index.

Can getByRole locate a password input?

A password input has no implicit ARIA role, so `getByLabel()` is normally the correct locator. Do not add an invented role merely to use `getByRole()`.

Does getByRole wait for the element?

Locator actions resolve the element at action time and perform relevant actionability checks. Web-first assertions also retry, but you must still assert the workflow's business outcome.

Should I use getByRole or getByTestId?

Prefer getByRole when semantics and accessible name identify the target. Use getByTestId for an intentional stable test contract when user-facing semantics are insufficient or content is volatile.

Related Guides