Q1 of 42 · Playwright
What are user-facing locators and why does Playwright recommend them?
Short answer
Short answer: User-facing locators (getByRole, getByLabel, getByText) target elements the way a user or assistive tech would. They're more resilient to markup churn and reward accessible HTML, so tests stay green when CSS class names change.
Detail
Playwright's recommended locator hierarchy is opinionated. At the top sit semantic locators — page.getByRole('button', { name: 'Submit' }), page.getByLabel('Email'), page.getByPlaceholder(...), page.getByText(...). These match the accessible name, label, or visible content rather than implementation details.
The case for them is twofold. First, they're stable under refactor: a designer renaming a CSS class from .btn-primary to .btn-action doesn't break a getByRole('button', { name: 'Submit' }) query. Second, they create a forcing function for accessibility: if you can't locate a button by its accessible name, neither can a screen reader user. Tests that fail because there's no aria-label are tests that caught a real a11y bug.
When semantic locators don't work — because the markup is genuinely opaque, third-party widgets, or canvas content — fall back to getByTestId with a stable data-testid attribute. CSS and XPath selectors should be a last resort because they couple tests to internal structure that has no contract to remain stable.
In an interview, the right framing is: the locator is a contract. Semantic locators express the user's contract; data-testid expresses an explicit testing contract; raw CSS expresses no contract at all and should be avoided.
// EXAMPLE
checkout.spec.ts
import { test, expect } from '@playwright/test';
test('user can submit checkout', async ({ page }) => {
await page.goto('/checkout');
// ✅ Preferred: semantic, accessibility-first
await page.getByLabel('Card number').fill('4242 4242 4242 4242');
await page.getByLabel('Expiry').fill('12/30');
await page.getByRole('button', { name: 'Pay now' }).click();
await expect(
page.getByRole('heading', { name: 'Order confirmed' }),
).toBeVisible();
// ⚠️ Fallback for opaque widgets — still better than CSS classes
// await page.getByTestId('payment-iframe').click();
});