QA & TestingMay 18, 2026

Playwright and Cypress Selector Strategies: Writing Tests That Don't Break

How to choose stable locators for Playwright, Cypress, and Selenium — covering ARIA roles, data-testid, CSS selectors, and XPath with practical examples and a quality rating framework.

Olivia Bennett
Olivia Bennett · Full-stack Developer
Full-stack developer focused on developer tooling and web performance. Writes about the formats, patterns, and shortcuts devs reach for every day.
playwrightcypressseleniumcss selectorstest automationqalocators

The Real Cost of Fragile Selectors

Every flaky test in a CI pipeline has a cost. Someone has to investigate it, decide whether the failure is real or noise, and either fix the test or re-run it. If the answer is "re-run it" more than once, the test suite has lost its purpose — developers stop trusting it.

The single biggest source of flakiness in UI automation is not timing, not network latency, and not test data. It is selectors that break on legitimate UI changes. A developer renames a CSS class, wraps a button in a new <div>, or adjusts layout with a new Tailwind utility class, and suddenly 15 tests fail for reasons that have nothing to do with the feature those tests were meant to verify.

This guide covers how to choose selectors that survive real-world UI evolution for Playwright, Cypress, and Selenium, with a framework for rating selector quality before you write the test.


The Four-Tier Quality Framework

Before writing any locator, ask: what has to change in the UI for this selector to break? The answer determines the selector's grade:

Grade Breaks when… Examples
Best The test requirement itself changes getByRole('button', { name: 'Place order' }), [data-testid="submit"]
Good The element's visible identity changes #checkout-btn, [aria-label="Search"], getByText('Sign in')
OK Styling or API schema changes input[name="email"], button.btn-primary
Fragile Any layout change nearby form > div:nth-child(3) > button, //div[2]/input[1]

A Best selector encodes intent: "find the submit button". A Fragile selector encodes structure: "find the first input inside the third div inside the form". The first survives a design system migration; the second breaks the moment anyone adds a <fieldset> wrapper.


Playwright Locator Priority

Playwright's documentation defines an explicit priority order. Following it exactly gives you the most resilient test suite:

1. getByRole — the gold standard

// Button with visible text
await page.getByRole('button', { name: 'Place order' }).click();

// Form field linked to a label
await page.getByRole('textbox', { name: 'Email address' }).fill('user@example.com');

// Checkbox
await page.getByRole('checkbox', { name: 'Remember me' }).check();

getByRole works by matching the element's ARIA role and its accessible name — the text that a screen reader would announce. This means:

  • It only breaks if the button is removed or its label changes (both are real bugs)
  • It serves as a passive accessibility audit: if getByRole can't find your button, a screen reader user can't either
  • It handles aria-label, aria-labelledby, and visible text content automatically

Every interactive HTML element has an implicit ARIA role. Buttons are button, links are link, text inputs are textbox, selects are combobox. You rarely need to add role= attributes — they are already there.

2. getByLabel — for form fields

await page.getByLabel('Password').fill('hunter2');
await page.getByLabel('Country').selectOption('Ukraine');

getByLabel finds form controls by their associated <label> text. It handles for/id linking, aria-labelledby, and inputs wrapped inside <label> elements. Use it for any form field that has a visible label — it is more specific than getByRole('textbox') when a form has multiple text inputs.

3. getByTestId — explicit test hooks

await page.getByTestId('checkout-place-order').click();

getByTestId requires you to add a data-testid attribute to the element. This is not a weakness — it is a contract: the developer is explicitly saying "this element is a test target, do not remove this attribute without updating tests." Use it when:

  • The element has no meaningful ARIA role or visible label
  • You need to distinguish between identical elements (three "Delete" buttons in a list)
  • The element's visible text is dynamic or localized

Name data-testid values in kebab-case, scoped to the feature: checkout-place-order, not submit. Bare submit collides as soon as a second form appears on the same page.

4. getByPlaceholder and getByText

// Input without a label
await page.getByPlaceholder('Search products…').fill('keyboard');

// Static visible text
await page.getByText('Forgot password?').click();

These are reliable for elements where role-based locators are too broad. getByPlaceholder breaks if placeholder text is localized or changed in a copy update — use it only when the input genuinely has no label. getByText is solid for static text that is part of the product contract (a button or link label), less solid for marketing copy that product may change.


Cypress Selector Strategies

Cypress does not have Playwright's semantic locator API built in, but the same principles apply:

// Best — dedicated test attribute
cy.get('[data-testid="login-submit"]').click();

// Best — accessible role + name via cypress-testing-library
cy.findByRole('button', { name: 'Sign in' }).click();

// Good — ID
cy.get('#login-submit').click();

// Good — text match with tag scoping
cy.contains('button', 'Sign in').click();

// OK — semantic class
cy.get('.btn-primary').click();

// Avoid — structural path
cy.get('form > div:nth-child(3) > button').click();

The cy.contains(tag, text) form is significantly more reliable than bare cy.contains(text). Scoping to a tag type prevents false matches on labels, tooltips, or other text nodes that happen to contain the same string.

If you use @testing-library/cypress, cy.findByRole and cy.findByLabelText bring Playwright's semantic locator API directly into Cypress. This is the recommended approach for new test suites.


Selenium Selector Priority

Selenium does not provide semantic locators natively, so the priority is CSS-based:

# Best — test attribute
driver.find_element(By.CSS_SELECTOR, '[data-testid="submit-order"]')

# Best — ID
driver.find_element(By.ID, 'submit-order')

# Good — name attribute (forms only)
driver.find_element(By.NAME, 'email')

# Good — link text
driver.find_element(By.LINK_TEXT, 'Forgot password?')

# OK — CSS class (fragile if design system changes)
driver.find_element(By.CSS_SELECTOR, 'button.btn-primary')

# Avoid — full XPath
driver.find_element(By.XPATH, '/html/body/div[1]/form/div[2]/button')

For Selenium, By.ID is the most reliable native locator because IDs are semantically unique per page. After that, By.CSS_SELECTOR with a data-testid gives you the same stability as getByTestId in Playwright.

XPath in Selenium should only appear when you need to traverse up to a parent element or filter by text content. Even then, use relative XPath (//button[contains(., 'Submit')]) rather than absolute paths (/html/body/…).


Adding data-testid to Your Components

The fastest way to harden your test suite is a single afternoon adding data-testid to interactive elements across your application:

// React — pass through from design system
interface ButtonProps {
  children: React.ReactNode;
  testId?: string;
}
export function Button({ children, testId }: ButtonProps) {
  return <button data-testid={testId}>{children}</button>;
}

// Usage
<Button testId="checkout-place-order">Place order</Button>
<!-- Vue -->
<template>
  <button :data-testid="testId"><slot /></button>
</template>
<!-- Plain HTML -->
<button data-testid="checkout-place-order">Place order</button>

A naming convention prevents collision: {feature}-{element}. The checkout- prefix scopes the attribute to the checkout feature — checkout-place-order, checkout-edit-address, checkout-apply-coupon. When a second team adds a "place order" button to a different flow, they use subscription-place-order.


CSS Selector Quality by Attribute Type

Not all CSS selectors carry the same risk. Here is how to assess a selector before using it:

Attribute Stability Why
data-testid Very high Dedicated test contract — only changes intentionally
id High Unique by definition; changes with feature renames
aria-label High Accessibility attribute — teams treat it seriously
name (form inputs) Medium Changes when backend API schema changes
Semantic class (.btn-primary) Low Changes with design system or component refactors
Utility class (.flex.mt-4) Very low Layout-only, no identity — Tailwind changes constantly
Structural path (div:nth-child(2) > input) Very low Breaks on any layout change, silently

The key test: could a developer change this attribute for a reason unrelated to what your test is checking? If yes, the selector is fragile.


XPath: When to Use It

XPath is more expressive than CSS but harder to read and maintain. Use it only when CSS cannot express what you need:

Parent traversal — CSS has no parent selector:

//label[contains(., 'Email')]/..

Finds the container element of a label. Useful when you need to scope interactions to a specific form group.

Text anywhere in a subtree:

//button[contains(., 'Submit')]

Matches a button whose text is nested inside a <span>. The CSS equivalent (:has()) has limited support in older test runners.

Attribute prefix matching:

//input[starts-with(@id, 'user-field-')]

Useful for dynamically generated IDs that follow a known pattern.

Always write relative XPath starting with //, never absolute paths starting with /html/body. Absolute XPath encodes the full DOM structure and breaks the moment anyone adds a wrapper element.


Practical Workflow

  1. Open the page in DevTools — right-click the target element and choose Copy → Copy outerHTML.
  2. Paste the HTML into the CSS Selector Generator and click the element. The tool shows Playwright, Cypress, Selenium, CSS, and XPath selectors side by side, each rated Best / Good / OK / Fragile with an explanation.
  3. Pick the highest-rated option. If only OK or Fragile options exist, that is a signal to add data-testid to the component before writing the test.
  4. Add the selector to your test — the Copy button puts it in the right framework syntax.
  5. Review after design changes — run the generator on the updated HTML. If the selector rating dropped, update the test and the component attribute together.

This loop keeps selector quality visible across the team rather than buried in test file diffs.


Common Mistakes

Using auto-generated class names as selectors. CSS-in-JS libraries (styled-components, Emotion, CSS Modules with hashing) generate class names like .sc-bdfxgF or .module_button__3kJHa that change on every build. Never use them as selectors.

Hardcoding position-based selectors after copy. Browser DevTools' "Copy selector" feature generates structural paths like #app > div.container > div:nth-child(2) > button. These are always Fragile — they encode DOM position, not element identity. Treat DevTools-generated selectors as a starting point for finding the element, not as a final selector.

Using getByRole without a name. page.getByRole('button') matches every button on the page. Always pass a name option: page.getByRole('button', { name: 'Submit' }). Without it, the locator becomes brittle to page composition changes.

Ignoring selector failures as flakiness. When a selector fails intermittently, the usual cause is not a timing problem but an ambiguous selector that sometimes matches the wrong element. Add a name option or switch to a more specific locator before adding waitFor retries.

← All guides