
Written by: Chief Operating Officer
Anastasiia SokolinskaPosted: 30.07.2026
14 min read
Most teams treat flaky Playwright tests as a timing problem. They're not. They're a selector problem.
Your pipeline fails 30% of the time in CI, passes locally, and the default fix is bumping the timeout from 5 seconds to 15. The tests turn green, for now. Two weeks later the failure rate climbs back. You bump the timeout again. This cycle continues until someone puts a Slack message in #dev-general saying "just re-run it, CI's being weird."
That's not a CI problem. That's a selector strategy problem masquerading as an infrastructure problem. This article gives you a decision framework for choosing the right Playwright locator before you write a single line of test code — and a diagnostic lens for why your current selectors are failing specifically in CI.
Why "just use data-testid" is incomplete advice
The standard advice — add data-testid to every element and use getByTestId — is not wrong. It's just insufficient as a strategy.
The real issue is that selector stability is a design property, not a locator property. The most carefully named data-testid becomes a liability the moment a component gets renamed, split, or redesigned. And brittle CSS selectors don't just fail — they fail silently in ways that produce false positives, not just red builds.
What most guides on this topic miss: there's a relationship between your selector choices and your application's accessibility posture. Playwright's role-based locators query what the browser exposes to assistive technologies, not what the DOM happens to contain. That means a test suite built on getByRole tells you two things at once: whether the interaction works, and whether the application is accessible. You don't get that from data-testid.
The rest of this article treats selector selection as an architectural decision. Every locator type has a context where it's the right tool, and a context where it's creating hidden coupling. Knowing the difference is what separates a stable 500-test suite from one that your team has stopped trusting.
The Playwright selector hierarchy — and why the order matters
Playwright ships six core locator methods. The official docs list them without ranking them. That's the problem — in practice, they're not equal, and treating them as interchangeable is how suites accumulate selector debt.
Here's how they stack up across the dimensions that matter:
getByRole
★★★★★
Very low
Yes — queries ARIA tree
Buttons, links, inputs, headings, dialogs
getByLabel
★★★★★
Low
Yes
Form inputs with associated labels
getByPlaceholder
★★★★
Low-medium
Partial
Inputs when no label is present
getByText
★★★★
Medium
Partial
Static content, nav items
getByTestId
★★★★
Medium
No
Elements with no accessible role
getByAltText
★★★★
Low
Yes
Images
CSS selectors
★★
High
No
Last resort — third-party components
XPath
★
Very high
No
Avoid unless nothing else works
The stability score above assumes proper application markup. A component that uses a <div> instead of a <button> will score worse with getByRole than this table suggests — but that's a signal to fix the component, not to use a different locator.
getByRole — the gold standard and its edge cases
getByRole queries the ARIA accessibility tree, not the DOM. That's why it's stable: UI refactors that change class names, restructure markup, or move elements around don't invalidate a role-based locator as long as the interactive element's semantic purpose doesn't change.
// Fragile — breaks on any class rename or DOM restructure
await page.locator('.btn-primary.checkout-submit').click();
// Stable — survives refactors as long as the button's purpose doesn't change
await page.getByRole('button', { name: 'Complete purchase' }).click();
The edge case is worth knowing: custom components that don't expose correct ARIA roles return no elements or the wrong one without throwing an error by default. A <div role="button"> without tabindex or aria-label will be invisible to getByRole. When your locator silently matches zero elements, Playwright's auto-waiting logic will simply wait until the timeout expires — which looks exactly like a timing problem.
Use Playwright's Accessibility checker during test development to verify the element you're targeting is actually in the accessibility tree before you write the locator.
getByTestId — when it helps, when it creates hidden coupling
getByTestId is useful for elements with no meaningful semantic role: analytics containers, custom components that don't expose ARIA attributes, or elements in third-party UI libraries where you can't modify the markup.
The problem is naming. Most teams default to element-scoped names like data-testid="submit-button" and data-testid="user-email-input". When a component gets renamed, these attributes either get removed entirely or drift from what they describe. Feature-scoped naming is more resilient:
// Drifts on rename — tightly coupled to implementation detail
await page.getByTestId('user-profile-avatar-image').click();
// Survives component refactors — scoped to the feature, not the element
await page.getByTestId('profile-section').getByRole('img').click();
If your codebase already uses a consistent attribute other than data-testid — common with design system tokens or accessibility metadata — configure testIdAttribute in playwright.config.ts rather than adding a parallel attribute:
// playwright.config.ts
export default defineConfig({
use: {
testIdAttribute: 'data-qa',
},
});
CSS and XPath — last resort, with rules
"Never use CSS selectors" is the wrong rule. The right rule is: use them only when no accessible locator exists and you can't modify the component.
Specific legitimate cases: vendor component libraries that expose no ARIA roles, legacy server-rendered HTML with no labels, or when you need to match an element by a dynamic attribute value that isn't part of the accessibility tree.
When you do use CSS, two hard rules apply:
// Never — nth-child breaks whenever the DOM order changes
await page.locator('ul.product-list > li:nth-child(3) > button').click();
// Never — generated class hashes change on every build
await page.locator('.sc-bdVTJa.hYZFcp').click();
// Acceptable — scoped to a stable parent, targets a structural relationship
await page.locator('[data-section="cart"]').locator('button[type="submit"]').click();
XPath is a last resort behind CSS. If you find yourself writing XPath, the first question is whether the component can be updated to expose a proper ARIA role. The second question is whether the test should exist at this granularity at all.
If your Playwright suite is flaking in CI and the root cause isn't obvious, DeviQA's automation engineers will audit your selector strategy and give you a concrete fix plan, not a report full of recommendations you already know.
Book a strategic QA consultation
The real cause of CI-specific flakiness — and it's not timeouts
When tests pass locally and fail in CI, the standard diagnosis is "headless mode is different" or "CI is slower." Both are true but incomplete.
Here's what actually happens:
Font loading. Locally, your browser caches the fonts. In CI, every run fetches them fresh — or doesn't, if the CDN is unreachable. Layout shifts from font swap events can move elements by enough pixels that a locator resolves to a different element, or none at all. This doesn't produce a selector error — it produces a click-on-wrong-element failure that looks like a test logic error.
JS hydration timing. Server-side rendered frameworks like Next.js send HTML that looks fully rendered before React has hydrated the interactive elements. In a development environment with hot-reload, hydration is near-instant. In CI with a production build, it takes longer — sometimes long enough for Playwright's auto-wait to time out before onClick handlers are attached.
Selector ambiguity at scale. A locator that matches one element in a development environment with three products matches eleven elements in CI running against a production data snapshot. This is where strict mode violations surface in CI but not locally.
The wrong fix:
// Wrong — you're masking the root cause
await page.locator('.add-to-cart').click({ timeout: 15000 });
The right fix:
// Right — scope the locator, then wait for the state that matters
const productCard = page.getByRole('article', { name: 'Wireless Headphones' });
await productCard.waitFor({ state: 'visible' });
await productCard.getByRole('button', { name: 'Add to cart' }).click();
Waiting for state, not time, is the structural fix. Scoping locators to a meaningful parent is what prevents ambiguity.
Locator chaining and scoping — the underused stability lever
Pages with repeated structures — product grids, data tables, navigation menus — will produce multi-match errors with any non-scoped locator. Chaining solves this.
// Fails with strict mode violation when multiple cart items exist
await page.getByRole('button', { name: 'Remove' }).click();
// Correct — scoped to the specific cart item
await page
.getByRole('region', { name: 'Shopping cart' })
.getByRole('listitem')
.filter({ hasText: 'Wireless Headphones' })
.getByRole('button', { name: 'Remove' })
.click();
The .filter() method is one of the most underused tools in Playwright. It lets you narrow a locator that matches multiple elements down to the one that contains specific text or a specific child element — without coupling to DOM position.
.nth() is acceptable when position is genuinely meaningful (first result, most recent entry). It's a red flag when you're using it to disambiguate elements that should be distinguishable by content.
Strict mode violations — what they tell you about your selectors
When Playwright throws "strict mode violation: locator resolved to 2 elements", treat that as design feedback, not a test error. The violation means your selector isn't precise enough — and if it's not precise enough in a test, it's ambiguous enough in production that users might be interacting with the wrong element too.
Diagnosis path:
Run the locator interactively in Playwright Inspector to see every matching element
Identify what makes your target element unique — text content, parent context, ARIA label
Add that specificity to the locator through chaining or the name option
Never suppress a strict mode violation with .first() unless you've explicitly verified that any matching element is valid for the test's purpose.
A selector decision framework your whole team can follow
No competitor publishes this. Every guide lists the locator types; none gives you a decision path for which to reach for first given the specific DOM context you're looking at.
START — What element do you need to interact with or assert on?
1. Does it have a meaningful, correct ARIA role? (button, link, checkbox, dialog, etc.)
YES → getByRole('role', { name: 'accessible name' })
NO → continue
2. Is it a form input associated with a visible <label>?
YES → getByLabel('Label text')
NO → continue
3. Does it have a stable, feature-scoped data-testid (or equivalent attribute)?
YES → getByTestId('feature-element')
NO → Can you add one?
YES → add it, then getByTestId
NO → continue
4. Does it contain unique, static visible text?
YES → getByText('Exact text', { exact: true })
NO → continue
5. Is it an image?
YES → getByAltText('Alt text')
NO → continue
6. Is it a third-party component or legacy element with no accessible role and no stable attribute?
YES → CSS selector scoped to a stable parent — document why
AVOID XPath unless no CSS path exists
Print this, put it in your wiki, or build it into your PR template as a checklist. The goal is to make selector decisions explicit before the test gets committed, not during the post-mortem after a CI failure.
Measuring and tracking selector-driven flakiness
Most teams know flakiness is a problem. Few measure it. Without measurement, "fix flaky tests" stays on the backlog indefinitely because it's never urgent enough to prioritize.
Two concrete starting points:
Annotate suspected flaky tests. Playwright's annotation API lets you tag tests for reporting:
test('checkout flow completes successfully', {
annotation: { type: 'flaky', description: 'Intermittent failure on cart locator — tracked in #2341' },
}, async ({ page }) => {
// ...
});
Extract failure patterns from the JSON reporter. Run with --reporter=json and pipe the output to a script that groups failures by test name over time. A test that fails in more than 10% of runs across a two-week window is a candidate for quarantine — removed from the blocking suite until it's fixed.
The concept worth adopting: a flakiness budget. Set a threshold (10% is common, Google uses lower) above which a test gets automatically tagged as quarantined in your CI config. Quarantined tests run in a non-blocking parallel job so they don't block deploys, but they stay visible so they don't get forgotten.
If a test exceeds the flakiness budget, the first diagnostic question isn't "what's the timing issue?" — it's "what does this test's locator resolve to in CI?"
Rebuilding a test suite is the right time to get the selector strategy right from day one. DeviQA has migrated teams from Cypress to Playwright and built automation layers from scratch across fintech, healthtech, and SaaS — without the 6-month ramp-up.
Book a strategic QA consultation
Playwright codegen output: trust it selectively
npx playwright codegen is the fastest way to generate test scaffolding. It's also the fastest way to introduce brittle selectors at scale if you commit the output without reviewing it.
Here's the same interaction, codegen output vs hardened:
// Raw codegen output — fragile
await page.locator('#app > div.container > div:nth-child(2) > form > button.btn-primary').click();
await page.locator('input[name="email"]').fill('user@example.com');
// After hardening — stable
await page.getByRole('button', { name: 'Sign in' }).click();
await page.getByLabel('Email address').fill('user@example.com');
Before you commit any generated test, run this checklist:
No
nth-childor positional selectors in the outputNo auto-generated class name hashes (
.sc-,.css-,.MuiButton-)Every selector has a fallback approach if the element gets refactored
All
page.locator()with raw CSS strings have been evaluated against the decision framework aboveStrict mode passes locally — no locators that match more than one element
The VS Code Playwright extension generates better selectors than the CLI codegen when used in Pick Locator mode — it applies the same hierarchy as the decision framework above and tends to produce role-based selectors first. Use it as your primary recording tool if the CLI output is consistently poor quality.
10 selector rules for a stable Playwright suite
This is the checklist format that earns featured snippets. Each rule is actionable, not advisory.
Role first, always. If the element has a meaningful ARIA role, use getByRole. No exceptions.
Scope every locator to its nearest stable parent. Never target elements globally on a page with repeated structure.
Never use generated class name hashes. If the class name looks like .sc-bdVTJa or .css-1a2b3c, it will break on the next build.
No positional nth-child without documented intent. If you need position to identify an element, either add a data-testid or reconsider whether the test is asserting the right thing.
Use getByLabel for every form input that has a visible label. It's the most resilient form locator Playwright ships.
Name your data-testid attributes by feature, not by element. checkout-section ages better than submit-button.
Never bump a timeout to fix a flaky test. Diagnose the selector first. If the element isn't ready, wait for the state, not for arbitrary time.
Enable strict mode globally. Add use: { strictSelectors: true } in playwright.config.ts and keep it on. Strict mode violations are selectors that need to be fixed.
Put shared locators in Page Object Models. When a selector changes, you want to fix it in one place. Duplicated locators across 40 test files is how selector debt compounds silently.
Review codegen output before every commit. Treat generated selectors as a draft, not a final test.
Conclusion
Stable selectors aren't the result of picking the right locator type once. They're the result of applying a consistent decision framework across every test your team writes — and treating a strict mode violation or a CI-only failure as design feedback rather than noise to suppress.
If your Playwright suite is generating more re-runs than confidence, the fix is rarely more infrastructure. It's a selector strategy review.
Running into persistent flakiness in your Playwright suite? DeviQA's automation engineers audit test architectures and rebuild selector strategies that hold in CI. Talk to our team.
Book a strategic QA consultation

About the author
Chief Operating Officer
Anastasiia Sokolinska is the Chief Operating Officer at DeviQA, responsible for operational strategy, delivery performance, and scaling QA services for complex software products.