Playwright Strict Mode Violation: Locator Found N Items
Count the matches, then scope from a parent or filter by content.
20+ years shipping production backend systems. Written from production experience, not tutorials.
- ✓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
- 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
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 — 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.locator.count()
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.
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.
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.
nth() for genuinely positional UI like carousel slides.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.
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.
The Redesign That Gave Every Button Three Twins in a Morning
- 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.
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.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.first() picks the wrong item after data changes.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.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.| File | Command / Code | Purpose |
|---|---|---|
| tests | test('save profile', async ({ page }) => { | What Strict Mode Violation Means |
| tests | await page.getByRole('button', { name: 'Save' }).count() // 3 | Why Your Locator Matched N Elements |
| tests | const userList = page.getByTestId('user-list') | Scoping Locators From a Stable Parent |
| tests | await page.getByRole('button', { name: 'Edit' }).nth(2).click() | filter() by Meaning, Not nth() by Position |
| tests | await page.getByTestId('save-profile-btn').click() | Test Ids |
Key takeaways
Common mistakes to avoid
5 patternsUsing page-wide locators for repeated components
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
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
.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
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'
{ name: 'Close dialog' } instead of { name: 'Close' }. Distinct names also fix the underlying accessibility bug your test just found.Interview Questions on This Topic
What does 'strict mode violation: locator resolved to N elements' mean?
Frequently Asked Questions
20+ years shipping production backend systems. Written from production experience, not tutorials.
That's Playwright. Mark it forged?
5 min read · try the examples if you haven't