Home › Testing › Playwright Strict Mode Violation: Locator Found N Items
Intermediate 5 min · September 23, 2026

Playwright Strict Mode Violation: Locator Found N Items

Count the matches, then scope from a parent or filter by content.

N
Naren Founder & Principal Engineer

20+ years shipping production backend systems. Written from production experience, not tutorials.

Follow
✓ Production
production tested
September 27, 2026
last updated
2,085
articles · all by Naren
Before you start⏱ 10 min
  • ✓A Playwright Test project with locator-based specs you can edit
  • ✓Basic HTML and accessibility-role concepts (button, dialog, heading)
  • ✓Ability to add data-testid attributes to your app's components
 ● Production Incident 🔎 Debug Guide
⚡Quick Answer
  • Strict mode throws when a locator resolves to N elements because actions require exactly one — Playwright refuses to guess which you meant
  • Count the matches first: the extras (duplicate rows, hidden dialogs, lingering toasts) dictate the fix
  • Scope from a stable parent, filter by content with .filter({ hasText }), or target a unique getByTestId
  • Avoid positional .nth() on sortable data — select by meaning so reordering cannot retarget the test
✦ Definition~90s read
What is Playwright Strict Mode Violation?

Strict mode is Playwright's rule that actions and assertions on a locator require exactly one matching element. When your locator matches two or more, Playwright throws a strict mode violation naming the resolved count rather than operating on an arbitrary match.

★
Imagine shouting 'hey, Alex!' in a room with three Alexes — all three turn around, and you cannot hand your package to 'Alex' without pointing.

Zero matches still retry until the timeout; multiple matches fail fast, because waiting cannot resolve ambiguity — only a better locator can.

The duplicates come from the DOM's hidden population: repeated component instances (every table row's Edit button), shared labels across forms, dialogs, and toasts (Save everywhere), responsive twins and animated leftovers that persist invisibly. Small test fixtures conceal them — one seeded row means one match — until realistic data or a redesign multiplies the matches and the suite turns red in a morning.

The toolkit has four instruments in escalating strength. Scoping chains locators from stable parent regions so only one subtree is searched. Filters (hasText, has) select by content or structure so meaning, not position, decides. first() and nth() select by position for genuinely positional UI.

And data-testid hooks, read by getByTestId(), give elements unique identities immune to copy, translation, and class churn. Used together — scope, then filter, anchored on test ids — they reduce every violation to a one-element locator that says exactly what the user means.

Plain-English First

Imagine shouting 'hey, Alex!' in a room with three Alexes — all three turn around, and you cannot hand your package to 'Alex' without pointing. Strict mode is Playwright refusing to hand the package to a random Alex. Scoping and filters are how you point: 'Alex in the blue shirt' (filter by content) or 'Alex at this table' (scope to a parent). Test ids are name tags — no pointing needed.

You wrote page.getByRole('button', { name: 'Save' }), the page clearly shows a Save button, and Playwright refuses to click it — because it found three. One in the form, one in a confirmation dialog, one in a toast from the previous test that has not dismissed yet. Strict mode violation: your locator resolved to 3 elements, and Playwright will not gamble on which one you meant.

This refusal is a gift disguised as an error. Every other automation tool would have clicked the first match — possibly the toast action — and your test would have passed or failed for reasons unrelated to the feature. Strict mode converts silent wrong-element clicks into loud, precise complaints naming the count. The error is not telling you Playwright is broken; it is telling you your description of the element fits several of them.

This article turns that complaint into a locator skill set. You will learn to count matches before acting, to scope from stable parents, to filter by content and structure, and to invest in test ids where ambiguity concentrates. Strict mode stops being the error you dread and becomes the reviewer that never lets an ambiguous locator ship.

What Strict Mode Violation Means

Strict mode is Playwright's contract with you: any action (click, fill, check) or assertion on a locator requires that locator to resolve to exactly one element. Zero matches retries until timeout; two or more matches throws immediately with the resolved count. The rule exists because clicking 'an' element when three match is a coin flip with side effects — submitting the wrong form, closing the wrong dialog, deleting the wrong row.

The extras hide in places humans never look. Dialogs closed with animation linger in the DOM. Toasts from previous steps overlap the next test's elements. Responsive layouts render mobile and desktop variants simultaneously, hiding one with CSS. Dropdowns and tooltips pre-render their items. None of these are visible together, yet all of them count — strict mode evaluates the DOM tree, not the screenshot.

So the error message is a precise description, not a complaint: 'resolved to 3 elements' tells you the ambiguity's size. Your first move is always the count probe — await locator.count() — followed by identifying the extras in the DOM. Siblings rows, hidden dialogs, lingering toasts: each species has its own fix, and misidentifying the species wastes the afternoon. Diagnose the N before treating it.

tests/strict-violation.spec.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// Strict mode: exactly one match, or it throws
import { test, expect } from '@playwright/test'

test('save profile', async ({ page }) => {
  await page.goto('/profile')
  // If 'Save' matches the form button AND a dialog button AND a toast:
  await page.getByRole('button', { name: 'Save' }).click()
  // ^ Error: strict mode violation: "getByRole('button', ...)"
  //   resolved to 3 elements.
})

// Diagnose first: count before you fix
test('diagnose', async ({ page }) => {
  await page.goto('/profile')
  const n = await page.getByRole('button', { name: 'Save' }).count()
  console.log('Save matches:', n) // 3 -> now find the extras
})
Try it live
📊 Production Insight
Teams that read 'resolved to N' as a measurement instead of an insult fix violations in minutes. The number is the diagnosis.
🎯 Key Takeaway
Strict mode counts the tree, not the screenshot — diagnose the N with a count probe before fixing.

Why Your Locator Matched N Elements

Duplication has predictable sources, and naming them speeds every fix. Repeated components top the list: lists, tables, and cards render the same buttons per item, so any page-wide locator for 'Edit' matches every row the moment the second row loads. Shared labels come next: design systems reuse Save, Close, Delete, and Confirm across forms, dialogs, and toasts until the words mean nothing to a query. Layout twins — responsive variants, pre-rendered menus, animated leftovers — supply the invisible extras.

Small fixtures hide all of this. One seeded row renders one Edit button, the count reads 1, and the test ships — then production-shaped data renders forty rows and strict mode reports forty matches. Tests that only pass on tiny data are testing the fixture, not the feature. Develop locators against realistic datasets from the start, or schedule a periodic run against full-size seeds specifically to shake out ambiguity.

The deeper lesson is that ambiguity is information. When a locator matches three Saves, it is reporting that three Saves genuinely coexist — a human disambiguates by context (the visible form, the open dialog), but the test has no eyes. Your fix supplies the context the human uses unconsciously: which region (scope), which content (filter), which exact control (test id). Each fix makes the test's intent as explicit as the user's perception.

tests/why-n-matches.spec.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
// Same label, three owners: form, dialog, toast
// <button>Save</button>              <- the profile form
// <div role="dialog"><button>Save</button></div>  <- hidden confirm dialog
// <div role="status"><button>Save</button></div>  <- lingering toast action

// Why page-wide text locators break:
await page.getByRole('button', { name: 'Save' }).count() // 3

// Even CSS falls into the trap:
await page.locator('button.primary').count() // 2 (form + dialog share classes)

// Visibility filters help but don't cure:
// visible buttons still collide when dialog AND form show together
Try it live
📊 Production Insight
Strict violations cluster after redesigns and data growth. Run locator checks against realistic datasets, not minimal seeds.
🎯 Key Takeaway
Ambiguity is real information about the DOM — supply the context (region, content, identity) a human uses unconsciously.

Scoping Locators From a Stable Parent

Chained locators are the primary cure because they mirror how users parse pages: within this list, the row for Ada, its Edit button. page.getByTestId('user-list').getByRole('button', { name: 'Edit' }) searches only inside the list subtree, so identical buttons elsewhere stop matching. Each chain level discards a class of duplicates — region first, then content, then control — until exactly one remains.

Row-first scoping reads best in tables: locate the row by its accessible name (which includes its cell text), then find the button inside it. The test now says what the user does — 'in Ada's row, click Edit' — instead of describing global coordinates. Dialog scoping works identically: anchor on the dialog's name, then act within it, and the background page's twins vanish from the match set.

Keep scopes anchored to stable hooks. A scope on a text heading that marketing rewrites quarterly just moves the fragility one level up; a scope on a data-testid region survives copy changes, translations, and refactors. When no hook exists, adding one attribute to the container fixes every locator scoped beneath it — the highest-leverage single edit in this article. Scope deliberately and the count probe stays at one.

tests/scoped-locators.spec.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Scope from a stable parent: search within, not across
// <section data-testid="user-list"> ... 40 rows ... </section>
const userList = page.getByTestId('user-list')
await userList.getByRole('button', { name: 'Edit' })
  .filter({ hasText: 'Ada' })
  .click()

// Scope to the row first, then act inside it (clearest for tables)
const row = page.getByRole('row', { name: /Ada.*lovelace/i })
await row.getByRole('button', { name: 'Edit' }).click()

// Parent scoping defeats dialog/toast twins too:
const dialog = page.getByRole('dialog', { name: 'Confirm delete' })
await dialog.getByRole('button', { name: 'Delete' }).click()
Try it live
📊 Production Insight
One test id on a container stabilizes every locator beneath it. Scope anchors are infrastructure worth investing in.
🎯 Key Takeaway
Chain region, then content, then control — each level discards a class of duplicates.

filter() by Meaning, Not nth() by Position

Filters select by meaning while positions select by accident of order. .filter({ hasText: 'Ada' }) keeps matching Ada's control through sorting, filtering, pagination, and seed changes — the test describes intent ('Ada's Edit button') rather than coordinates ('the third Edit button'). .filter({ has: innerLocator }) extends the same idea to structure: the card containing the admin badge, the row containing the warning icon. Meaning survives data churn; indices do not.

This makes .nth() the riskiest fix for a violation. It silences strict mode today by blessing one position, then clicks the wrong item the first time order shifts — a silent wrong action, exactly what strict mode exists to prevent. Reaching for nth() converts a loud precise error into a quiet incorrect test, the worst possible trade. Use it only where position is the semantics: carousel slides, tab order, ranked lists where rank is the feature.

.first() sits in the middle: safe for true singletons (the top toast, the first search suggestion) and dangerous anywhere multiplicity is possible. The test is simple — ask whether a second match could ever legitimately appear. If yes, filter by meaning. Strict mode rewards this honesty: locators that say what they mean keep passing while the page evolves around them.

tests/filter-not-nth.spec.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// Filter by meaning: which one, not which number
// BAD: passes until the list order changes
await page.getByRole('button', { name: 'Edit' }).nth(2).click()

// GOOD: selects by content — survives sorting and filtering
await page.getByRole('button', { name: 'Edit' })
  .filter({ hasText: 'Ada' })
  .click()

// GOOD: selects by structure — the card containing an avatar
await page.getByTestId('user-card')
  .filter({ has: page.getByTestId('admin-badge') })
  .getByRole('button', { name: 'Edit' })
  .click()

// first() is fine for stable singletons (dismiss the top toast)
await page.getByRole('status').first().waitFor({ state: 'detached' })
Try it live
⚠ Positions Rot, Meanings Endure
nth() encodes today's order as tomorrow's assumption. Sorting, filtering, pagination, and new seed data all silently retarget positional picks — with no error, just the wrong row. Reserve nth() for genuinely positional UI like carousel slides.
📊 Production Insight
nth() on sortable data is a wrong-click bug with a timer. Code review should challenge every positional pick.
🎯 Key Takeaway
Filter by content or structure; reserve nth() for UI where position is the actual semantics.

Test Ids: Hooks That Survive Refactors

The data-testid attribute exists for exactly this problem: a hook whose only job is identifying the element to tests. Unlike text (rewritten by marketing, translated by i18n) or classes (churned by design systems), test ids change only when the element's purpose changes — at which point the test should break loudly anyway. One attribute per interactive element in repeated components ends ambiguity at its source.

Playwright's getByTestId() reads data-testid by default and a custom attribute via testIdAttribute if your codebase already uses one (data-qa, data-cy). Standardize on a single attribute org-wide so every team writes locators the same way; mixed conventions (data-testid here, data-test there) recreate the discovery problem test ids were meant to solve.

Sell test ids as infrastructure, not test charity. They stabilize automation, give accessibility tooling extra anchors, and make refactors safer for everyone — renaming a class no longer risks breaking tests nobody remembers. Teams that treat test-id coverage like type coverage (expected on interactive elements, checked in review) watch strict violations fade to a rare, genuinely informative event. Unique hooks make ambiguity a choice, not an accident.

tests/testid-locators.spec.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
// Test ids: unique hooks that survive refactors
// Component (React example — one attribute, permanent stability):
// <button data-testid="save-profile-btn">Save</button>
// <button data-testid="confirm-delete-btn">Delete</button>
// <ul data-testid="user-list">...</ul>

// Spec: unambiguous by construction
await page.getByTestId('save-profile-btn').click()
await expect(page.getByTestId('save-profile-btn')).toHaveCount(1)

// Custom test id attribute (playwright.config.js):
// use: { testIdAttribute: 'data-qa' }
// then: page.getByTestId('save-profile-btn') reads data-qa instead
Try it live
📊 Production Insight
Test-id coverage is infrastructure with compounding returns: stable tests, safer refactors, and happier accessibility audits.
🎯 Key Takeaway
One data-testid per interactive element ends ambiguity at its source and survives every refactor.

Roles and Accessible Names Users Would Use

Roles and accessible names are Playwright's preferred locators because they describe what users perceive: getByRole('button', { name: 'Delete' }) finds the control a screen reader announces as Delete. When these collide — three 'Close' buttons across dialogs — the test is reporting a genuine usability defect: screen-reader users face the same ambiguity. Fixing the name fixes both audiences at once.

Give every control a distinct, purposeful name: 'Close dialog', 'Close menu', 'Dismiss notification' instead of three 'Close's. Distinct names make locators unique by construction while improving the accessibility tree — the rare change where test stability and inclusive design are literally the same diff. Accessibility audits and strict-mode errors start agreeing with each other, and both get fixed faster.

Build the locator review into team habit. Every new spec answers three questions in review: does the count probe read 1 on realistic data, is the scope anchored to a stable region, and are positions avoided on sortable content? Three questions, thirty seconds, and the violation category stays empty. Strict mode then does what it was designed for — catching the occasional real ambiguity — instead of screaming about habits the team never fixed.

📊 Production Insight
When tests and accessibility audits report the same defect, fix it once in the component and collect the win twice.
🎯 Key Takeaway
Distinct accessible names fix tests and screen readers together — review locators for uniqueness like any other quality gate.
● Production incidentPOST-MORTEMseverity: high

The Redesign That Gave Every Button Three Twins in a Morning

Symptom
Sixty specs failed within one release with 'resolved to 2 (or 3) elements' errors, all on button clicks. Manual testing passed everything — humans disambiguate by context effortlessly, so the duplicates were invisible outside automation.
Assumption
The team assumed 'the Save button' was unambiguous because each screen shows only one at a time to a human. Nobody accounted for hidden dialogs, lingering toasts, and responsive-layout duplicates coexisting in the DOM — visible one at a time, present all at once.
Root cause
The redesign reused labels like Save, Close, and Edit across forms, dialogs, and toasts. Page-wide locators that previously matched one element now matched two or three — including hidden dialog buttons still in the DOM. Strict mode correctly refused every ambiguous action, turning a labeling decision into suite-wide red.
Fix
They added data-testid attributes to all dialog buttons and toast actions, scoped list operations from row containers, and added locator review to the PR checklist. Strict violations dropped to zero, and as a bonus the duplicate-name cleanup fixed three real screen-reader complaints from an accessibility audit the same quarter.
Key lesson
  • The DOM contains more than the eye sees — hidden, animating, and duplicate nodes all count toward N. Test against the tree, not the screenshot.
  • Ambiguous locators are latent wrong-click bugs; strict mode surfaces them before users feel them. Treat each violation as a prevented incident.
  • Test ids are accessibility-adjacent infrastructure: unique hooks help assistive tech, tests, and future refactors simultaneously.
Production debug guideFive checks that turn an N-element complaint into a one-element locator.5 entries
Symptom · 01
Strict mode violation with resolved to N elements
→
Fix
Insert a count probe before the failing line: console.log(await page.locator('your-selector').count()). If it prints N > 1, list what the extras are (rows? dialogs? toasts?). The identity of the extras dictates the fix: sibling rows need parent scoping, stray dialogs need visibility filtering or dismissal, toasts need test isolation.
Symptom · 02
Locator matches repeated components across the page
→
Fix
Rewrite as a chained locator from the nearest stable container: page.getByTestId('user-list').getByRole('button', { name: 'Edit' }). Re-run the count probe on the chained version — it should read 1. Prefer containers with test ids so the scope survives refactors.
Symptom · 03
nth() or first() picks the wrong item after data changes
→
Fix
Add .filter({ hasText: 'Ada' }) (or filter({ has: ... }) for structure) to select by meaning, then verify with the count probe across different data sets — sorted, filtered, and full-size. If positions shift but the filter still reads 1, the fix is durable.
Symptom · 04
Same text appears in form, dialog, and toast at once
→
Fix
Add data-testid to the component (one attribute, permanent fix) and switch to page.getByTestId(...). Confirm count is 1 and re-run the full spec file — text collisions elsewhere on the page no longer matter.
Symptom · 05
Vague names like Close or Edit collide across controls
→
Fix
Give each control a distinct accessible name ('Close dialog', 'Close menu') in the component, then target the precise name. Verify with a screen-reader pass or the accessibility tree snapshot — the test fix and the a11y fix are the same change.
Strict Mode Violations — Root Cause vs Fix at a Glance
Root CauseHow to ConfirmFixPrevention
Page-wide locator hits repeated componentsCount probe returns N > 1; duplicates are sibling rows or dialogsScope from a stable parent containerChain locators from test-id'd regions by default
Non-unique text matched across the pageSame label in header, dialog, and toast simultaneouslyAdd a unique data-testid and use getByTestIdReserve text locators for genuinely unique copy
Positional pick on a reorderable listPasses on seed data, wrong item after sort/filterFilter by content with .filter({ hasText })Select by meaning (content), never by position
Vague accessible name shared by many controlsSeveral 'Close' or 'Edit' buttons match at onceUse distinct accessible names per controlTreat name collisions as accessibility bugs
⚙ Quick Reference
5 commands from this guide
FileCommand / CodePurpose
testsstrict-violation.spec.jstest('save profile', async ({ page }) => {What Strict Mode Violation Means
testswhy-n-matches.spec.jsawait page.getByRole('button', { name: 'Save' }).count() // 3Why Your Locator Matched N Elements
testsscoped-locators.spec.jsconst userList = page.getByTestId('user-list')Scoping Locators From a Stable Parent
testsfilter-not-nth.spec.jsawait page.getByRole('button', { name: 'Edit' }).nth(2).click()filter() by Meaning, Not nth() by Position
teststestid-locators.spec.jsawait page.getByTestId('save-profile-btn').click()Test Ids

Key takeaways

1
Strict mode refuses N-element matches so tests never silently click the wrong element.
2
Count matches first
the extras reveal whether the fix is scoping, filtering, or test ids.
3
Chain locators from stable parents; page-wide queries invite duplicates.
4
Filter by meaning (hasText, has), not by position (nth) on reorderable data.
5
getByTestId gives unique, refactor-proof hooks for repeated interactive elements.
6
Duplicate accessible names are accessibility bugs too
fix the name, help everyone.

Common mistakes to avoid

5 patterns
×

Using page-wide locators for repeated components

Symptom
A button locator matches every row's button in a list — strict mode fires the moment the second row renders, even though each button looks unique to a human.
Fix
Scope from a stable parent: page.getByTestId('user-list').getByRole('button', { name: 'Edit' }). Chained locators search within the parent's subtree, so duplicate buttons elsewhere stop matching.
×

Relying on text that appears in several places

Symptom
'Save' matches the form button, the dialog button, and the toast action — the test breaks whenever marketing adds another Save anywhere on the page.
Fix
Give the element data-testid="save-user-btn" and use page.getByTestId('save-user-btn'). One unique hook ends the ambiguity permanently, surviving refactors that shuffle text and classes.
×

Grabbing positionally with nth() on shifting lists

Symptom
.nth(2) passes until sorting, filtering, or a new seed row reorders the list — then it confidently clicks the wrong item with no error.
Fix
Chain .filter({ hasText: 'Ada' }) or .filter({ has: page.getByTestId('avatar') }) to select by row content or structure. Filters express which one you mean instead of hoping positions never change.
×

Assuming a locator is unique without checking

Symptom
Tests pass on small fixtures and explode on realistic data where duplicates genuinely exist — strict mode discovers the ambiguity production data always had.
Fix
Assert the count first (await expect(locator).toHaveCount(1)) during development so ambiguity surfaces as a clear failure, then scope properly. Never ship a locator you have not counted.
×

Using vague accessible names like 'Close' or 'Edit'

Symptom
Every dialog's close button and every row's edit link collide — and screen-reader users suffer the same ambiguity your test reports.
Fix
Match the accessible name users hear: { name: 'Close dialog' } instead of { name: 'Close' }. Distinct names also fix the underlying accessibility bug your test just found.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
What does 'strict mode violation: locator resolved to N elements' mean?
Q02JUNIOR
What is getByTestId and when do you prefer it?
Q03SENIOR
How do you click the Edit button in the row for user 'Ada'?
Q04SENIOR
Does using .first() fully solve a strict mode violation?
Q05SENIOR
How do you stop strict violations across a team and codebase?
Q01 of 05JUNIOR

What does 'strict mode violation: locator resolved to N elements' mean?

ANSWER
The locator matched N elements (N > 1) where the action required exactly one. Playwright refuses to guess which element you meant. I would count the matches, inspect what the extras are, then scope the locator — from a parent, with a filter, or via a unique test id.
FAQ · 6 QUESTIONS

Frequently Asked Questions

01
What is Playwright strict mode?
02
Can I turn strict mode off?
03
first() vs nth() vs filter() — which should I use?
04
How do filter({ hasText }) and filter({ has }) differ?
05
My app has no test ids and I cannot change the markup. What now?
06
Does strict mode apply inside shadow DOM?
N
Naren Founder & Principal Engineer

20+ years shipping production backend systems. Written from production experience, not tutorials.

Follow
✓ Verified
production tested
September 27, 2026
last updated
2,085
articles · all by Naren
🔥

That's Playwright. Mark it forged?

5 min read · try the examples if you haven't

←
Previous
Playwright Test Timeout of 30000ms Exceeded
2 / 3 · Playwright
Next
Playwright Browser Has Been Closed Mid-Test
→