Locators That Don't Break
Master Playwright's locator strategy. Learn why role-based selectors are superior to CSS, when to use test IDs, how to filter and chain locators, and avoid the brittle patterns that break on every UI.
This post is part of the Playwright Essentials series. Part 1 covered setup and your first test. This post focuses on writing locators that survive UI refactors.
The Problem with Brittle Selectors
Here’s the most expensive line of code in a typical E2E test suite:
await page.click('div.MuiBox-root > div:nth-child(2) > button.btn-primary');
This selector will break when:
- A developer adds a wrapper div for styling
- The CSS framework is upgraded and class names change
- A designer reorders elements in the layout
- The button moves from the second child to the third
The selector is coupled to implementation details, not user-facing behaviour. Users don’t see div.MuiBox-root. They see a “Submit” button. Your tests should mirror that.
Brittle selectors are the #1 cause of high-maintenance test suites. Teams spend more time updating tests after refactors than writing new tests. Eventually, the suite is abandoned because maintenance cost exceeds value.
The solution: use selectors that describe what the user sees, not how the DOM is structured.
Playwright’s Locator Priority — The Right Order
Playwright’s documentation recommends locators in priority order from most resilient to least resilient:
1. Role-based locators (best)
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByRole('textbox', { name: 'Email' }).fill('user@example.com');
await page.getByRole('link', { name: 'View details' }).click();
Why this works: Roles are part of the accessibility tree. They describe how assistive technology (screen readers) sees the page. If your test can’t find an element by role, neither can a blind user with a screen reader. This selector strategy enforces accessible markup.
Common roles:
button—<button>or<input type="button">textbox—<input type="text">or<textarea>link—<a href="...">checkbox—<input type="checkbox">heading—<h1>,<h2>, etc.
See the full list: ARIA roles reference
2. Label-based locators (for form inputs)
await page.getByLabel('Password').fill('s3cr3t');
await page.getByLabel('Accept terms and conditions').check();
Finds inputs associated with a <label>. This is semantically correct HTML and mirrors how users navigate forms.
3. Placeholder-based locators
await page.getByPlaceholder('Search articles...').fill('playwright');
Useful when there’s no label but a placeholder provides context. Less resilient than labels (placeholders often change), but better than CSS selectors.
4. Text-based locators
await page.getByText('Welcome back').click();
await page.getByText(/logged in as/i).isVisible();
Finds elements containing specific text. Supports exact match, substring, or regex. Good for links, paragraphs, and static content.
:::warning[Text Locators and Internationalisation] If your application is localised (multi-language), text-based locators break when the language changes. Use test IDs or role + accessible name for localised apps. :::
5. Test ID (explicit stability)
await page.getByTestId('checkout-button').click();
Add data-testid attributes to elements where other strategies don’t apply:
<button data-testid="checkout-button" class="btn-dynamic-class">
Proceed to Checkout
</button>
Test IDs are:
- Invisible to users — they don’t affect rendering or behaviour
- Stable across refactors — a developer changing CSS won’t touch them
- Explicit — they signal “this element is tested”
When to use test IDs:
- Dynamic content where text or role changes frequently
- Third-party components you don’t control (React libraries, embedded widgets)
- Elements with no semantic role or accessible name
6. CSS or XPath (last resort)
await page.locator('[data-testid="submit"]').click();
await page.locator('css=.btn-primary').click();
await page.locator('xpath=//button[@type="submit"]').click();
Only use CSS/XPath when:
- You’re testing legacy code that can’t be changed
- You’re scraping an external site (not your own application)
- All other strategies have failed
If you find yourself writing CSS selectors regularly, treat it as a code smell and refactor the HTML to be more semantic.
Filtering and Chaining Locators
Playwright locators are composable. You can narrow down matches by chaining filters.
Filter by visible text
// Multiple buttons — find the one with specific text
await page.getByRole('button').filter({ hasText: 'Delete' }).click();
Filter by nested element
// Click the delete button inside the "Order #1234" row
await page.getByRole('row')
.filter({ hasText: 'Order #1234' })
.getByRole('button', { name: 'Delete' })
.click();
This is a game-changer for table interactions. Instead of brittle nth-child selectors, you locate the row by meaningful content and then find the button within that row.
Filter by state
// Find the enabled submit button (not the disabled one)
await page.getByRole('button', { name: 'Submit' }).filter({ hasNot: page.locator('[disabled]') });
Chaining for specificity
// Find the "Email" input inside the "Login" form
await page.locator('form').filter({ hasText: 'Login' })
.getByLabel('Email')
.fill('user@example.com');
Chaining makes selectors self-documenting. The intent is clear: “in the login form, fill the email field.”
Anti-Patterns — Locators to Avoid
❌ nth-child and positional selectors
// BAD — breaks if element order changes
await page.locator('div > button:nth-child(2)').click();
// GOOD — describe what you're clicking
await page.getByRole('button', { name: 'Submit' }).click();
❌ Generated class names
// BAD — class names change on every build
await page.locator('.MuiButton-root-123').click();
// GOOD — use role or test ID
await page.getByRole('button', { name: 'Submit' }).click();
❌ Hardcoded IDs from dynamic lists
// BAD — ID is database-generated and changes per environment
await page.locator('#order-98234').click();
// GOOD — find by meaningful content
await page.getByRole('row').filter({ hasText: 'Order #1234' }).click();
❌ Overly specific CSS paths
// BAD — one extra div breaks this
await page.locator('div#app > main > section > div > form > button').click();
// GOOD — as specific as needed, no more
await page.locator('form[aria-label="Login"]').getByRole('button', { name: 'Submit' }).click();
Real-World Example — E-Commerce Checkout
Let’s refactor a brittle checkout test into a resilient one.
Before (brittle)
test('add item to cart', async ({ page }) => {
await page.goto('/products');
await page.click('div.product-grid > div:nth-child(1) > button.add-to-cart');
await page.click('a.cart-icon');
await expect(page.locator('div.cart-item').count()).toBe(1);
});
Problems:
div:nth-child(1)— breaks if product order changesbutton.add-to-cart— breaks if class name changesa.cart-icon— breaks if the cart link is refactoreddiv.cart-item— brittle class selector
After (resilient)
test('add item to cart', async ({ page }) => {
await page.goto('/products');
// Find product by name, then find its "Add to Cart" button
await page.getByRole('article')
.filter({ hasText: 'Wireless Headphones' })
.getByRole('button', { name: 'Add to Cart' })
.click();
// Navigate to cart via accessible link
await page.getByRole('link', { name: /cart/i }).click();
// Assert on cart contents
await expect(page.getByRole('list', { name: 'Cart items' }).getByRole('listitem')).toHaveCount(1);
});
Why this is better:
- Product identified by name — survives reordering, CSS changes, framework upgrades
- Button found by accessible label — if the button text changes, the test breaks intentionally (because user-facing behaviour changed)
- Cart link found by text — regex allows “Cart (2)” or “Shopping Cart”
- Cart items counted semantically — uses proper list/listitem roles
Debugging Locators — The Playwright Inspector
When a locator doesn’t match, use Playwright Inspector to debug:
npx playwright test --debug
Or add await page.pause() in your test:
test('debug locator', async ({ page }) => {
await page.goto('/checkout');
await page.pause(); // Opens inspector
// Write locators in the console, see matches in real-time
await page.getByRole('button', { name: 'Pay now' }).click();
});
The inspector shows:
- Which elements match your locator
- Why a locator doesn’t match (wrong role, text mismatch, element not in DOM)
- Suggestions for alternative locators
:::tip[Use Codegen for Locator Ideas]
npx playwright codegen https://example.com opens a browser and records your interactions. It auto-generates locators using Playwright’s recommended priority. Use this as a starting point, then refine manually.
:::
Testing Dynamic Content
What if the element appears conditionally or after a delay?
Wait for element to appear
// Automatically retries until element is visible (up to 5s timeout)
await expect(page.getByText('Order confirmed')).toBeVisible();
Wait for element to disappear
// Wait for loading spinner to disappear
await expect(page.getByTestId('loading-spinner')).not.toBeVisible();
Wait for element state
// Wait for button to become enabled
await page.getByRole('button', { name: 'Submit' }).waitFor({ state: 'enabled' });
Wait for specific count
// Wait for exactly 3 items in cart
await expect(page.getByRole('listitem')).toHaveCount(3);
Playwright’s auto-waiting handles most timing issues. If you find yourself writing explicit waits, you’re probably fighting the framework. Refactor the locator or use a web-first assertion instead.
Locator Strategy Checklist
Use this when reviewing tests:
☐ Uses getByRole where possible
☐ Uses getByLabel for form inputs
☐ Uses getByText for static content
☐ Falls back to getByTestId only when semantic locators don't fit
☐ No nth-child or positional selectors
☐ No generated class names (MuiButton-root-*, css-xyz123)
☐ No hardcoded waits (waitForTimeout)
☐ Locators describe user-facing behaviour, not DOM structure
☐ Test IDs added to HTML are documented (why they're needed)
Conclusion
Locator strategy is not a minor detail — it’s the difference between a test suite that runs for years with minimal maintenance and one that requires a full-time engineer just to keep it green.
The rule: If a UI refactor that doesn’t change user-facing behaviour breaks your tests, your locators are wrong.
Invest time in learning role-based selectors and filtering. It pays off every sprint when refactors don’t break your tests.
Action for this week: Pick one existing test with CSS or XPath selectors. Refactor it to use role-based locators and filters. Run it before and after to verify behaviour is unchanged. Then note how much more readable the refactored version is.
Previous: Part 1 — Playwright from Scratch
Next: Part 3 — Page Objects vs Fixtures