Q13 of 42 · Playwright
Compare locator strategies: getByRole vs getByTestId vs CSS selectors.
Short answer
Short answer: `getByRole` matches by accessible name — most stable, double-duties as a11y testing. `getByTestId` is the explicit testing-attribute fallback when semantic markup isn't enough. CSS selectors couple tests to implementation and should be a last resort. Use them in that order of preference.
Detail
Playwright's recommended hierarchy is opinionated and worth understanding deeply.
Tier 1 — Semantic / accessible locators:
getByRole(role, { name }), getByLabel(label), getByPlaceholder(text), getByText(text), getByTitle(title), getByAltText(alt). These match elements the way a user or screen reader would identify them.
Why prefer them: they survive class renames, CSS overhauls, and refactors that change DOM structure but preserve user-facing semantics. They also make a11y problems visible — if you can't getByRole('button', { name: 'Submit' }), neither can a screen-reader user.
Tier 2 — Test ID:
getByTestId('submit-order') matches data-testid="submit-order" (configurable). Use when:
- The element has no good accessible name (icon-only buttons without
aria-label). - Generic markup like
divs with no semantic role. - Disambiguating multiple elements with the same role/text.
Tier 3 — CSS selectors:
page.locator('[data-test=submit]'), page.locator('.btn-primary > span'). Anything based on DOM structure, classes, or attributes. Brittle; couples tests to implementation. Use sparingly.
Tier 4 — XPath: page.locator('xpath=//button[text()="Submit"]'). Almost always avoidable with the higher tiers. The exception is querying based on text + ancestor relationships not expressible in CSS.
Other useful locator features:
.filter({ hasText: ... })— narrow a set:page.getByRole('listitem').filter({ hasText: 'Apples' })..nth(N)— index into a set, when ordering is meaningful..getByRole(...).and(...)/.or(...)— combine locators.
The senior framing: the locator is a contract. getByRole expresses the user's contract; getByTestId expresses an explicit testing contract; page.locator('.btn-primary') expresses no contract at all.
// EXAMPLE
// ✅ Tier 1 — semantic
await page.getByRole('button', { name: 'Submit order' }).click();
await page.getByLabel('Email').fill('alice@x.com');
await page.getByText('Order confirmed').isVisible();
// ✅ Tier 2 — explicit test ID
await page.getByTestId('payment-iframe').click();
// ✅ Disambiguating with filter
await page.getByRole('listitem').filter({ hasText: 'Apples' }).click();
// ⚠️ Tier 3 — CSS, last resort
await page.locator('[data-test=summary] > .total').textContent();
// ❌ Tier 4 — XPath, almost never the answer
await page.locator('xpath=//div[@class="container"]/span[1]').click();