SoftwareTestPilot
Automation TestingPublished: 11 min read

Playwright Locator Best Practices (2026) — The Only Guide You Need

Master Playwright locators the right way: getByRole vs getByTestId vs CSS vs XPath, live-tested examples, Page Object patterns, and how to kill flaky tests. Includes a free XPath & CSS selector generator.

Avinash Kamble
Founder & QA Engineer at SoftwareTestPilot
Reviewed by Priyanka G.
Share:XLinkedInWhatsApp
Playwright locator strategy diagram — getByRole, getByTestId, CSS, XPath ranked by stability, with SoftwareTestPilot.com wordmark.
Playwright locator strategy diagram — getByRole, getByTestId, CSS, XPath ranked by stability, with SoftwareTestPilot.com wordmark.

Last updated: July 17, 2026 · 11 min read · By Avinash Kamble, reviewed by Priyanka G.

Every senior QA engineer eventually reaches the same conclusion: locators are the number-one predictor of a stable test suite. Get them right and your Playwright pipeline runs green for weeks. Get them wrong and you spend Friday afternoons rewriting selectors after a designer moves a div. This guide is the distilled 2026 playbook — the exact strategy we use across our own QA Practice Hub and recommend in the Playwright interview prep.

Key takeaways

  • Prefer getByRole and getByTestId — the Playwright team, MDN and W3C ARIA all agree.
  • Every absolute XPath in your suite is a future flaky test. Refactor them now.
  • Generate and validate locators against real HTML using our free XPath & CSS Selector Generator.
  • Wrap locators in a Page Object; never let raw selectors leak into test bodies.
  • Assert with expect(locator).toBeVisible(), not waitForTimeout.

1. Why locator strategy is the #1 driver of flaky tests

Analyse any 3-month flaky-test report from a large Playwright suite and 70–80% of the top offenders share the same root cause: a locator that matched the DOM last sprint but doesn't match today. Class names get renamed by Tailwind refactors, wrappers get inserted by design-system upgrades, ids get hashed by build tools, and text copy gets tweaked by product managers. Every one of those events breaks a fragile selector.

The good news: Playwright ships with a hierarchy of user-facing locators that are essentially immune to visual refactors because they mirror how a real user (or a screen reader) perceives the page. Use them and your suite becomes robust by default.

2. The Playwright locator hierarchy — best to worst

+----------------------------------------------------------------------+
|          PLAYWRIGHT LOCATOR HIERARCHY (STABLE → FRAGILE)             |
+----------------------------------------------------------------------+
| 1. page.getByRole('button', { name: 'Sign in' })   ⭐ RECOMMENDED    |
| 2. page.getByTestId('submit-btn')                  ⭐ RECOMMENDED    |
| 3. page.getByLabel('Email')                        ✅ good           |
| 4. page.getByPlaceholder('you@example.com')        ✅ good           |
| 5. page.getByText('Forgot password?')              ✅ good           |
| 6. page.locator('#submit-btn')                     ⚠️  ok if stable  |
| 7. page.locator('form.auth input[name=email]')     ⚠️  fragile       |
| 8. page.locator('xpath=/html/body/div[2]/form/..') ❌ never ship it  |
+----------------------------------------------------------------------+

The official Playwright docs explicitly recommend the top of this list. Everything below getByText should be treated as a warning sign in code review — acceptable only when nothing more stable exists.

3. getByRole — the accessibility-first default

getByRole queries the accessibility tree, not the DOM. That means it matches the element the way a screen reader would — by its ARIA role (button, link, textbox, heading, dialog…) and its accessible name (visible text, aria-label, associated label). Two consequences:

  • Your tests double as an accessibility audit. If getByRole('button', { name: 'Submit' }) can't find your button, neither can a screen reader user.
  • Refactors that don't change semantics don't break tests. Swap a <div onclick> for a <button>? The role stays 'button' — test still passes.
// ✅ Accessibility-first, stable across visual refactors
await page.getByRole('button', { name: 'Sign in' }).click();
await page.getByRole('textbox', { name: 'Email' }).fill('you@example.com');
await expect(page.getByRole('heading', { name: 'Welcome back' })).toBeVisible();

Common gotcha: implicit roles. <a href> is 'link', <input type="checkbox"> is 'checkbox', <h1><h6> are 'heading'. Use the W3C HTML-ARIA mapping when unsure.

4. getByTestId — the pragmatic escape hatch

Not every element has a meaningful role. A pricing tier card, a hidden feature flag toggle, a specific row in a data grid — these are best selected with an explicit data-testid that developers commit to preserving. Once a data-testid ships, changing it becomes a breaking change that shows up in code review — exactly the contract you want.

// Playwright — clean and stable
await page.getByTestId('pricing-tier-pro').click();

// In the HTML — developers own this attribute
// <div data-testid="pricing-tier-pro" class="px-4 py-6 rounded-2xl">...</div>

Configure Playwright's test-id attribute globally so you're not tied to data-testid specifically:

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

Team rule of thumb: add a data-testid the moment you write a test whose only stable option is a fragile CSS selector. Push it as part of the same PR as the test.

5. CSS vs XPath — when to use each

When you must fall back to a raw selector, prefer CSS attribute selectors over XPath. CSS is faster, better supported by browsers, and easier for future maintainers to parse. Use XPath only when you specifically need capabilities CSS doesn't have — text matching, axis traversal (ancestor::, following-sibling::), or attribute predicates with logic.

// ✅ CSS — first choice for attribute-based selection
page.locator('input[name="email"][type="email"]');
page.locator('button[data-status="loading"]');

// ✅ XPath — legitimate use cases
page.locator('xpath=//tr[td[normalize-space()="Acme Corp"]]/td[3]/button');
page.locator('xpath=//label[contains(text(), "Terms")]/preceding-sibling::input');

// ❌ Absolute XPath — breaks on any DOM change
page.locator('xpath=/html/body/div[2]/div[1]/form/div[3]/button');

Every absolute XPath (one that starts /html/) in your suite is a Friday afternoon waiting to happen. Refactor them proactively. Not sure what to replace them with? Paste the surrounding HTML into our free XPath & CSS Selector Generator — it validates each candidate against the DOM and flags fragile ones automatically.

6. Chaining and filtering — scoping locators the safe way

Complex UIs have repeated patterns — twenty rows in a table, six pricing cards on a marketing page. Instead of writing an elaborate single selector, chain locators to scope the search:

// Scope: find the 'Delete' button inside the row whose first cell contains 'Acme'
const row = page.getByRole('row').filter({ hasText: 'Acme' });
await row.getByRole('button', { name: 'Delete' }).click();

// Scope a dialog and interact only within it
const dialog = page.getByRole('dialog', { name: 'Confirm delete' });
await dialog.getByRole('button', { name: 'Yes, delete' }).click();

Chained locators read like English, survive DOM changes, and give better error messages when they fail (Playwright reports the exact step in the chain that didn't match).

7. Auto-waiting assertions — never write waitForTimeout

The single biggest source of flakiness after bad locators is hardcoded waits. Playwright's expect(locator).toBe*() assertions retry automatically until the condition is met or a timeout expires. Use them everywhere:

// ❌ Flaky and slow
await page.click('#submit');
await page.waitForTimeout(3000);
const text = await page.locator('.status').textContent();
expect(text).toBe('Saved');

// ✅ Auto-waits, fast when the app is fast
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.getByRole('status')).toHaveText('Saved');

Available: toBeVisible, toBeEnabled, toBeChecked, toHaveText, toHaveValue, toHaveCount, toHaveURL, toHaveAttribute. If you're still typing waitForTimeout, you're leaving reliability on the table.

8. Encapsulate locators in a Page Object

Never let a raw selector leak into a test body. Wrap every locator in a Page Object class — this is the single change that most improves a suite's long-term maintenance cost. When the DOM changes, you update one file, not fifty tests.

// pages/LoginPage.ts
import { Page, Locator } from '@playwright/test';

export class LoginPage {
  constructor(private readonly page: Page) {}

  readonly email = this.page.getByRole('textbox', { name: 'Email' });
  readonly password = this.page.getByRole('textbox', { name: 'Password' });
  readonly submit = this.page.getByRole('button', { name: 'Sign in' });
  readonly error = this.page.getByRole('alert');

  async goto() { await this.page.goto('/login'); }

  async login(email: string, password: string) {
    await this.email.fill(email);
    await this.password.fill(password);
    await this.submit.click();
  }
}

Don't want to hand-write POM classes? Our selector generator emits a full Page Object class for Playwright TypeScript, Selenium Java, or Cypress JavaScript in one click — paste the HTML, click Copy, commit.

9. iframes, Shadow DOM and other edge cases

Modern apps embed content — payment widgets, video players, embedded design tools. Two rules cover most of them:

  • iframes: use page.frameLocator('iframe[title="Stripe"]').getByRole('textbox', { name: 'Card number' }). Never try to reach into an iframe with a normal locator — it won't work.
  • Shadow DOM: Playwright's locators pierce open shadow roots automatically. getByRole works across shadow boundaries. For closed shadow roots, there is no supported access — that's the platform's contract.

Web Components with a lot of shadow DOM are where getByRole and getByTestId earn their keep — CSS selectors simply can't traverse shadow trees reliably.

10. The 60-second locator code-review checklist

Print this list. Paste it into your PR template. Reject any test PR that fails it:

  1. Does the locator use getByRole, getByTestId, getByLabel, or getByPlaceholder? If yes → approve.
  2. Is there a raw xpath=/html/...? Reject — request refactor.
  3. Is a raw class selector used (.btn-primary-xl)? Ask if a role or test-id would work.
  4. Is the locator scoped with .filter() or a parent chain? If iterating over similar elements, it should be.
  5. Are there any waitForTimeout() calls? Replace with expect(locator).toBe*().
  6. Do reused locators live in a Page Object or inline in the test? Move them out.

Ship this discipline for one quarter and your flake rate typically drops by 60–80%.

FAQ — Playwright locator questions engineers actually ask

Q: When should I still use CSS selectors over getByRole?
When the element has no meaningful ARIA role and no data-testid — for example, a decorative wrapper you want to assert exists. Even then, add a data-testid instead if you own the codebase.

Q: Is getByText fragile because product managers rewrite copy?
Yes, in fast-moving marketing UIs. In stable interactive UIs (buttons, links, headings) it's fine. Use getByRole with the name option when you can — it matches accessible name, which is more forgiving than exact text.

Q: Should I ban XPath entirely?
No. XPath's text and axis capabilities are legitimately useful (finding the button inside the row that contains a specific customer name, for example). Ban absolute XPath. Allow relative XPath with review.

Q: Do these rules apply to Selenium and Cypress?
Yes — the principles transfer directly. Selenium 4's relative locators and Cypress's cy.findByRole() (via Testing Library) map to the same hierarchy. See our Selenium interview prep and Cypress vs Playwright 2026 comparison.

Next steps

Ready to put this into practice?

Keep going

Practice these questions

Drill 200+ Playwright questions with senior-SDET sample answers — locators, auto-wait, fixtures, parallelism and trace viewer.

Found this useful?
Share:XLinkedInWhatsApp

Was this article helpful?

Cluster · Playwright

More from Playwright TypeScript

Playwright with TypeScript — POM, fixtures, locators.

Pillar guide · 6 articles
More in this cluster
From the Playwright pillar

Keep building your QA edge

Practice these questions live

Rehearse with an AI QA interviewer that scores your answers in real time.

Start a Free AI Mock Interview →

Continue reading

Join the QA Community

Connect with fellow testers, share job leads, and get career advice.

Premium QA Resources

Stop Reinventing the Wheel. Upgrade Your QA Arsenal.

Take your testing skills from beginner to Lead Engineer. Supercharge your daily workflow with our premium digital resources.

  • Ready-to-use testing strategy templates
  • Advanced API & UI automation guides
  • ⏱️ Save 10+ hours a week on test planning
4.9/5 rating
Explore All Products

⭐⭐⭐⭐⭐ Trusted by 1,000+ Software Test Pilots • Instant Access