The fix is choosing locators anchored to what a user actually perceives and interacts with (a role, a label, a stable identifier deliberately placed in the markup for this purpose), instead of locators anchored to the DOM's incidental structure. Concretely, that means a locator priority order, roughly in this sequence:
- Accessible role and name (`getByRole('button', { name: 'Continue' })`), because this reflects what a screen reader and a real user both perceive, and a11y roles are far less likely to change on a purely cosmetic refactor.
- A dedicated test identifier (`data-testid`, or an equivalent attribute), deliberately added to markup specifically so tests have something stable to hold onto, independent of styling or layout.
- Visible text, when the role alone isn't unique enough, understanding that copy changes are still a risk, just a smaller and more visible one than a structural change.
- CSS structural selectors and `nth-child` chains, only as a last resort, and treated as technical debt the moment they're written, not a first choice.
The before/after example below is the concrete version of the content gap this article exists to close: it isn't enough to say "tests are brittle," the connection between locator choice and survivability needs to be shown directly.
Before: a brittle, CSS-path-based test
// checkout.spec.ts (brittle version)
import { test, expect } from '@playwright/test';
test('user can submit checkout form', async ({ page }) => {
await page.goto('/checkout');
// Selector copied straight from DevTools "Copy selector"
await page.fill('#root > div > div.MuiBox-root.css-1a2b3c > form > div:nth-child(2) > input', 'jane@example.com');
await page.click('#root > div > div.MuiBox-root.css-1a2b3c > form > div.btn-wrapper > button.btn-primary');
await expect(page.locator('div.confirmation-banner')).toBeVisible();
});
Now suppose the design team wraps the checkout form in a new responsive layout container for a mobile redesign, and the CSS-in-JS build regenerates `css-1a2b3c` to a new hash on the next deploy, as CSS-in-JS libraries routinely do. None of the actual behavior changed. The email field still exists, the submit button still says the same thing, the confirmation banner still appears. But every selector in this test references a DOM path and a class name that no longer exist, so the test fails on `page.fill(...)` before it even reaches the assertion that matters.
After: a resilient, role-and-testid-based test
// checkout.spec.ts (resilient version)
import { test, expect } from '@playwright/test';
test('user can submit checkout form', async ({ page }) => {
await page.goto('/checkout');
await page.getByRole('textbox', { name: 'Email' }).fill('jane@example.com');
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByTestId('checkout-confirmation')).toBeVisible();
});
This version survives the same redesign scenario. The wrapping container, the regenerated CSS-in-JS class name, and the reordered `div`s are all invisible to this test, because none of the locators reference DOM structure or class names at all. `getByRole` matches on the accessible role and visible label, which a purely cosmetic markup change does not touch. `getByTestId` matches a `data-testid="checkout-confirmation"` attribute that a developer deliberately added to that element specifically so it stays addressable regardless of styling changes: `<div data-testid="checkout-confirmation" className={styles.banner}>`.
The difference isn't "this test is written more carefully." It's that the before version encodes an assumption ("the DOM will look exactly like this forever") that nothing in a normal front-end workflow promises to keep true, and the after version encodes an assumption ("this button will still be labeled Continue and reachable as a button") that a routine restyle has no reason to break.