Locator Strategies
Choosing the right locator strategy is one of the highest-leverage decisions in test automation. Fragile locators are the leading cause of test maintenance burden. Playwright provides user-centric locators that mirror how real users find elements — and they are dramatically more stable than the CSS/XPath selectors used in Selenium.
The Locator Hierarchy
Playwright recommends locators in this priority order:
| Priority | Locator | Example | Why |
|---|---|---|---|
| 1 | Role | getByRole('button', { name: 'Submit' }) |
Mirrors accessibility tree; resilient to markup changes |
| 2 | Text | getByText('Sign in') |
User-visible; breaks only when copy changes |
| 3 | Test ID | getByTestId('login-form') |
Explicit contract between dev and test |
| 4 | Label | getByLabel('Email address') |
Form fields via associated label |
| 5 | Placeholder | getByPlaceholder('Enter email') |
Inputs without visible labels |
| 6 | CSS/XPath | page.locator('.btn-primary') |
Last resort for complex DOM traversal |
Role-Based Locators (Preferred)
Role locators use the accessibility tree, not the DOM structure. They find elements the way a screen reader would — which means they are resilient to HTML refactoring.
// Finds <button>, <input type="submit">, or any element with role="button"
await page.getByRole('button', { name: 'Submit' }).click();
// Finds <a> elements or elements with role="link"
await page.getByRole('link', { name: 'Sign up' }).click();
// Finds <h1>–<h6> or elements with role="heading"
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
// Checkboxes and radio buttons
await page.getByRole('checkbox', { name: 'Remember me' }).check();
Text Locators
// Exact text
await page.getByText('Welcome back', { exact: true }).click();
// Substring match (default)
await page.getByText('Welcome').click();
// Regex
await page.getByText(/welcome/i).click();
Test ID Locators
When role and text locators are not viable (e.g., elements without meaningful text or multiple identical elements), test IDs provide a stable contract.
// In the application: <div data-testid="user-profile">...</div>
await page.getByTestId('user-profile').click();
// Configure the attribute name in playwright.config.ts:
// use: { testIdAttribute: 'data-qa' }
Label and Placeholder Locators
// Finds input by its associated <label>
await page.getByLabel('Email address').fill('user@example.com');
// Finds input by placeholder text
await page.getByPlaceholder('Search products').fill('laptop');
CSS and XPath: When You Need Them
Playwright supports CSS and XPath for cases where semantic locators are insufficient:
// CSS selector
await page.locator('.product-card >> .price').textContent();
// XPath (prefix with //)
await page.locator('//div[@class="sidebar"]//a').click();
When CSS/XPath Is Acceptable
- Third-party widgets without accessible roles or test IDs
- Complex DOM traversal (parent → child → sibling relationships)
- Legacy applications where adding test IDs is not possible
When to Avoid CSS/XPath
- Positional selectors like
div:nth-child(3)— break when markup order changes - Deep nesting like
#app > div > main > section > form > button— break on any refactor - Class-based selectors that target styling (
.btn-primary) rather than purpose
Locator Strategy Comparison
| Strategy | Stability | Readability | Maintenance | Best For |
|---|---|---|---|---|
getByRole() |
High | High | Low | Buttons, links, headings, form controls |
getByText() |
Medium | High | Low | Static content, labels |
getByTestId() |
High | Medium | Low | Dynamic content, complex components |
getByLabel() |
High | High | Low | Form inputs |
| CSS selector | Low–Medium | Low | High | Third-party widgets, legacy apps |
| XPath | Low | Low | High | Complex traversal, text matching |
Practical Example: Login Form
// BEST: role-based locators
await page.getByLabel('Email').fill('user@example.com');
await page.getByLabel('Password').fill('secret');
await page.getByRole('button', { name: 'Sign in' }).click();
// GOOD: test ID locators
await page.getByTestId('email-input').fill('user@example.com');
await page.getByTestId('password-input').fill('secret');
await page.getByTestId('login-button').click();
// AVOID: CSS selectors tied to implementation
await page.locator('input[type="email"]').fill('user@example.com');
await page.locator('input[type="password"]').fill('secret');
await page.locator('form.login-form button.btn-primary').click();
Key Takeaways
- Prefer role-based locators (
getByRole) — they mirror user behavior and survive refactoring - Use
getByTestIdwhen semantic locators are not available — it creates a stable test contract - Reserve CSS/XPath for third-party widgets and legacy applications
- Avoid positional selectors, deep nesting, and styling-based class selectors
- A good locator strategy reduces test maintenance by 60–80% compared to CSS/XPath-heavy tests