getByRole queries the browser's accessibility tree instead of the raw DOM. That single difference makes it the most resilient, most readable, and most accessibility-friendly locator strategy in Playwright. If your tests use getByRole as the default, they simultaneously verify that your app works and that it is accessible to screen readers and assistive technologies.
This guide covers every role, every option, and every pattern you need. Whether you are new to Playwright or migrating an existing test suite away from CSS selectors, you will find complete, copy-paste-ready examples for every scenario. For a broader comparison of all locator types, see the Playwright Locators Guide.
Why getByRole Is the Recommended Locator
The Playwright documentation explicitly states: "Role locators are the recommended way to locate elements." There are three reasons this advice is worth following.
1. Accessibility-first testing. When you write page.getByRole('button', { name: 'Submit' }), you are asserting that a button element exists with the accessible name "Submit." If a developer replaces the <button> with a styled <div> that has no ARIA role, the test fails — catching an accessibility regression before it reaches production.
2. Resilient to refactors. CSS class names, data attributes, and DOM structure change constantly during development. ARIA roles and accessible names rarely change because they are tied to what the element does, not how it is styled. A locator like getByRole('link', { name: 'Pricing' }) survives a complete CSS rewrite, a component library migration, or a framework switch.
3. Self-documenting tests. Compare page.locator('.btn-primary.cta-hero') with page.getByRole('button', { name: 'Enroll Now' }). The second version tells you exactly what the test is looking for without opening the source code. New team members understand the test intent immediately.
Tip: Playwright's code generator (npx playwright codegen) already prefers getByRole when recording actions. If you record a click on a button, the generated code will use getByRole('button', { name: '...' }) by default.
How getByRole Works
Every HTML element has an ARIA role — either implicit (assigned by the browser based on the tag) or explicit (set via the role attribute). The browser builds an accessibility tree from these roles, and getByRole queries that tree directly.
Implicit vs. Explicit Roles
Most standard HTML elements have implicit roles defined by the HTML specification. You do not need to add any attributes for these to work:
<!-- role="button" (implicit) --> <button>Save</button> <!-- role="link" (implicit, only when href is present) --> <a href="/pricing">Pricing</a> <!-- role="textbox" (implicit) --> <input type="text" /> <!-- role="checkbox" (implicit) --> <input type="checkbox" /> <!-- role="heading" with level 2 (implicit) --> <h2>Features</h2> <!-- role="navigation" (implicit) --> <nav>...</nav>
When the implicit role is not enough — for example, a custom dropdown built with <div> elements — you assign an explicit role:
<!-- Custom dropdown with explicit combobox role --> <div role="combobox" aria-expanded="false" aria-label="Select country"> Select a country... </div>
The Accessibility Tree
The accessibility tree is a parallel representation of the DOM that strips away visual details and keeps only semantic information: roles, names, states, and relationships. When you call page.getByRole('button'), Playwright walks this tree — not the DOM — to find matching nodes. This means elements hidden from the accessibility tree (via aria-hidden="true" or display: none) are excluded by default.
Key insight: If getByRole cannot find your element, it usually means the element is not properly represented in the accessibility tree. This is a signal that your HTML semantics need fixing — not that you should switch to a CSS selector.
The name Option
Most pages have multiple buttons, multiple links, and multiple headings. The name option filters by accessible name — the text that screen readers announce for the element. The accessible name is computed from (in priority order):
aria-labelledby(references another element's text)aria-label(direct string label)- Associated
<label>element (for form controls) - Element's text content (for buttons, links, headings)
altattribute (for images)titleattribute (fallback)
Exact string match
// Matches <button>Submit</button> page.getByRole('button', { name: 'Submit' }); // Matches <a href="/pricing">Pricing</a> page.getByRole('link', { name: 'Pricing' });
By default, name matching is case-insensitive and matches a substring. So { name: 'submit' } will match a button with text "Submit Form".
Exact match with exact: true
// Will NOT match "Submit Form" — only exact "Submit" page.getByRole('button', { name: 'Submit', exact: true });
Regex matching
// Match any button containing "delete" (case-insensitive) page.getByRole('button', { name: /delete/i }); // Match a link that starts with "Read more" page.getByRole('link', { name: /^Read more/ }); // Match heading with dynamic user name page.getByRole('heading', { name: /Welcome, .+/ });
Tip: When a button has no visible text but uses an icon, it should have an aria-label. That label becomes the accessible name: <button aria-label="Close"><svg>...</svg></button> is matched by getByRole('button', { name: 'Close' }).
Common Roles with Examples
Below is a practical reference for the roles you will use most often. Each example shows the HTML element and the corresponding getByRole call.
button
Matches <button>, <input type="submit">, <input type="button">, and any element with role="button".
<button>Save Changes</button> <input type="submit" value="Submit Form" /> <button aria-label="Close dialog"> <svg>...</svg> </button>
// Text button page.getByRole('button', { name: 'Save Changes' }); // Submit input page.getByRole('button', { name: 'Submit Form' }); // Icon button with aria-label page.getByRole('button', { name: 'Close dialog' });
link
Matches <a href="..."> elements. Note: an <a> without href does not have an implicit link role.
<a href="/pricing">Pricing</a> <a href="/docs" aria-label="Documentation"> <svg>...</svg> Docs </a>
page.getByRole('link', { name: 'Pricing' }); page.getByRole('link', { name: 'Documentation' });
textbox
Matches <input type="text">, <input type="email">, <input type="password">, <input> (with no type), and <textarea>. The accessible name comes from the associated <label> or aria-label.
<label for="email">Email address</label> <input type="email" id="email" /> <textarea aria-label="Your message"></textarea>
page.getByRole('textbox', { name: 'Email address' }); page.getByRole('textbox', { name: 'Your message' });
checkbox and radio
Matches <input type="checkbox"> and <input type="radio">. Use the checked option to filter by state.
<label> <input type="checkbox" /> I agree to the terms </label> <label> <input type="radio" name="plan" /> Monthly </label> <label> <input type="radio" name="plan" /> Annual </label>
// Find the checkbox page.getByRole('checkbox', { name: 'I agree to the terms' }); // Find only checked checkboxes page.getByRole('checkbox', { checked: true }); // Find a specific radio button page.getByRole('radio', { name: 'Annual' });
combobox
Matches <select> elements and custom dropdowns with role="combobox". Note that <select> with no multiple attribute has the implicit role combobox, while <select multiple> has role listbox.
<label for="country">Country</label> <select id="country"> <option value="us">United States</option> <option value="uk">United Kingdom</option> </select>
page.getByRole('combobox', { name: 'Country' });
heading (h1–h6 with level option)
All heading elements (<h1> through <h6>) share the role heading. Use the level option to target a specific depth.
<h1>Dashboard</h1> <h2>Recent Activity</h2> <h3>Revenue Chart</h3>
// Any heading with text "Dashboard" page.getByRole('heading', { name: 'Dashboard' }); // Only the h1 page.getByRole('heading', { name: 'Dashboard', level: 1 }); // Any h2 on the page page.getByRole('heading', { level: 2 });
dialog
Matches <dialog> elements and any element with role="dialog" or role="alertdialog".
<dialog open aria-label="Confirm deletion"> <p>Are you sure you want to delete this item?</p> <button>Cancel</button> <button>Delete</button> </dialog>
// Find the dialog by its accessible name const dialog = page.getByRole('dialog', { name: 'Confirm deletion' }); // Then interact with elements inside it await dialog.getByRole('button', { name: 'Delete' }).click();
navigation, main, banner, contentinfo (landmarks)
HTML5 landmark elements have implicit roles that let you scope locators to specific page regions. This is extremely useful when the same button text appears in multiple areas of the page.
<header>...</header> <!-- role="banner" --> <nav aria-label="Main">...</nav> <!-- role="navigation" --> <main>...</main> <!-- role="main" --> <footer>...</footer> <!-- role="contentinfo" --> <aside>...</aside> <!-- role="complementary" -->
// Click "Home" link inside the main navigation only await page.getByRole('navigation', { name: 'Main' }) .getByRole('link', { name: 'Home' }) .click(); // Find the search button in the header, not the footer await page.getByRole('banner') .getByRole('button', { name: 'Search' }) .click(); // Assert footer contains copyright await expect(page.getByRole('contentinfo')) .toContainText('2026');
row, cell, columnheader (tables)
HTML tables have rich implicit roles: <table> is table, <tr> is row, <td> is cell, and <th> is columnheader (or rowheader).
<table> <thead> <tr> <th>Name</th> <th>Email</th> <th>Status</th> </tr> </thead> <tbody> <tr> <td>Alice</td> <td>alice@test.com</td> <td>Active</td> </tr> </tbody> </table>
// Find the "Name" column header page.getByRole('columnheader', { name: 'Name' }); // Find a row containing "Alice" page.getByRole('row', { name: 'Alice' }); // Find a cell with specific content page.getByRole('cell', { name: 'Active' }); // Count all data rows await expect(page.getByRole('row')).toHaveCount(2); // header + 1 data row
tab and tabpanel
Tab interfaces use role="tab" for the tab buttons and role="tabpanel" for the content panels. Use the selected option to find the active tab.
<div role="tablist"> <button role="tab" aria-selected="true">General</button> <button role="tab" aria-selected="false">Security</button> <button role="tab" aria-selected="false">Billing</button> </div> <div role="tabpanel">General settings...</div>
// Click the Security tab await page.getByRole('tab', { name: 'Security' }).click(); // Find the currently selected tab page.getByRole('tab', { selected: true }); // Assert the visible tab panel has content await expect(page.getByRole('tabpanel')) .toContainText('Two-factor authentication');
menuitem and menu
Matches dropdown menus and context menus built with role="menu" and role="menuitem".
<div role="menu" aria-label="File"> <button role="menuitem">New</button> <button role="menuitem">Open</button> <button role="menuitem">Save</button> </div>
// Click "Save" in the File menu await page.getByRole('menu', { name: 'File' }) .getByRole('menuitem', { name: 'Save' }) .click();
list and listitem
Matches <ul>/<ol> (role list) and <li> (role listitem).
<ul aria-label="Shopping cart"> <li>Keyboard - $79</li> <li>Mouse - $49</li> </ul>
// Find the shopping cart list page.getByRole('list', { name: 'Shopping cart' }); // Count items in the cart await expect( page.getByRole('list', { name: 'Shopping cart' }) .getByRole('listitem') ).toHaveCount(2);
Options Deep Dive
getByRole accepts a second argument with filtering options. Here is every option and when to use it:
page.getByRole(role, { name: '...', // Filter by accessible name (string or RegExp) exact: true, // Exact name match (case-sensitive, no substring) checked: true, // checkbox/radio: only checked elements disabled: true, // Only disabled elements expanded: true, // Only expanded elements (aria-expanded="true") includeHidden: true, // Include elements hidden from accessibility tree level: 2, // heading level (1-6) pressed: true, // Toggle button: only pressed (aria-pressed="true") selected: true, // tab/option: only selected (aria-selected="true") });
checked
Filters checkboxes and radio buttons by their checked state. Pass false to find unchecked elements.
// Find all checked checkboxes page.getByRole('checkbox', { checked: true }); // Find all unchecked checkboxes page.getByRole('checkbox', { checked: false });
disabled
Filters by the disabled attribute or aria-disabled.
// Find the disabled submit button page.getByRole('button', { name: 'Submit', disabled: true });
expanded
Filters by aria-expanded. Useful for accordions, collapsible menus, and disclosure widgets.
// Find the expanded accordion item page.getByRole('button', { expanded: true }); // Find a collapsed dropdown trigger page.getByRole('button', { name: 'Options', expanded: false });
includeHidden
By default, getByRole skips elements hidden from the accessibility tree. Pass includeHidden: true to override this and find hidden elements.
// Find a hidden modal that has not been opened yet page.getByRole('dialog', { name: 'Settings', includeHidden: true, });
pressed
Filters toggle buttons by their pressed state (aria-pressed).
// Find the "Bold" button when it is active page.getByRole('button', { name: 'Bold', pressed: true });
selected
Filters tabs and listbox options by their selected state (aria-selected).
// Find the currently active tab page.getByRole('tab', { selected: true }); // Assert which tab is selected await expect(page.getByRole('tab', { selected: true })) .toHaveText('General');
getByRole vs Other Locators
Playwright offers several locator strategies. Here is when to use each one:
getByRole — Your default choice. Use for any element that has a meaningful ARIA role (buttons, links, inputs, headings, landmarks, tables, dialogs). Validates accessibility and survives refactors.
getByLabel — Best for form inputs when you want to target the <label> text directly. Internally similar to getByRole('textbox', { name: 'Email' }), but also works for elements where the role is ambiguous.
getByText — Use for non-interactive elements (paragraphs, spans, divs) that have no semantic role. Avoid for buttons and links — prefer getByRole for those.
getByTestId — The escape hatch. Use when an element has no accessible role, no label, and no visible text. Common for containers, wrappers, and generated components. Does not validate accessibility.
CSS / XPath selectors — Last resort. Brittle and implementation-dependent. If you find yourself reaching for page.locator('.btn-primary'), ask whether the element should have a proper ARIA role first.
page.getByRole('button', { name: 'Save' })
page.locator('.btn-primary.save-btn')
page.getByRole('link', { name: 'Pricing' })
page.locator('a[href="/pricing"]')
Tip: For a complete comparison including performance considerations and chaining patterns, see the Playwright Locators Guide.
Real-World Patterns
These patterns show how getByRole is used in complete test scenarios, not just isolated locator calls.
Login form
import { test, expect } from '@playwright/test'; test('successful login redirects to dashboard', async ({ page }) => { await page.goto('https://example.com/login'); // Fill the form using role locators await page.getByRole('textbox', { name: 'Email' }) .fill('user@example.com'); await page.getByRole('textbox', { name: 'Password' }) .fill('SecurePass123!'); // Submit await page.getByRole('button', { name: 'Sign in' }).click(); // Assert redirect await expect(page).toHaveURL(/\/dashboard/); await expect(page.getByRole('heading', { level: 1 })) .toHaveText('Dashboard'); });
Navigation menu
test('main navigation has correct links', async ({ page }) => { await page.goto('https://example.com'); const nav = page.getByRole('navigation', { name: 'Main' }); // Verify link count await expect(nav.getByRole('link')).toHaveCount(5); // Verify link text in order await expect(nav.getByRole('link')) .toHaveText(['Home', 'Products', 'Pricing', 'Docs', 'Contact']); // Click and verify navigation await nav.getByRole('link', { name: 'Pricing' }).click(); await expect(page).toHaveURL(/\/pricing/); });
Data table
test('table filtering works', async ({ page }) => { await page.goto('https://example.com/users'); // Verify column headers await expect(page.getByRole('columnheader')) .toHaveText(['Name', 'Email', 'Role', 'Status']); // Filter the table await page.getByRole('textbox', { name: 'Search users' }) .fill('admin'); // Verify filtered results (header row + data rows) await expect(page.getByRole('row')).toHaveCount(3); // Verify a specific cell in the first data row const firstRow = page.getByRole('row').nth(1); await expect(firstRow.getByRole('cell').first()) .toContainText('admin'); });
Modal dialog
test('delete confirmation dialog works', async ({ page }) => { await page.goto('https://example.com/settings'); // Click delete to open confirmation dialog await page.getByRole('button', { name: 'Delete account' }).click(); // Wait for dialog to appear const dialog = page.getByRole('dialog', { name: 'Confirm deletion' }); await expect(dialog).toBeVisible(); // Verify dialog content await expect(dialog) .toContainText('This action cannot be undone'); // Cancel the deletion await dialog.getByRole('button', { name: 'Cancel' }).click(); // Verify dialog closed await expect(dialog).not.toBeVisible(); });
Accordion / FAQ
test('FAQ accordion expands and collapses', async ({ page }) => { await page.goto('https://example.com/faq'); const question = page.getByRole('button', { name: 'What is your refund policy?', }); // Initially collapsed await expect(question).toHaveAttribute('aria-expanded', 'false'); // Click to expand await question.click(); await expect(question).toHaveAttribute('aria-expanded', 'true'); // Alternatively, use the expanded option await expect(page.getByRole('button', { name: 'What is your refund policy?', expanded: true, })).toBeVisible(); });
Debugging getByRole
When getByRole cannot find your element, use these techniques to diagnose the problem.
highlight() for visual debugging
The highlight() method draws a red border around all matched elements in the browser. This is useful when you are not sure which elements your locator is finding.
// Highlight all buttons on the page await page.getByRole('button').highlight(); // Highlight a specific element await page.getByRole('link', { name: 'Pricing' }).highlight();
Playwright Inspector
Run your test with --debug to open the Playwright Inspector. It shows the accessibility tree for the entire page and lets you test locators interactively:
npx playwright test --debug
In the Inspector's "Locator" tab, type a getByRole expression and it highlights matching elements in real time.
Accessibility tree snapshot
You can dump the entire accessibility tree to the console. This reveals exactly what roles and names are available on the page:
const snapshot = await page.accessibility.snapshot(); console.log(JSON.stringify(snapshot, null, 2));
The output shows every node with its role, name, value, and children. Search the output for the element you are trying to find — if it is missing or has a different role/name than expected, that explains why your locator fails.
Tip: Chrome DevTools also shows the accessibility tree. Open DevTools, go to Elements, and look for the "Accessibility" pane. This is useful for investigating roles without running a Playwright test.
Common Mistakes & Fixes
Mistake 1: Using the wrong role
A common error is using getByRole('input') — but there is no ARIA role called "input." The correct role for text inputs is textbox, for checkboxes it is checkbox, and for dropdowns it is combobox.
page.getByRole('textbox', { name: 'Email' })
page.getByRole('input', { name: 'Email' })
Mistake 2: Missing accessible name
If your <input> has no <label>, no aria-label, and no aria-labelledby, then its accessible name is empty. The locator getByRole('textbox', { name: 'Email' }) will not find it.
<label for="email">Email</label> <input id="email" />
<input placeholder="Email" />
The placeholder attribute does not count as an accessible name in most browsers. Always use a <label> or aria-label.
Mistake 3: Forgetting that <a> without href has no link role
An <a> element only has the implicit link role when it has an href attribute. Without href, it is treated as generic text.
<a href="/pricing">Pricing</a>
<a>Pricing</a>
Mistake 4: Trying to find hidden elements
Elements with display: none, visibility: hidden, or aria-hidden="true" are excluded from the accessibility tree by default. If you need to find them, use includeHidden: true.
// This will NOT find a hidden dialog page.getByRole('dialog'); // fails if dialog is display:none // This will find it page.getByRole('dialog', { includeHidden: true });
Mistake 5: Confusing implicit roles for <div> and <span>
<div> and <span> have no implicit ARIA role. If your component is built entirely with divs, getByRole will not find anything unless you add explicit role attributes. This is actually a feature — it reveals inaccessible markup.
Generate getByRole Locators with Claude AI
Writing getByRole locators for every element on a complex page is time-consuming. In the Playwright + Claude AI & MCP Server course, you will learn how to use Claude AI to automatically analyze your page's accessibility tree and generate the optimal getByRole calls. Claude identifies the correct role, computes the accessible name, and suggests the right filtering options — so you get production-ready locators in seconds instead of minutes.
The course covers how to connect Claude AI to your Playwright project via the MCP Server, enabling a workflow where you describe what you want to test and AI writes the locators for you.
Frequently Asked Questions
What is the difference between getByRole and getByTestId in Playwright?
getByRole queries the accessibility tree using ARIA roles and accessible names, mimicking how assistive technologies see the page. getByTestId queries a custom data attribute (data-testid by default). Playwright recommends getByRole as the primary locator because it validates accessibility and is resilient to markup changes. Use getByTestId as a fallback when an element has no meaningful ARIA role or accessible name.
How does Playwright determine the role of an HTML element?
Playwright uses the same ARIA role resolution as browsers. Most HTML elements have implicit roles defined by the HTML spec — for example, <button> has role button, <a href> has role link, and <input type="checkbox"> has role checkbox. You can also assign explicit roles with the role attribute. Playwright queries the computed accessibility tree, so both implicit and explicit roles work.
What does the name option do in getByRole?
The name option filters elements by their accessible name. The accessible name is computed from the element's text content, aria-label, aria-labelledby, associated <label>, alt text (for images), or title attribute. For example, getByRole('button', { name: 'Submit' }) finds a button whose accessible name is "Submit." You can pass a string for substring match or a regex for pattern matching.
Can getByRole find elements that are hidden with display:none?
By default, getByRole only matches elements visible in the accessibility tree. Elements hidden with display:none, visibility:hidden, or aria-hidden="true" are excluded. To locate a hidden element, pass the includeHidden: true option: page.getByRole('button', { name: 'Submit', includeHidden: true }).
How do I use getByRole with heading levels like h1, h2, h3?
All heading elements (<h1> through <h6>) share the ARIA role heading. To target a specific level, use the level option: page.getByRole('heading', { level: 1 }) matches only <h1> elements. You can combine level with name to be even more specific: page.getByRole('heading', { name: 'Dashboard', level: 2 }).
Asim Noaman
Senior QA Automation Engineer & AI Testing Specialist
With years of hands-on experience building test automation frameworks for production applications, Asim specializes in combining traditional QA methodologies with cutting-edge AI tools. He has helped teams adopt Playwright and AI-driven testing workflows to ship faster with fewer bugs.