Skip to main content
/tayyab/portfolio — zsh
tayyab
TA
// dispatch.read --classified=false --access-level: public

Brittle CSS Locators in Playwright: Migrate to Role-Based Selectors in One Hour

April 17, 2026 EST. READ: 11 MIN #Quality Assurance

Pull up any Playwright suite older than a year. Roughly two-thirds of the locators will look like this:

await page.locator('.btn.btn-primary.checkout-btn').click();
await page.locator('div.modal > div.modal-body > input.form-control').fill('hello');
await page.locator('#user-menu-dropdown li:nth-child(3) a').click();

Every one of these is a future bug report. The first breaks when someone adds a CSS modifier class. The second breaks when the modal structure changes. The third breaks when the menu item order changes. None of them survive a redesign.

The fix is the same fix you've heard about: role-based locators. getByRole, getByLabel, getByPlaceholder, getByTestId. The Playwright docs cover the basics. What they don't cover is the systematic migration — how to convert an existing test file in one focused hour without breaking everything.

Table of Contents

Why CSS Locators Rot Faster Than Every Other Test Asset

Three reasons:

  1. CSS classes are written for styling, not testing. A frontend developer renames .btn-primary to .button-primary because it reads better. Their bundle size shrinks by 40 bytes. Your tests die.
  2. CSS-in-JS hashing. Tailwind, styled-components, CSS Modules — all generate randomized class names like .css-1xj2k4f. The class on Monday is different from the class on Tuesday.
  3. Structural selectors are fragile. div > div > ul > li:nth-child(2) breaks the moment someone wraps the section in another div. Your test fails for reasons unrelated to the user-facing behavior.

Role-based locators avoid all three because they're based on the accessibility tree — what assistive technology sees. Screen readers don't care about CSS class names. They care about role="button" and the accessible name. Same as a real user does.

The Priority Order for Picking a Locator

This is the heuristic I use, in order. Pick the highest applicable option and stop.

  1. getByRole(role, { name }) — for any interactive element with text or aria-label. Buttons, links, headings, form controls.
  2. getByLabel(label) — for form inputs that have a <label>.
  3. getByPlaceholder(placeholder) — for inputs with placeholder text but no label.
  4. getByText(text) — for non-interactive text content (paragraphs, status messages).
  5. getByAltText(alt) — for images.
  6. getByTitle(title) — for elements with title attributes (icons, tooltips).
  7. getByTestId(testid) — for everything else, only after explicit cooperation with the dev team to add data-testid attributes.
  8. CSS/XPath — last resort. Only when none of the above work.

The first four cover 80% of cases. Test IDs cover the next 15%. CSS handles the long tail.

CSS-to-Role Conversion Cheat Sheet

The most common conversions:

Old (CSS)New (Role-Based)
page.locator('button.submit')page.getByRole('button', { name: 'Submit' })
page.locator('a.nav-link:has-text("About")')page.getByRole('link', { name: 'About' })
page.locator('input[type="email"]')page.getByLabel('Email') or page.getByRole('textbox', { name: 'Email' })
page.locator('input[placeholder="Search..."]')page.getByPlaceholder('Search...')
page.locator('h1.page-title')page.getByRole('heading', { level: 1 })
page.locator('.error-message')page.getByRole('alert')
page.locator('img.logo')page.getByAltText('Company logo')
page.locator('.checkbox-confirm')page.getByRole('checkbox', { name: 'I agree' })
page.locator('select.country')page.getByRole('combobox', { name: 'Country' })
page.locator('table.users tr')page.getByRole('row')
page.locator('.modal')page.getByRole('dialog')

The One-Hour Migration Workflow

Pick one test file. Goal: zero CSS locators when you're done.

Step 1 (5 minutes): Audit current locators

grep -E "(locator\(|.locator\()|page\.\\\$|page\.\\\$\\\$" tests/checkout.spec.ts

Or in the IDE: search for locator(. List every line. Most files have 20–40.

Step 2 (40 minutes): Convert each one

Open the test file and the app side-by-side. For each locator:

  1. What is this element for from the user's perspective? "Submit button." "Email input." "Error message."
  2. Pick the highest-priority locator that matches.
  3. Replace.

Use the Playwright codegen tool to verify your new locators work:

npx playwright codegen http://localhost:3000

Click on the element you're targeting. Codegen suggests the locator. Cross-check against your manual choice. Codegen leans heavily on role-based selectors and is a useful sanity check.

Step 3 (10 minutes): Run, fix, run again

npx playwright test tests/checkout.spec.ts

Most failures will be:

  • "Locator resolved to multiple elements" — your role+name match is too broad. Add a parent scope: page.getByRole('dialog').getByRole('button', { name: 'Save' }).
  • "Locator not found" — the element has no accessible name. The dev team needs to add an aria-label, or you fall back to getByTestId.

Step 4 (5 minutes): Commit

Single commit per file. Don't bundle the migration with feature work. Reviewers should see the migration in isolation.

Hard Cases (and How to Handle Them)

Icon-only buttons

Button with no text, just an icon. getByRole('button', { name: 'Delete' }) fails because there's no accessible name.

Right fix: add aria-label="Delete" on the button. This is also a real accessibility bug — screen readers can't announce the button. Fix it for both reasons.

Workaround if you can't change the app: getByRole('button').filter({ has: page.locator('svg.icon-trash') }).

Nth-of-kind elements (table rows, list items)

Don't use nth-child. Use the visible content:

// BAD
await page.locator('table tr:nth-child(3) button').click();

// GOOD
await page.getByRole('row', { name: /jane@test.com/ })
  .getByRole('button', { name: 'Edit' })
  .click();

If your table doesn't have unique row content, ask why. Tests that depend on row position are brittle by design.

Custom dropdowns and comboboxes

Many design systems implement dropdowns as divs. They look like comboboxes but don't have role="combobox". Same fix: ask the design system team to add proper roles. Workaround: getByTestId until they do.

Dynamically-numbered elements

Lists where item content can repeat (e.g., "Apple" might appear 3 times in a comments thread). Scope to the parent:

// First find the comment by author, then the like button within it
await page.getByRole('article', { name: 'Comment by Bob' })
  .getByRole('button', { name: 'Like' })
  .click();

Iframes and shadow DOM

Roles work inside iframes (after frameLocator) and across open shadow DOM (automatically). For closed shadow DOM, see my iframe + shadow DOM post.

Preventing Regression With ESLint

After the migration, install eslint-plugin-playwright and enable the no-raw-locators rule:

// .eslintrc.json
{
  "plugins": ["playwright"],
  "rules": {
    "playwright/prefer-locator": "error",
    "playwright/no-raw-locators": "error"
  }
}

Now any new test that uses page.locator('.css-class') fails the lint check. Migration sticks because new code can't slip back into the old pattern.

FAQs

What if the dev team won't add aria-labels or test IDs?

Frame it as accessibility, not testing. Missing aria-labels on icon buttons fail WCAG 2.1. Most companies have a compliance reason to fix them. Once they're fixed, your tests get reliable locators for free.

Are test IDs worse than role-based locators?

Test IDs are fine — they're just less general. Role-based locators work whether the dev team helps or not. Test IDs require dev cooperation but give you 100% reliability where they exist. Use both.

Should I use getByText for buttons?

No. Buttons should use getByRole('button', { name: ... }). getByText matches any element containing that text — could match a span inside a div that's not the button.

What about XPath?

Even more brittle than CSS. XPath ties to DOM structure even more tightly. If you ever find yourself writing XPath, that's a red flag — find a different locator.

Can I use getByRole with i18n?

Yes — name can be a regex: getByRole('button', { name: /save|guardar|sauvegarder/i }). Or scope by user state: log the user in with their preferred locale and the role/name match works.

Does this work with React Native or other non-web targets?

The patterns translate. Playwright doesn't target React Native directly, but tools like Playwright for Android (via Espresso adapters) use similar role-based ideas.

How do I migrate Selenium tests with By.cssSelector to Playwright role-based?

Same exercise but slower because Selenium-to-Playwright is a framework migration too. See my Selenium-to-Playwright migration post.

What if my page has the wrong roles (e.g., a div that should be a button)?

That's an a11y bug in your app. Fix it. Role-based locators won't work until the page has correct semantics, and screen-reader users have the same problem your tests do.

Should I always use { exact: true } for name matching?

By default name matching is substring + case-insensitive. { exact: true } requires exact match. I default to non-exact for resilience to label changes; switch to exact only when names are ambiguous.

What's the impact on test execution speed?

Negligible. Role-based queries are O(n) over the accessibility tree, which is built once per page. CSS queries are O(n) over the DOM. Both are fast.

Wrap-Up

The CSS-to-role migration is one of the highest-leverage things you can do for an existing test suite. Brittleness drops, tests survive redesigns, and you stop getting paged when the design team ships a Tailwind refactor.

If you have hundreds of tests in CSS-locator hell and you'd rather pay someone to do the migration than spend two weeks of internal time on it, that's literally what I do in framework engagements. Or book a free call.

Related reading:

Tayyab Akmal
// author

Tayyab Akmal

AI & QA Automation Engineer

6 years of catching critical bugs in fintech, e-commerce, and SaaS — then building the Playwright and Selenium automation that prevents them from shipping again.

// feedback_channel

FOUND THIS USEFUL?

Share your thoughts or let's discuss automation testing strategies.

→ Start Conversation
Available for hire