{"title":"Debug Playwright Strict-Mode Locator Failures","excerpt":"Strict mode is Playwright refusing to guess which element you meant. The error already lists the candidates and suggests a fix for each. Here is the order to work through, and why an index is almost never the right answer.","canonicalUrl":"https://automationtester.in/blog/automation-tutorials/debug-playwright-strict-mode-locator-failures","category":{"name":"Automation Tutorials","slug":"automation-tutorials"},"tags":["playwright","locators","test-automation","debugging","accessibility"],"author":{"name":"Shashank Rawlani","url":"https://shashank.rawlani.com","linkedin":"https://www.linkedin.com/in/shashankrawlani/"},"datePublished":"2026-09-02T13:30:00.000Z","dateModified":"2026-09-03T20:05:56.899Z","coverImage":{"url":"https://ik.imagekit.io/automationtester/automationtester-in/blog/covers/v2/debug-playwright-strict-mode-locator-failures.webp","alt":"Abstract diagram in which one locator query fans out to three identical matching controls and is rejected, then resolves to a single match once it is scoped inside a container boundary."},"contentHtml":"<div class=\"callout callout-info\"><strong>Quick answer:</strong> Read the matched-element list Playwright prints in the error, then rebuild the locator from the accessible name or the owning container. Reach for <code>filter()</code> and chaining before <code>first()</code>, <code>nth()</code>, or a test id — and treat <code>nth()</code> as a last resort the docs themselves discourage.</div>\n\n<p>Strict mode is not an obstacle Playwright puts in your way. It is the framework refusing to guess which of several elements you meant, at the one moment when guessing would be cheapest and most dangerous. The fix is almost never to pick an index. It is to say which element you meant in the same terms a user would.</p>\n\n<h2 id=\"what-the-error-actually-says\">What the error actually says</h2>\n\n<p>A strict-mode failure prints the matched candidates. Most of the debugging information you need is already on your screen:</p>\n\n<pre class=\"language-text\"><code class=\"language-text\">Error: strict mode violation: getByRole('button', { name: 'Save' }) resolved to 3 elements:\n    1) &lt;button type=\"submit\" class=\"btn-primary\"&gt;Save&lt;/button&gt; aka getByRole('form', { name: 'Profile' }).getByRole('button')\n    2) &lt;button type=\"submit\" class=\"btn-primary\"&gt;Save&lt;/button&gt; aka getByRole('form', { name: 'Notifications' }).getByRole('button')\n    3) &lt;button type=\"button\" hidden class=\"btn-primary\"&gt;Save&lt;/button&gt; aka getByTestId('row-template').getByRole('button')</code></pre>\n\n<p>Three things are worth noticing before you change a line of code.</p>\n\n<p>First, Playwright suggests a disambiguating locator for each candidate on the <code>aka</code> line. That suggestion is usually the answer, and it is usually a container scope rather than an index.</p>\n\n<p>Second, candidate 3 is <code>hidden</code>. A hidden row template matching your locator is a different bug from two real forms matching it, and it wants a different fix.</p>\n\n<p>Third, candidates 1 and 2 are genuinely indistinguishable by their own markup. Two buttons with the same accessible name, same role, and same classes. If your test cannot tell them apart from the accessibility tree, neither can a screen-reader user tabbing through the page. That is worth saying out loud in the pull request, because it is a product finding, not a test finding.</p>\n\n<h2 id=\"which-operations-enforce-strictness\">Which operations enforce strictness</h2>\n\n<p>Strictness applies to operations that must resolve to exactly one DOM element. Actions like <code>click()</code>, <code>fill()</code>, and <code>press()</code> throw when the locator matches more than one. Operations that are inherently about sets do not:</p>\n\n<pre class=\"language-ts\"><code class=\"language-ts\">const saveButtons = page.getByRole('button', { name: 'Save' });\n\nawait saveButtons.count();        // fine - 3\nawait saveButtons.all();          // fine - three Locator handles\nawait expect(saveButtons).toHaveCount(2);  // fine - asserts on the set\n\nawait saveButtons.click();        // throws: strict mode violation</code></pre>\n\n<p>This is why <code>count()</code> is such a useful debugging probe. You can measure the ambiguity before and after a change without triggering the failure you are investigating.</p>\n\n<h2 id=\"the-order-to-try-fixes-in\">The order to try fixes in</h2>\n\n<p>Work down this list. Each step is more specific than the last, and the first four keep the locator anchored to something a user can perceive.</p>\n\n<h3 id=\"1-add-the-accessible-name\">1. Add the accessible name</h3>\n\n<p>The single most common cause is a role locator without a name. <code>getByRole('button')</code> matches every button on the page; it is a category, not an element.</p>\n\n<pre class=\"language-ts\"><code class=\"language-ts\">// Ambiguous: matches every button\npage.getByRole('button');\n\n// Specific: role plus accessible name\npage.getByRole('button', { name: 'Save profile' });\n\n// Exact match when one name is a substring of another\n// (by default 'Save' also matches 'Save and close')\npage.getByRole('button', { name: 'Save', exact: true });\n\n// Anchored but case-insensitive: pass a regular expression\npage.getByRole('button', { name: /^save$/i });</code></pre>\n\n<p>The <code>exact</code> option is worth understanding precisely, because the default is more permissive than most people expect. With <code>exact</code> unset, <code>name</code> matching is case-insensitive and matches a <em>substring</em> — so <code>{ name: 'Save' }</code> matches \"Save and close\" too. Setting <code>exact: true</code> makes it case-sensitive and whole-string. Passing a regular expression ignores <code>exact</code> entirely, which is how you get anchoring and case-insensitivity together. Whitespace is normalised in every case: runs of spaces collapse, newlines become spaces, and leading and trailing whitespace is ignored.</p>\n\n<h3 id=\"2-scope-to-the-owning-container\">2. Scope to the owning container</h3>\n\n<p>When two controls are legitimately identical because they belong to two different regions, name the region. This is the fix that matches how a user actually disambiguates them — by looking at which form they are in.</p>\n\n<pre class=\"language-ts\"><code class=\"language-ts\">const profileForm = page.getByRole('form', { name: 'Profile' });\nawait profileForm.getByRole('button', { name: 'Save' }).click();\n\n// The same idea for a table row, which is where this comes up most often\nawait page\n  .getByRole('row', { name: 'ada@example.com' })\n  .getByRole('button', { name: 'Revoke access' })\n  .click();</code></pre>\n\n<p>Chaining is the workhorse here. Each link narrows the search to descendants of the previous match, so the final locator stays readable and survives layout changes that an index-based locator would not.</p>\n\n<h3 id=\"3-filter-by-content-or-descendant\">3. Filter by content or descendant</h3>\n\n<p>When the container has no accessible name to grab, filter the set by something inside it. <code>filter()</code> accepts <code>hasText</code>, <code>hasNotText</code>, <code>has</code>, and <code>hasNot</code>:</p>\n\n<pre class=\"language-ts\"><code class=\"language-ts\">// By text somewhere inside the element, including descendants\nawait page.getByRole('listitem').filter({ hasText: 'Orange' }).click();\n\n// By the presence of a descendant matching another locator\nawait page\n  .getByRole('listitem')\n  .filter({ has: page.getByTestId('sale-badge') })\n  .getByRole('button', { name: 'Add to cart' })\n  .click();\n\n// By the absence of one - useful for skipping archived rows\nconst activeRows = page.getByRole('row').filter({ hasNot: page.getByText('Archived') });\nawait expect(activeRows).toHaveCount(2);</code></pre>\n\n<p><code>hasText</code> matches a substring anywhere inside the element, case-insensitively. That is convenient and occasionally too permissive; pass a regular expression when you need to anchor it.</p>\n\n<h3 id=\"4-filter-out-the-elements-a-user-cannot-see\">4. Filter out the elements a user cannot see</h3>\n\n<p>Candidate 3 in our error was a hidden row template. Component libraries, virtualised lists, and carousels routinely keep offscreen or hidden copies in the DOM. If the duplicates are genuinely invisible:</p>\n\n<pre class=\"language-ts\"><code class=\"language-ts\">await page.getByRole('button', { name: 'Save' }).filter({ visible: true }).click();</code></pre>\n\n<p>Use this deliberately rather than reflexively. It is the right fix for a hidden template. It is the wrong fix for a modal that is open when it should not be, because it will hide that bug rather than surface it.</p>\n\n<h3 id=\"5-a-test-id-when-the-ui-is-genuinely-ambiguous\">5. A test id, when the UI is genuinely ambiguous</h3>\n\n<p>If two controls are indistinguishable to assistive technology and you cannot change that today, a test id is a legitimate, explicit escape hatch:</p>\n\n<pre class=\"language-ts\"><code class=\"language-ts\">await page.getByTestId('profile-save').click();</code></pre>\n\n<p>Prefer adding the test id to the <em>container</em> rather than the control, so the locator still reads as \"the save button inside the profile card\" and still breaks if that button disappears:</p>\n\n<pre class=\"language-ts\"><code class=\"language-ts\">await page.getByTestId('profile-card').getByRole('button', { name: 'Save' }).click();</code></pre>\n\n<p>When you do this, file the accessible-naming gap. A test id resolves the test; it does not resolve the ambiguity for the person using a screen reader.</p>\n\n<h3 id=\"6-last-resort-positional-selection\">6. Last resort: positional selection</h3>\n\n<p>Playwright's documentation is unusually blunt about this. You can opt out of strictness with <code>first()</code>, <code>last()</code>, and <code>nth()</code>, but the docs state these \"are not recommended because when your page changes, Playwright may click on an element you did not intend.\"</p>\n\n<p>There is a narrow case where positional selection is honest: when order is part of the specification and you assert that order.</p>\n\n<pre class=\"language-ts\"><code class=\"language-ts\">const rows = page.getByRole('row').filter({ hasNot: page.getByRole('columnheader') });\n\n// The order is the thing under test, so state it\nawait expect(rows).toHaveCount(3);\nawait expect(rows.nth(0)).toContainText('Most recent');\n\nawait rows.nth(0).getByRole('button', { name: 'Open' }).click();</code></pre>\n\n<p>The difference between this and a drive-by <code>.first()</code> is the assertion above it. If sort order changes, this test fails with a message about ordering instead of silently operating on the wrong row.</p>\n\n<h2 id=\"assert-uniqueness-before-you-act\">Assert uniqueness before you act</h2>\n\n<p>The cheapest way to keep a locator honest over time is to state its cardinality where you define it. This converts a future strict-mode violation — which surfaces at the click, with a stack trace pointing at an action — into a clear assertion failure pointing at the locator.</p>\n\n<pre class=\"language-ts\"><code class=\"language-ts\">const saveProfile = page\n  .getByRole('form', { name: 'Profile' })\n  .getByRole('button', { name: 'Save' });\n\nawait expect(saveProfile).toHaveCount(1);\nawait saveProfile.click();</code></pre>\n\n<p>This is also the before-and-after evidence to attach to the change. Record the match count from the failing locator and the match count from the replacement. Two numbers, both reproducible, both meaningful to a reviewer who was not involved in the debugging.</p>\n\n<h2 id=\"causes-worth-recognising-on-sight\">Causes worth recognising on sight</h2>\n\n<h3 id=\"or-matching-both-branches\"><code>or()</code> matching both branches</h3>\n\n<p>The <code>or()</code> combinator is a frequent and surprising source of strict-mode violations. It is designed for \"whichever of these appears\" — but if both appear, it matches both:</p>\n\n<pre class=\"language-ts\"><code class=\"language-ts\">const newEmail = page.getByRole('button', { name: 'New' });\nconst dialog = page.getByText('Confirm security settings');\n\n// Throws if the dialog and the button are both on screen\nawait expect(newEmail.or(dialog)).toBeVisible();\n\n// The documented fix for this specific case\nawait expect(newEmail.or(dialog).first()).toBeVisible();</code></pre>\n\n<p>This is the one place where <code>first()</code> is the documented answer rather than a shortcut, because the intent really is \"either of these, I do not care which.\"</p>\n\n<h3 id=\"portals-and-stacked-modals\">Portals and stacked modals</h3>\n\n<p>Dialogs rendered through a portal land at the end of <code>&lt;body&gt;</code>, outside the DOM subtree you scoped to. A closed-but-not-unmounted modal from a previous step then matches your locator alongside the live one. Scope to <code>getByRole('dialog')</code> and assert there is exactly one before interacting with its contents.</p>\n\n<h3 id=\"duplicate-landmarks\">Duplicate landmarks</h3>\n\n<p>A page with two <code>&lt;nav&gt;</code> elements and no <code>aria-label</code> on either gives you two <code>navigation</code> roles that nothing can tell apart. The test fix and the accessibility fix are the same edit: label the landmarks.</p>\n\n<h2 id=\"use-the-trace-not-guesswork\">Use the trace, not guesswork</h2>\n\n<p>Rather than iterating on selector guesses, let the tooling enumerate the candidates for you:</p>\n\n<pre class=\"language-bash\"><code class=\"language-bash\"># Step through with the inspector and try locators against the live DOM\nnpx playwright test tests/profile.spec.ts --debug\n\n# Record a locator by pointing at the element\nnpx playwright codegen https://example.com\n\n# Open the trace from a CI failure and read the DOM snapshot at the failing step\nnpx playwright show-trace trace.zip</code></pre>\n\n<p>In the trace viewer, the DOM snapshot at the failing action is authoritative in a way that a local reproduction is not — it is the actual page state in the environment where it failed. When a strict-mode violation only happens in CI, that snapshot almost always shows a second element that never renders locally.</p>\n\n<h2 id=\"where-ai-helps-and-where-it-does-not\">Where AI helps, and where it does not</h2>\n\n<p>A model is good at reading a printed candidate list and proposing a scoped replacement locator. That is a pattern-matching task over text you already have, and it is a genuine time-saver.</p>\n\n<p>It is not able to tell you which of two identical Save buttons is the one your test means. That is a question about the product's intent, and the answer lives with the person who wrote the requirement. Accepting a model's guess here produces a test that passes and protects nothing — the most expensive failure mode in a suite, because it is invisible.</p>\n\n<p>A workable boundary: let the model propose, and let <code>toHaveCount(1)</code> plus a human reviewer decide. If a proposed locator cannot be justified in one sentence that mentions the user's task, it is not ready to merge.</p>\n\n<h2 id=\"apply-this-now\">Apply this now</h2>\n\n<p>Take one strict-mode violation in your suite. Record the match count of the current locator. Rebuild it using the highest step on the list above that works — accessible name, then container scope, then filter. Record the new match count, assert it with <code>toHaveCount(1)</code>, and put both numbers in the pull request.</p>\n\n<p>If the only fix that worked was <code>nth()</code>, that is a finding rather than a failure. Write down what made the two elements indistinguishable, and route it to whoever owns that component.</p>\n\n<h2 id=\"frequently-asked-questions\">Frequently asked questions</h2>\n\n<h3 id=\"faq-disable\">Can I turn strict mode off?</h3>\n\n<p>Not globally, and that is deliberate. Strictness is per-operation and you opt out one locator at a time with <code>first()</code>, <code>last()</code>, or <code>nth()</code>. A global switch would convert every future ambiguity into a silent wrong-element interaction.</p>\n\n<h3 id=\"faq-count\">Why does <code>count()</code> work when <code>click()</code> throws?</h3>\n\n<p>Playwright distinguishes operations that need exactly one element from operations that are about a set. <code>count()</code>, <code>all()</code>, and <code>toHaveCount()</code> are set operations, so multiple matches are expected rather than ambiguous.</p>\n\n<h3 id=\"faq-ci-only\">Why does it only fail in CI?</h3>\n\n<p>Usually a second element renders there and not locally: a cookie banner, a feature flag defaulting differently, a slower load leaving a skeleton row mounted, or seeded data producing two rows where your local database has one. Read the DOM snapshot in the trace rather than reproducing locally.</p>\n\n<h3 id=\"faq-testid\">Is <code>getByTestId</code> bad practice?</h3>\n\n<p>No, but it is a different trade. It is stable and explicit, and it is invisible to users, so it cannot tell you when a control has become unreachable by name. Use it when the UI is genuinely ambiguous, and log the naming gap when you do.</p>\n\n<h2 id=\"primary-references\">Primary references</h2>\n\n<ul>\n<li><a href=\"https://playwright.dev/docs/locators\" target=\"_blank\" rel=\"noopener noreferrer\">Playwright — Locators</a>: strictness, the recommended locator order, filtering, and the explicit caution against <code>first()</code>, <code>last()</code>, and <code>nth()</code></li>\n<li><a href=\"https://playwright.dev/docs/api/class-locator\" target=\"_blank\" rel=\"noopener noreferrer\">Playwright — Locator API</a>: exact signatures for <code>filter()</code>, <code>and()</code>, <code>or()</code>, <code>count()</code>, and <code>all()</code></li>\n<li><a href=\"https://playwright.dev/docs/other-locators\" target=\"_blank\" rel=\"noopener noreferrer\">Playwright — Other locators</a>: when CSS and XPath are appropriate, and their trade-offs</li>\n<li><a href=\"https://playwright.dev/docs/trace-viewer\" target=\"_blank\" rel=\"noopener noreferrer\">Playwright — Trace viewer</a>: reading DOM snapshots from a failing CI run</li>\n</ul>\n"}