C.W.K.
Stream
Lesson 01 of 04 · published

Role-Based Selectors — getByRole and Friends

~17 min · playwright-locators, role, selectors

Level 0Test Curious
0 XP0/32 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete
"Same accessibility-first philosophy as RTL, applied to a real browser."

The Same Priority List (Different API Surface)

Playwright's recommended locator priority mirrors Testing Library's exactly — for the same reason. Real users find elements via accessibility metadata; tests that do the same survive UI refactors that aren't user-visible.

  1. page.getByRole(role, { name }) — the gold standard.
  2. page.getByLabel(text) — for form fields with labels.
  3. page.getByPlaceholder(text) — when no label.
  4. page.getByText(text) — for non-interactive content.
  5. page.getByAltText(text) — for images.
  6. page.getByTitle(text) — for elements with title attribute.
  7. page.getByTestId(id) — escape hatch.

Below this tier sits page.locator('css selector') and page.locator('xpath=...') — both work, both are fallbacks. The recommendation is: don't reach for CSS until role + name + filter combinations have failed you.

The Locator Object vs Immediate Element

Playwright's getByRole returns a Locator — NOT the element itself. The locator is a lazy reference: it doesn't resolve until you use it (with .click(), .fill(), or an expect). Each resolution re-queries the DOM, which is why locators play nicely with auto-wait and retries.

The consequence: storing a locator in a variable is cheap and safe. const submit = page.getByRole('button', { name: 'Submit' }) can be referenced many times in the test — each await submit.click() or await expect(submit).toBeEnabled() re-queries fresh.

Chaining and Filtering

Locators chain. page.getByRole('list').getByRole('listitem').first() says "find a list, then the items inside it, then the first one." This is the model for narrowing into complex DOM structures without writing brittle CSS paths.

Useful filter methods:

  • .first(), .last(), .nth(n) — positional narrowing.
  • .filter({ hasText }), .filter({ has: locator }) — content-based.
  • .and(otherLocator), .or(otherLocator) — set operations.
If the role-based query doesn't match, the component is probably missing accessibility metadata. Same diagnostic as RTL: fix the component (add a label, use a proper element) rather than reach for a CSS escape hatch. The bar is even higher in Playwright — you're testing in a real browser, so the accessibility tree IS what assistive tech sees.

When CSS / XPath Is Honestly the Right Answer

Some elements genuinely have no accessible name: a styled icon-only button without aria-label, a custom SVG chart, a third-party widget you don't control. In those cases, page.locator('css selector') is honest — but consider: can the component be fixed instead? Most icon buttons should have an aria-label; most charts should have an aria-label describing the data; most third-party widgets either have stable test ids or are wrappable in one.

Code

Role-based locators — the default approach·typescript
// e2e/login.spec.ts — role-based locators throughout
import { test, expect } from '@playwright/test';

test('sign-in flow uses role-based locators', async ({ page }) => {
  await page.goto('/');

  // Role-based: same locator API surface as RTL
  await page.getByRole('link', { name: 'Sign in' }).click();

  // Form fields by label
  await page.getByLabel('Email').fill('pippa@example.com');
  await page.getByLabel('Password').fill('s3cret');

  // Button by role + name
  await page.getByRole('button', { name: 'Submit' }).click();

  // Heading by role + level + name — three filters on one query
  await expect(
    page.getByRole('heading', { name: /welcome back, pippa/i, level: 1 })
  ).toBeVisible();
});
Chaining + filter — scoped locators replace CSS paths·typescript
// Chaining + filtering — narrowing without CSS
import { test, expect } from '@playwright/test';

test('cart shows the right item', async ({ page }) => {
  await page.goto('/cart');

  // Find the cart list, then the listitem containing 'Coffee Beans'
  const item = page
    .getByRole('list', { name: /cart items/i })
    .getByRole('listitem')
    .filter({ hasText: 'Coffee Beans' });

  await expect(item).toBeVisible();

  // Inside that item, find the quantity input by label and the price by role
  await expect(item.getByLabel('Quantity')).toHaveValue('2');
  await expect(item.getByRole('text', { name: /\$24.00/ })).toBeVisible();
});
CSS / test-id escape — when role isn't available·typescript
// When you genuinely need CSS — own the escape
import { test, expect } from '@playwright/test';

test('chart container is rendered', async ({ page }) => {
  await page.goto('/dashboard');

  // Anti-pattern (brittle):
  // page.locator('div.chart-wrapper.js-revenue-chart')

  // Honest escape — the SVG has no accessible name we control
  await expect(
    page.locator('[data-testid="revenue-chart"]')
  ).toBeVisible();

  // Even better — add an aria-label to the chart and use getByRole:
  // <svg role="img" aria-label="Revenue chart"> → page.getByRole('img', { name: /revenue chart/i })
});
The role vocabulary you'll use 95% of the time·typescript
// Common role + name patterns — memorize the dozen, ignore the rest
await page.getByRole('button',   { name: 'Sign in' }).click();
await page.getByRole('link',     { name: 'Pricing' }).click();
await page.getByRole('heading',  { name: /dashboard/i }).isVisible();
await page.getByRole('textbox',  { name: 'Email' }).fill('a@b.io');
await page.getByRole('checkbox', { name: 'Remember me' }).check();
await page.getByRole('radio',    { name: 'Standard plan' }).check();
await page.getByRole('combobox', { name: 'Country' }).selectOption('KR');
await page.getByRole('tab',      { name: 'Billing' }).click();
await page.getByRole('menuitem', { name: 'Sign out' }).click();
await page.getByRole('dialog',   { name: 'Confirm deletion' }).waitFor();
await page.getByRole('alert')
          .filter({ hasText: /required/i })
          .first()
          .waitFor();

External links

Exercise

Pick a page in your app with a list of items (a search results page, a dashboard, a cart). Write a Playwright test that: (1) navigates to the page, (2) finds the list via getByRole, (3) asserts it has N items via expect(items).toHaveCount(N) where items = listLocator.getByRole('listitem'), (4) clicks the first item and asserts navigation to its detail page. Avoid page.locator('css') entirely.
Hint
If you can't find a role for some part of the list, the markup is probably non-semantic. Use a real <ul> instead of <div>s, add aria-label to the list, and the test gets cleaner along with the markup.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.