Halmurat T.
Halmurat T.

Senior SDET

Home Blog Books ask About

The Dispatch

Weekly QA notes from the trenches.

Welcome aboard!

You're on the list. Expect real-world QA insights — no fluff, no spam.

© 2026 Halmurat T.

Automation 24
  • Selenium
  • Playwright
  • Appium
  • Cypress
AI Testing 5
CI/CD 6
  • GitHub Actions
  • Slack Reporting
QA Strategy 4
Case Studies 5
Blog/Automation
AutomationHalmurat T./March 19, 2026/9 min

Stop Using locator() for Everything in Playwright

Filed underplaywright/selenium/framework-design/design-patterns
Stop Using locator() for Everything in Playwright

Table of Contents
  • The Selenium Muscle Memory Problem
  • What the same code looks like with semantic locators
  • Why getByRole Is More Than Syntactic Sugar
  • It catches accessibility regressions for free
  • It matches how your users actually navigate
  • When to Use Which Locator
  • Tier 1: Semantic locators (use by default)
  • Tier 2: Test IDs (when semantics aren’t enough)
  • Tier 3: CSS/XPath via locator() (last resort)
  • The Migration Pattern I Recommend
  • Step 1: New tests use semantic locators only
  • Step 2: Refactor on touch
  • Step 3: Audit the remaining locator() calls
  • The Real Test: What Happens During a Redesign
  • What About Performance?
  • The Gotcha That Trips Up Every Team: Partial Text Matching
  • One Action You Can Take Today

On this page

  • The Selenium Muscle Memory Problem
  • What the same code looks like with semantic locators
  • Why getByRole Is More Than Syntactic Sugar
  • It catches accessibility regressions for free
  • It matches how your users actually navigate
  • When to Use Which Locator
  • Tier 1: Semantic locators (use by default)
  • Tier 2: Test IDs (when semantics aren’t enough)
  • Tier 3: CSS/XPath via locator() (last resort)
  • The Migration Pattern I Recommend
  • Step 1: New tests use semantic locators only
  • Step 2: Refactor on touch
  • Step 3: Audit the remaining locator() calls
  • The Real Test: What Happens During a Redesign
  • What About Performance?
  • The Gotcha That Trips Up Every Team: Partial Text Matching
  • One Action You Can Take Today

I review test automation code for a living. The single most common pattern I see in Playwright codebases — especially from teams that migrated from Selenium — is page.locator('.btn-submit') everywhere. CSS selectors, data attributes, XPaths, all piped through locator(). The Playwright docs are right there. getByRole, getByText, getByLabel, getByTestId — purpose-built methods that find elements the way a user would. But old habits die hard.

The Selenium Muscle Memory Problem

This isn’t a knowledge problem. Most teams know getByRole exists. It’s a muscle memory problem. If you spent 5+ years writing By.id(), By.cssSelector(), and By.xpath() in Selenium, your brain defaults to the same pattern in Playwright. You reach for locator() with a CSS selector because that’s what “finding an element” means to you.

I’ve seen this play out at three different enterprises in the last year alone. Teams migrate from Selenium to Playwright, run through the getting-started docs, and then immediately start writing:

src/pages/LoginPage.ts
// The Selenium mindset in Playwright clothes
async login(username: string, password: string) {
await this.page.locator('#username').fill(username);
await this.page.locator('#password').fill(password);
await this.page.locator('.btn-login').click();
}

This works. It passes. But it’s fragile for the same reasons your Selenium selectors were fragile — you’ve coupled your test to implementation details that change with every UI refactor. I covered this broader problem in why text-based selectors outlast CSS classes: when your locator depends on a class name or ID, every frontend commit is a potential test-breaking event.

What the same code looks like with semantic locators

src/pages/LoginPage.ts
// How Playwright wants you to find elements
async login(username: string, password: string) {
await this.page.getByLabel('Username').fill(username);
await this.page.getByLabel('Password').fill(password);
await this.page.getByRole('button', { name: 'Log in' }).click();
}

No CSS selectors. No IDs. No fragile coupling. These locators survive a complete redesign as long as the labels and button text stay the same — and if those change, your test should break, because that’s a user-facing change worth verifying.

Why getByRole Is More Than Syntactic Sugar

The getByRole method isn’t just a prettier way to find elements. It queries the accessibility tree, not the DOM. That distinction matters more than most teams realize.

It catches accessibility regressions for free

When you use getByRole('button', { name: 'Submit' }), Playwright checks that the element is actually exposed as a button to assistive technology. If someone replaces a <button> with a styled <div onclick="...">, your test fails — not because the click doesn’t work, but because the element isn’t a button in the accessibility tree anymore. That’s a real accessibility regression your CSS selector would have missed.

tests/checkout.spec.ts
// This passes even if the button is a styled div
await page.locator('.submit-btn').click();
// This fails if the element isn't actually a button role
await page.getByRole('button', { name: 'Place order' }).click();
[ TIP ]

Think of getByRole as a free accessibility audit on every test run. If your locator can’t find the element, something is wrong with your HTML semantics — not your test.

It matches how your users actually navigate

Screen readers, keyboard navigation, and automated accessibility tools all use the same role-based model. When you write getByRole('textbox', { name: 'Email' }), your test is doing exactly what a screen reader does: looking for an input labeled “Email.” If that works for your test, it works for your users.

When to Use Which Locator

Playwright gives you a hierarchy of locator methods. Here’s the decision framework I use after reviewing dozens of enterprise codebases:

Tier 1: Semantic locators (use by default)

MethodWhen to useExample
getByRoleButtons, links, headings, checkboxes, inputs with visible labelsgetByRole('button', { name: 'Save' })
getByLabelForm fields with associated <label> elementsgetByLabel('Email address')
getByTextStatic text, paragraphs, error messages, non-interactive contentgetByText('Order confirmed')
getByPlaceholderInputs without visible labels (fix the a11y, but this works meanwhile)getByPlaceholder('Search...')

Tier 2: Test IDs (when semantics aren’t enough)

MethodWhen to useExample
getByTestIdComplex components with no unique text, dynamic lists, canvas elementsgetByTestId('transaction-row-5')

Tier 3: CSS/XPath via locator() (last resort)

MethodWhen to useExample
locator()Third-party widgets you can’t modify, shadow DOM, nth-child patternslocator('.ag-grid-row >> nth=0')
[ WARNING ]

If more than 20% of your locators are Tier 3, you’re probably reaching for locator() out of habit rather than necessity. Audit your page objects — most CSS selectors have a semantic equivalent.

The Migration Pattern I Recommend

You don’t need to rewrite everything at once. Here’s the approach I’ve used on three enterprise Playwright migrations — as I described in moving from Selenium wrappers to Playwright locators, the key is incremental adoption, not a big-bang rewrite.

Step 1: New tests use semantic locators only

Set a team rule: any new test or page object method must use getByRole, getByLabel, or getByText first. Only fall back to locator() if you can explain why the semantic approach doesn’t work.

Step 2: Refactor on touch

When you modify an existing test for any reason — fixing a bug, updating a flow, adding an assertion — convert its locators at the same time. Don’t create separate “locator migration” tickets. Spread the work across normal development.

Step 3: Audit the remaining locator() calls

After a month, grep your codebase:

terminal
grep -rn "page\.locator(" tests/ | wc -l
grep -rn "page\.getBy" tests/ | wc -l

Track the ratio over time. On one project, we went from 85% locator() to 30% in six weeks without any dedicated migration sprint. The remaining 30% were legitimate uses — third-party widgets, complex grid components, and a few shadow DOM edge cases.

The Real Test: What Happens During a Redesign

The argument for semantic locators isn’t theoretical. I watched it play out at a large financial services company during a design system migration. The frontend team replaced their custom component library with a new one over a 3-week sprint. Every CSS class changed. Every data-attribute prefix changed. The DOM structure of most components changed.

The result on our 600-test Playwright suite:

  • Tests using locator() with CSS selectors: 73% broke. Two engineers spent 4 days updating selectors.
  • Tests using getByRole/getByLabel/getByText: 8% broke — and every one of those was a genuine user-facing change (button text changed, a label was reworded).

That’s not a subtle difference. That’s the difference between “the test suite is a burden” and “the test suite caught real issues.”

What About Performance?

I hear this objection occasionally: “CSS selectors are faster than accessibility tree queries.” It’s technically true — DOM queries are faster than accessibility tree traversal. But in practice, the difference is 1-3ms per locator call. On a 500-test suite, that adds up to maybe 2 seconds total. You’ll save more time in a single morning of not debugging broken CSS selectors than you’ll lose to accessibility tree queries in a year of test runs.

The Gotcha That Trips Up Every Team: Partial Text Matching

Here’s something the Playwright docs mention but most teams miss until it costs them a debugging session. By default, getByRole and getByText use substring matching. If your page has both a “Log in” button and a “Log in with Google” button, getByRole('button', { name: 'Log in' }) matches both — and Playwright throws a strict mode violation because it found multiple elements.

I’ve seen this burn teams hard on pages with repetitive button labels — think an admin dashboard with 12 “Edit” buttons across different table rows. The fix is the exact option:

tests/admin-dashboard.spec.ts
// Fails: matches "Edit", "Edit user", "Edit permissions"
await page.getByRole('button', { name: 'Edit' }).click();
// Works: matches only buttons with exactly "Edit"
await page.getByRole('button', { name: 'Edit', exact: true }).click();

When exact: true still isn’t enough — like those 12 identical “Edit” buttons — scope the locator to a parent container. This is the one pattern where chaining locator() with getByRole makes sense: page.locator('[data-testid="users-table"]').getByRole('button', { name: 'Edit' }). You get the semantic benefits of getByRole with just enough DOM context to disambiguate. This chained approach is especially useful in data-heavy enterprise apps where managing test data properly can also help reduce ambiguity in your selectors.

§ Frequently Asked FAQ
+ Is getByRole slower than locator() with CSS selectors?

Technically yes by 1-3ms per call, but the difference is negligible at scale. A 500-test suite adds roughly 2 seconds total. You save far more time avoiding brittle selector maintenance.

+ When should I still use locator()?

Third-party widgets you can’t modify, shadow DOM boundaries, complex grid components like ag-Grid, and nth-child patterns where no semantic identifier exists. If you can explain why getByRole doesn’t work, locator() is fine.

+ Should I use data-testid on everything instead?

No. data-testid is better than CSS classes but worse than getByRole because it still requires developer effort to add and maintain. Use getByRole first, getByTestId when semantic locators genuinely can’t distinguish the element.

+ Does getByRole work with custom web components?

Yes, if the component exposes proper ARIA roles. If it doesn’t, that’s an accessibility bug worth fixing. Use getByTestId as a workaround while the component team adds proper roles.

One Action You Can Take Today

Open your Playwright codebase, pick one page object, and replace every locator() call with its semantic equivalent. getByRole for buttons and links, getByLabel for form fields, getByText for static content. Run the tests. If they pass, you just made that page object redesign-proof. If they fail, you just found an accessibility gap your users have been hitting too.

§ Further Reading 03 of 03
01Automation

XPath text() vs Dot — Why Your Text Match Fails

The real difference between XPath text(), dot, contains(), and normalize-space() for test automation — with examples that explain real flaky failures.

Read →
02Automation

Stop Launching a Browser Per Test in Playwright

Launching a new browser per test in Playwright wastes 3-6 seconds each time. BrowserContext gives you the same isolation in milliseconds. Here's how to switch.

Read →
03Automation

Selenium's Alert Handling Crashed Our Parallel Suite

How UnhandledAlertException broke 8-thread parallel execution and why Playwright's event-driven dialog model avoids that entire failure pattern in practice.

Read →

Don't miss a thing

Subscribe to get updates straight to your inbox.

HT

No spam · Unsubscribe anytime

Welcome aboard!

You're on the list. Expect real-world QA insights — no fluff, no spam.

§ Colophon

Halmurat T. — Senior SDET writing about test automation, CI/CD, and QA strategy from 10+ years in the enterprise trenches.

Set in
IBM Plex Sans, Lora, and IBM Plex Mono.
Built with
Astro, MDX, Tailwind CSS & Expressive Code. Served by Vercel.
Privacy
No cookies. No tracking scripts on the main thread — analytics run sandboxed via Partytown.
Source
github.com/Halmurat-Uyghur
Terminal
Try /ask to query Halmurat's notes in a shell prompt.

© 2026 Halmurat T. · Written in plain text, shipped in plain time.

Search
Esc

Search is not available in dev mode.

Run npm run build then npm run preview:local to test search locally.