"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.
page.getByRole(role, { name }) — the gold standard.
page.getByLabel(text) — for form fields with labels.
page.getByPlaceholder(text) — when no label.
page.getByText(text) — for non-interactive content.
page.getByAltText(text) — for images.
page.getByTitle(text) — for elements with title attribute.
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.
.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 + 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
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.