← All posts
Best practices

Locators that survive a redesign

A visual refresh ships, no behavior changes, and half your test suite goes red — the buttons still say the same thing and still work, but the tests can no longer find them. That gap between "the app works" and "the tests pass" almost always comes down to how you locate elements: a locator bound to markup structure breaks when the markup moves, while a locator bound to what the user sees only breaks when the visible behavior actually changes. What follows is a hierarchy from the locators that outlast a redesign to the ones that break on contact — the examples are Playwright, but the ranking holds for any framework.

Why CSS and XPath break first

.btn-primary.mt-4 > span:nth-child(2) reads a specific tree: a class that a designer might rename, a utility margin that a spacing change deletes, a child index that shifts the moment someone wraps the label in another element. None of that is behavior. It's the shape of today's DOM, and the shape is exactly what a redesign rearranges.

// Fragile — bound to structure and styling
await page.locator('div.card > div.card-body > button.btn-primary').click();
await page.locator('//*[@id="app"]/main/section[2]/form/button').click();

The XPath is worse: section[2] means "the second section," so inserting a promo banner above the form silently retargets the click. Both selectors pass today and lie tomorrow. They encode how the page is built, not what it does.

The resilient rungs, best first

Rank a locator by how closely it tracks what a user perceives. The closer it is, the less a cosmetic change can touch it.

1. Role plus accessible name. The strongest default. It finds an element the way a screen reader and a human both do — by what kind of control it is and what it's called.

await page.getByRole('button', { name: 'Add to cart' }).click();
await page.getByRole('link', { name: 'View order history' }).click();
await expect(page.getByRole('heading', { name: 'Checkout', level: 1 })).toBeVisible();

Move the button, restyle it, wrap it in three new divs — as long as it's still a button labeled "Add to cart," this holds. Change the label to "Buy now" and the test fails, which is correct: that's a user-visible change worth a human deciding on. This locator also doubles as an accessibility check. If getByRole can't find your button, assistive tech can't either.

2. Form fields by their label. For inputs, target the visible label a user reads before typing.

await page.getByLabel('Email address').fill('casey@example.com');
await page.getByLabel('Remember me').check();
await page.getByPlaceholder('Search products').fill('headphones');

getByLabel follows the same <label for> / aria-label wiring the browser uses, so it survives the field being moved or restyled. Reach for getByPlaceholder only when there's no real label — placeholder text is a weaker contract because it's often treated as decorative and rewritten freely.

3. Text content. For elements that are their text — a heading, a status message, a cell — match the words directly.

await expect(page.getByText('Payment received')).toBeVisible();
await page.getByRole('listitem').filter({ hasText: 'Pro plan' }).getByRole('button', { name: 'Cancel' }).click();

That second line is the workhorse pattern: anchor on stable text, then narrow to the control inside it. It reads like an instruction you'd give a person — "in the row that says Pro plan, click Cancel" — and it doesn't care where that row sits in the list.

When data-testid is the right call

Sometimes there is no good visible anchor. A drag handle with no label, an icon-only control that even a person identifies only by position, a chart canvas, a container whose only text is dynamic data you can't hardcode. Forcing a text or role locator onto these produces something fragile in a different way — matching on data that changes every run.

That's when a dedicated test hook earns its place. data-testid is an explicit, versioned contract between the app and its tests: this attribute exists for testing, so nobody deletes it during a redesign, and it carries no styling or layout meaning to break.

<button data-testid="cart-checkout" aria-label="Checkout">…</button>
await page.getByTestId('cart-checkout').click();

Playwright reads data-testid by default; point it at your own attribute once if your codebase uses a different one:

// playwright.config.ts
export default defineConfig({
  use: { testIdAttribute: 'data-qa' },
});

Two rules keep this honest. Add test IDs deliberately, not reflexively — if a role-and-name locator already works, a data-testid is strictly worse, because it tests a hidden attribute instead of the interface a user actually touches. And treat the ID as API: renaming cart-checkout is a breaking change, so do it on purpose, not in a careless refactor. Used this way, data-testid is the sanctioned escape hatch below the accessible rungs and above raw CSS — not the first tool you reach for.

Let the framework do the waiting

Resilient locators pair with resilient assertions. A locator that finds the right element still flakes if you check it before the app has rendered. Playwright's web-first assertions retry automatically until the condition holds or the timeout expires, so you never sprinkle waitForTimeout guesses through a test.

// Flaky — reads state once, at a moment that may be too early
expect(await page.getByRole('alert').textContent()).toBe('Saved');

// Resilient — retries until the alert appears and matches
await expect(page.getByRole('alert')).toHaveText('Saved');

Combine an accessible locator with an auto-retrying assertion and most timing flake disappears alongside the structural flake. One more habit: when a locator could match two things, Playwright throws a strictness error instead of silently clicking the first. Resolve it by narrowing — .filter(), .getByRole() inside a container, or { exact: true } on the name — rather than falling back to .first(), which hides the ambiguity your redesign will later expose.

The short version

Reach for locators in this order: role and accessible name, then label or placeholder, then visible text, then a deliberate data-testid, and only then CSS or XPath as a last resort you plan to revisit. Each rung down trades a little resilience for a little convenience; spend that trade consciously. The payoff is a suite that stays green through a redesign and goes red only when behavior actually changes — which is the whole point of having tests.

This is also why TestVibe's generation starts from what a Gherkin step describes a user seeing: a visible label like "Add to cart" gives the agent something to anchor a readable locator to, rather than the raw markup a redesign will move. Get early access to try it on your own app, or read the Gherkin and feature files guide for the authoring syntax behind it.

Early access

Ready for tests that write themselves?