Skip to content
Independent guides for QA & test automationRSSEditorial policy
QA Vibes

Playwright Strict Mode Violation: Why a Locator Resolved to 2 Elements, and How to Fix It

Playwright's strict mode violation, reproduced four ways on Sauce Demo and this site's practice shop: six identical buttons, two equal prices, a substring match, and a hidden alert added by Next.js. How to read the error, why it fails immediately instead of retrying, how to fix each case, and why .first() is usually the wrong fix.

QA Vibes EditorialPublished 9 minTested with Playwright 1.63.0, Chromium 153Revision history ↓

Key takeaways

  • A strict mode violation means a locator you used for one element matched several. Playwright refuses to guess which one you meant.
  • It fails immediately. In our test, an assertion failed after 38 ms even though the extra element would have gone 2 seconds later, so wait for the page you expect before asserting on it.
  • getByText and getByRole names match substrings and ignore case by default: getByText("T-Shirt") matched four elements on Sauce Demo, two of them product descriptions.
  • Fix the locator, not the symptom: scope it to the item you mean with filter(), or use exact: true. .first() makes the error go away and the test depend on the page's order.
  • Frameworks add elements of their own. Next.js keeps a hidden role="alert" route announcer on every page, so getByRole("alert") matched two elements on our own shop.
Contents (8 sections)

Introduction

"Error: strict mode violation: getByRole('alert') resolved to 2 elements" is the error Playwright gives when a locator you used to act on, or assert on, one element matches more than one. Playwright doesn't pick one for you. It stops and lists every element it found.

This page shows what the message tells you, four real ways to get it, and the fixes that hold up. Everything was run on 8 October 2026 with Playwright 1.63.0 and Chromium 153, against Sauce Demo and this site's own practice shop. The examples are TypeScript; the locators and the strictness rule are the same in Playwright's other languages.

What the error tells you

Here is one of the errors from our run, in full:

Error: expect(locator).toBeVisible() failed
 
Locator: getByText('$15.99')
Expected: visible
Error: strict mode violation: getByText('$15.99') resolved to 2 elements:
    1) <div class="inventory_item_price" data-test="inventory-item-price">$15.99</div> aka getByTestId('inventory-item-price').nth(2)
    2) <div class="inventory_item_price" data-test="inventory-item-price">$15.99</div> aka getByTestId('inventory-item-price').nth(5)

Read it in three parts:

  • The call that failed, here expect(locator).toBeVisible(). For an action it reads locator.click or locator.fill.
  • The locator and the count: getByText('$15.99') resolved to 2 elements. The count is the clue: two means a near-duplicate, six means you matched a whole list.
  • Every element it found, with its HTML and an aka locator Playwright suggests for that one element. The HTML is usually enough to see why both matched. Treat the aka suggestions with care: .nth(2) here means "the third price on the page", which stops being the Bolt T-Shirt as soon as the list is sorted.

Four ways to get it

This spec logs in to Sauce Demo and makes three of the most common mistakes. Sauce Demo marks its elements with data-test, so the config sets testIdAttribute: "data-test" for getByTestId:

import { defineConfig } from "@playwright/test";
 
export default defineConfig({
  testDir: "./tests",
  use: { testIdAttribute: "data-test" },
  reporter: "list",
});

Save the spec as tests/broken.spec.ts:

import { test, expect } from "@playwright/test";
 
// Sauce Demo publishes this password on its own login page.
const PASSWORD = process.env.SAUCE_PASSWORD ?? "secret_sauce";
 
test.beforeEach(async ({ page }) => {
  await page.goto("https://www.saucedemo.com/");
  await page.getByPlaceholder("Username").fill("standard_user");
  await page.getByPlaceholder("Password").fill(PASSWORD);
  await page.getByRole("button", { name: "Login" }).click();
});
 
test("adds a product to the cart", async ({ page }) => {
  await page.getByRole("button", { name: "Add to cart" }).click();
  await expect(page.getByTestId("shopping-cart-badge")).toHaveText("1");
});
 
test("shows the Bolt T-Shirt's price", async ({ page }) => {
  await expect(page.getByText("$15.99")).toBeVisible();
});
 
test("lists the T-Shirt", async ({ page }) => {
  await expect(page.getByText("T-Shirt")).toHaveText("Sauce Labs Bolt T-Shirt");
});

The fourth runs against this site's practice shop, as tests/announcer.spec.ts. It fills the checkout form with nothing and checks the error:

import { test, expect } from "@playwright/test";
 
// The practice shop shows this password on its own login page.
const PASSWORD = process.env.PRACTICE_PASSWORD ?? "practice-only";
 
test("names the missing first name", async ({ page }) => {
  await page.goto("https://qavibes.com/practice/shop");
  await page.getByLabel("Username").fill("shopper");
  await page.getByLabel("Password").fill(PASSWORD);
  await page.getByRole("button", { name: "Log in" }).click();
  await page.getByRole("listitem").filter({ hasText: "Assertion Mug" }).getByRole("button", { name: "Add to cart" }).click();
  await page.getByRole("link", { name: /^Cart/ }).click();
  await page.getByRole("button", { name: "Checkout" }).click();
  await page.getByRole("button", { name: "Continue" }).click();
  await expect(page.getByRole("alert")).toHaveText("Error: First name is required");
});

All four failed:

Running 4 tests using 1 worker
 
  x  1 tests\announcer.spec.ts:6:5 › names the missing first name (1.7s)
  x  2 tests\broken.spec.ts:13:5 › adds a product to the cart (1.4s)
  x  3 tests\broken.spec.ts:18:5 › shows the Bolt T-Shirt's price (1.1s)
  x  4 tests\broken.spec.ts:22:5 › lists the T-Shirt (1.2s)

1. A whole list: six "Add to cart" buttons

Every product on Sauce Demo has its own "Add to cart" button with the same name, so the first test's locator matched all of them:

Error: locator.click: Error: strict mode violation: getByRole('button', { name: 'Add to cart' }) resolved to 6 elements:
    1) <button id="add-to-cart-sauce-labs-backpack" name="add-to-cart-sauce-labs-backpack" data-test="add-to-cart-sauce-labs-backpack" class="btn btn_primary btn_small btn_inventory ">Add to cart</button> aka getByTestId('add-to-cart-sauce-labs-backpack')
    2) <button id="add-to-cart-sauce-labs-bike-light" name="add-to-cart-sauce-labs-bike-light" data-test="add-to-cart-sauce-labs-bike-light" class="btn btn_primary btn_small btn_inventory ">Add to cart</button> aka getByTestId('add-to-cart-sauce-labs-bike-light')

(Four more buttons follow, one per product.) The test never said which product it wanted, and that's the real bug: "adds a product" isn't a check of anything in particular.

2. Two equal values: two prices of $15.99

The Bolt T-Shirt and the Test.allTheThings() T-Shirt (Red) both cost $15.99, so getByText("$15.99") found two. This is the "resolved to 2 elements" case people search for most, and it usually means the text you chose isn't unique on the page, even though it was unique in your head.

3. A substring match: four "T-Shirt"s

getByText("T-Shirt") found four elements, and only two of them were product names:

Error: strict mode violation: getByText('T-Shirt') resolved to 4 elements:
    1) <div class="inventory_item_name " data-test="inventory-item-name">Sauce Labs Bolt T-Shirt</div> aka getByTestId('item-1-title-link')
    2) <div class="inventory_item_desc" data-test="inventory-item-desc">Get your testing superhero on with the Sauce Labs…</div> aka getByText('Get your testing superhero on')
    3) <div class="inventory_item_name " data-test="inventory-item-name">Test.allTheThings() T-Shirt (Red)</div> aka getByTestId('item-3-title-link')
    4) <div class="inventory_item_desc" data-test="inventory-item-desc">This classic Sauce Labs t-shirt is perfect to wea…</div> aka getByText('This classic Sauce Labs t-')

By default, getByText and the name option of getByRole match any element whose text contains the string, ignoring case. Element 4 says "t-shirt" in lower case and still matched. exact: true turns that off.

4. An element the framework added: Next.js's route announcer

The practice shop's form shows exactly one error, yet getByRole("alert") found two:

Error: strict mode violation: getByRole('alert') resolved to 2 elements:
    1) <p role="alert" data-testid="error" class="border-l-[3px] border-fail bg-paper-2 px-3 py-2 font-sans text-sm text-ink">Error: First name is required</p> aka locator('[data-testid="error"]')
    2) <div role="alert" aria-live="assertive" id="__next-route-announcer__">Checkout: your details</div> aka locator('[id="__next-route-announcer__"]')

The second element isn't ours. The shop is a Next.js app, and Next.js puts a hidden div with role="alert" on every page, to announce the new page title to screen readers after a client-side navigation: here "Checkout: your details". It is there even when it has nothing to say. After opening the checkout URL directly, the announcer was empty and getByRole("alert") still resolved to 2 elements. So on a Next.js app, getByRole("alert") on its own matches both your alert and the announcer whenever your alert is showing. We first hit this in our own practice-lab tests. Other parts of a page can do the same: toast and notification components often use role="alert" or role="status" too.

It fails at once, not after the timeout

Look at the durations above: each test failed in under two seconds, login included, although Playwright waits up to 5 seconds for an assertion by default. To check, we made a page with two alerts and a script that removes one of them after 2 seconds:

  • expect(page.getByRole("alert")).toHaveText(...) failed after 38 ms.
  • page.getByRole("button", { name: "Add to cart" }).click() against two such buttons failed after 10 ms.

Neither waited for the page to settle. A strict mode violation isn't a "not found yet" that Playwright retries; it ends the action or assertion immediately. That matters most just after a navigation. Our first version of the .first() test below clicked the cart link and at once checked getByTestId("inventory-item-name"). The products page, with six names, was still on screen, and the assertion failed with "resolved to 6 elements". Waiting for the cart URL first fixed it:

await page.getByTestId("shopping-cart-link").click();
await expect(page).toHaveURL(/cart\.html$/);
await expect(page.getByTestId("inventory-item-name")).toHaveText("Sauce Labs Onesie");

So when a strict mode violation lists elements from the page you just left, the locator is fine; the test asserted too early.

How to fix it

Each fix makes the locator say which element you mean. Here is the fixed spec, tests/fixed.spec.ts:

import { test, expect } from "@playwright/test";
 
// Sauce Demo publishes this password on its own login page.
const PASSWORD = process.env.SAUCE_PASSWORD ?? "secret_sauce";
 
test.beforeEach(async ({ page }) => {
  await page.goto("https://www.saucedemo.com/");
  await page.getByPlaceholder("Username").fill("standard_user");
  await page.getByPlaceholder("Password").fill(PASSWORD);
  await page.getByRole("button", { name: "Login" }).click();
});
 
test("adds the backpack to the cart", async ({ page }) => {
  const backpack = page.getByTestId("inventory-item").filter({ hasText: "Sauce Labs Backpack" });
  await backpack.getByRole("button", { name: "Add to cart" }).click();
  await expect(page.getByTestId("shopping-cart-badge")).toHaveText("1");
  await expect(backpack.getByRole("button", { name: "Remove" })).toBeVisible();
});
 
test("shows the Bolt T-Shirt's price", async ({ page }) => {
  const shirt = page.getByTestId("inventory-item").filter({ hasText: "Sauce Labs Bolt T-Shirt" });
  await expect(shirt.getByTestId("inventory-item-price")).toHaveText("$15.99");
});
 
test("lists the T-Shirt", async ({ page }) => {
  await expect(page.getByText("Sauce Labs Bolt T-Shirt", { exact: true })).toBeVisible();
});
 
test("checks every price at once, on purpose", async ({ page }) => {
  await expect(page.getByRole("button", { name: "Add to cart" })).toHaveCount(6);
  await expect(page.getByTestId("inventory-item-price")).toHaveText(["$29.99", "$9.99", "$15.99", "$49.99", "$7.99", "$15.99"]);
});
 
test(".first() clicks whatever is first, not the backpack", async ({ page }) => {
  await page.getByTestId("product-sort-container").selectOption("lohi");
  await page.getByRole("button", { name: "Add to cart" }).first().click();
  await page.getByTestId("shopping-cart-link").click();
  await expect(page).toHaveURL(/cart\.html$/);
  await expect(page.getByTestId("inventory-item-name")).toHaveText("Sauce Labs Onesie");
});

In tests/announcer.spec.ts, only the last line changes:

await expect(page.getByRole("alert").filter({ hasText: "Error:" })).toHaveText("Error: First name is required");

Our run:

Running 6 tests using 1 worker
 
  ok 1 tests\announcer.spec.ts:6:5 › names the missing first name (1.8s)
  ok 2 tests\fixed.spec.ts:13:5 › adds the backpack to the cart (1.4s)
  ok 3 tests\fixed.spec.ts:20:5 › shows the Bolt T-Shirt's price (999ms)
  ok 4 tests\fixed.spec.ts:25:5 › lists the T-Shirt (1.1s)
  ok 5 tests\fixed.spec.ts:29:5 › checks every price at once, on purpose (1.2s)
  ok 6 tests\fixed.spec.ts:34:5 › .first() clicks whatever is first, not the backpack (1.2s)
 
  6 passed (9.1s)

We ran it three times; all six passed each time. What each fix does:

  • Scope to the item you mean. getByTestId("inventory-item").filter({ hasText: "Sauce Labs Backpack" }) finds the one product card, and the button inside it is unique. This is the fix for repeated lists: buttons per row, links per card, prices per product. The same pattern works with getByRole("listitem") or getByRole("row") on pages without test ids, as in the practice shop test.
  • Match the whole text. exact: true on getByText, or on getByRole's name, matches the full string and respects case, so "Sauce Labs Bolt T-Shirt" no longer catches a description that mentions it.
  • Filter out what the framework added. getByRole("alert").filter({ hasText: "Error:" }) keeps the shop's message and drops the route announcer. A test id on your own error element works too, if your app has one.
  • Say when you mean many. Strictness only applies when a call needs one element. toHaveCount(6) and toHaveText([...]) with an array are made for lists, and so are count() and all(). If you meant "all six buttons exist", say that instead of clicking one.

Why .first() is the wrong fix

.first(), .last() and .nth() make the error go away by telling Playwright to take one element by position. The last test shows the cost: sorted by price, low to high, the first "Add to cart" button belongs to the Sauce Labs Onesie, not the Backpack the test was probably written for. The test still passes, because nothing in it says which product it meant.

Use a position when the position is the point, such as "the first row is the newest order". Otherwise it hides the question the error was asking: which one?

Exercise

On Sauce Demo's cart page, add two products and write a test that removes only the Bike Light. Before you run it, predict what page.getByRole("button", { name: "Remove" }).click() does with two items in the cart, and what message you'll get. Then fix it with filter() instead of .nth().

To practise on locators where the page fights back, the practice lab runs your tests against a shop with seeded bugs, and the Playwright test reviewer checks a spec for the other common mistakes, such as fixed waits and assertions that can't fail.

Conclusion

A strict mode violation is Playwright asking which element you meant. The count and the list in the message usually answer it: a whole list means the locator needs scoping, two near-identical elements mean the text isn't unique, unexpected elements mean a substring match, and an element you didn't write means the framework added one. Fix the locator so it names one thing, use count and array assertions when you mean many, and remember that the error doesn't wait. If the elements it lists belong to the previous page, wait for the new one first.

Sources and further reading

Tools mentioned

PlaywrightUI AutomationOpen source

Links go to each tool’s official site. How we choose and link tools

Revision history

First published.

Spotted a mistake? Report it — corrections land here.

Written and reviewed by

QA Vibes Editorial

Articles are written and reviewed by practicing QA and automation engineers. Every article lists its sources and shows when it was last updated.

Check your own tests

Playwright test reviewer

Paste a test to find fixed waits, missing awaits, and assertions that can never fail.