Why locators matter in Playwright automation
Locator strategy determines everything: how fast tests run, how often they break, and whether they actually test what users experience. Playwright locators explained: best practices explained isn't just about syntax—it's about building a test suite that survives product changes and scales across your team.
We've all inherited test suites held together by brittle CSS chains that snap the moment design tweaks a margin. The cost is real: broken tests block CI/CD, engineers waste time fixing selectors instead of writing features, and confidence in automation plummets. The solution is systematic: prioritize locators that reflect how users interact with your application, fall back to stable identifiers only when user-facing selectors aren't available, and avoid deep DOM traversal entirely.
Role locators first — getByRole matches accessible roles and names
Start with role-based locators. They query the accessibility tree, matching elements by their semantic role and name—exactly how assistive technologies and actual users discover components. When a button has the accessible name "Sign in," use it.
await page.getByRole('button', { name: 'Sign in' }).click();This approach is powerful because it's user-centric. Users don't care about data-automation-id or CSS class chains. They see a button labeled "Sign in" and click it. Your test should do the same.
Role locators work across:
- Buttons:
getByRole('button', { name: 'Submit' }) - Links:
getByRole('link', { name: 'Home' }) - Headings:
getByRole('heading', { name: 'Checkout', level: 2 }) - Dialogs:
getByRole('dialog', { name: 'Confirm action' }) - Navigation:
getByRole('navigation', { name: 'Main menu' })
A real example: in a ride-hailing app, when a driver accepts a ride, the modal confirms the action. Instead of hunting through nested divs, you query by role:
// Good: matches the semantic meaning
await page.getByRole('dialog', { name: 'Confirm Pickup' })
.getByRole('button', { name: 'Accept Ride' })
.click();
// Avoid: brittle and disconnected from user experience
await page.locator('[class*="modal"] [data-qa="accept-btn"]').click();The role locator remains valid even if CSS classes shuffle or the modal structure refactors slightly. As long as the button retains its accessible name and role, the test holds.
Label and text — getByLabel for inputs, getByText for content
For form fields, getByLabel associates inputs with their labels the way browsers do. This catches real usability issues: if a label is missing or incorrectly associated, your test fails—alerting you to an accessibility bug before production.
// Fills the email field using its associated label
await page.getByLabel('Email address').fill('user@example.com');
// Selects a radio button by label
await page.getByLabel('Preferred delivery method').getByRole('radio', { name: 'Express' }).check();getByText targets visible text content, ideal for navigation, alerts, and table rows where semantic roles don't apply. In a travel booking app, selecting a flight by its departure time:
// Finds the row displaying the exact departure time
await page.getByText('2:45 PM — Chicago to Denver').click();
// Handles partial matches for flexible content
await page.getByText('Book Now', { exact: false }).click();Combine getByText with role refinement to narrow results when multiple elements share the same text:
// Gets the button containing this text (not just any element)
await page.getByRole('button', { name: 'Continue' }).click();
// Gets the list item containing this text
await page.getByRole('listitem').filter({ hasText: 'Premium Plan' }).click();In an e-commerce checkout, you might confirm the discount was applied:
const discountText = await page.getByText('10% discount applied').isVisible();
expect(discountText).toBeTruthy();Test IDs — getByTestId when nothing user-facing is stable
Test IDs are an escape hatch, not the default. Use them when no user-facing locator exists: dynamic lists without stable labels, chart elements, or server-generated IDs. Add them intentionally, not everywhere.
// Targeting a specific chart data point
await page.getByTestId('revenue-chart-2024').hover();
// Selecting a dynamically generated list item by its stable test ID
await page.getByTestId('order-item-12345').click();The contract is clear: when you add data-testid="order-item-12345" to your component, you're saying "this test ID is part of the public API." Change it, and tests break. Use them sparingly.
A CRM system querying account details by stable ID:
// The account name may change, but its data-testid is stable
await page.getByTestId(`account-row-${accountId}`)
.getByRole('button', { name: 'Edit' })
.click();
// Verify the account details loaded
await expect(page.getByTestId(`account-details-${accountId}`))
.toBeVisible();Avoid CSS chains — Deep selectors break on every layout change
CSS chains like div.container > div.row > span.price are maintenance disasters. Every layout refactor, every utility class reshuffle, every nested div you add or remove breaks the selector. Tests fail silently until you run them, wasting time on false negatives.
// Avoid this: breaks on any structural change
await page.locator('div.checkout-container div.item-list > div:nth-child(2) span.product-price').click();
// Better: target by user-visible content
await page.getByRole('cell', { name: /\$\d+\.\d+/ }).click();
// Or use a test ID for truly dynamic content
await page.getByTestId('item-price-2').click();Deep CSS selectors create a false sense of specificity. They work today and fail unpredictably when a design system update adds wrapper divs or when responsive behavior changes the DOM structure on mobile.
In a SaaS dashboard, fetching metrics from a table:
// Fragile: restructuring breaks it
await page.locator('div.dashboard-body table tbody tr:nth-child(3) td:nth-child(5)').textContent();
// Robust: reads the table semantically
await page.getByRole('table', { name: 'Monthly Metrics' })
.getByRole('row')
.filter({ hasText: 'November' })
.getByRole('cell', { name: /\d+%/ })
.textContent();Common mistakes
Overusing test IDs: Teams add data-testid to every button "just in case." This pollutes the codebase with test-only attributes and makes tests less representative of real user behavior. Add test IDs only where role, label, or text locators won't work.
Ignoring accessibility: Accessible markup makes for better locators. If your component isn't accessible, your locators become harder to write. Use role locators as a forcing function: if you can't query by role, the component probably has an accessibility issue.
Mixing strategies in the same test: A test using role locators on one line and CSS chains on the next is confusing. Pick a strategy and stick with it. If a locator strategy fails partway through, refactor the entire test to use user-facing selectors.
Waiting for CSS classes instead of state: Tests often wait for animation classes to be removed. This couples tests to implementation details. Instead, wait for actual visibility or readiness:
// Avoid: tied to CSS animation class
await page.waitForSelector('.modal.hidden', { state: 'hidden' });
// Better: wait for semantic visibility
await expect(page.getByRole('dialog')).toBeVisible();Key takeaways
- Role-based locators come first. They match how users interact with pages and improve test resilience.
- Labels and visible text are your second line of defense. They're user-centric and break only when actual product changes occur.
- Test IDs are intentional, not ubiquitous. Use them sparingly for dynamic content where stable user-facing alternatives don't exist.
- Avoid CSS chains entirely. They're brittle, maintenance-heavy, and disconnect tests from real user behavior.
- Accessible markup enables better tests. If a component isn't accessible, your locators will be fragile. Use locators as a signal to improve product quality.
- Consistency matters more than perfection. A team using the same locator strategy across all tests can reason about and maintain them. Mixed strategies create confusion and technical debt.






Comments
Loading comments…