Skip to content
ExponaBLOG
PLAYWRIGHT & BROWSER AUTOMATION · DAY 085 / 100

Locator strict mode explained for Playwright developers

Why Playwright refuses ambiguous locators.

Expona Admin7 min read

Introduction

When you run a Playwright action on a locator that matches multiple elements, the automation fails. This isn't a bug—it's strict mode, Playwright's built-in safeguard that forces you to be explicit about which element you're targeting. Locator strict mode explained for Playwright developers matters because production automation that silently picks the wrong target leads to flaky tests, silent failures, and wasted debugging hours. We'll walk through why Playwright refuses ambiguous locators, how strictness catches bugs early, and the practical patterns to narrow down to exactly one match.

Actions require exactly one match

By default, Playwright actions like click(), fill(), select(), and check() will throw an error if your locator matches more than one element. This is intentional. In older automation frameworks, picking the first match or a random one silently meant you could be clicking the wrong button, filling the wrong input, or checking the wrong checkbox.

Consider a page with multiple buttons labeled "Submit":

typescript
await page.locator('button:has-text("Submit")').click();
// Error: locator resolved to 3 elements. Strict mode violation.

Playwright stops here. It won't guess. It tells you: "You have an ambiguous locator. Fix it." That error message is a gift—it surfaces the problem during test development, not in production when a test mysteriously passes but the wrong action was taken.

The strict mode check applies to all user actions: click(), fill(), selectOption(), check(), press(), type(), and assertion methods like toBeVisible() or toHaveText(). Read-only queries like count() or allTextContents() are exempt because they explicitly fetch multiple elements. Navigation actions like goto() bypass locators entirely.

Strictness catches wrong targets early

Strict mode acts as an early warning system. Without it, a test suite might pass locally but fail in CI, or worse, pass entirely while the wrong element was being interacted with. We've seen this in production: a team's login tests passed because the locator matched both a visible login button and a hidden one in the footer, and the hidden one happened to come first in the DOM.

Here's a realistic scenario from an e-commerce flow:

typescript
// Page has: a "Save" button in the form, a "Save" link in the nav, a "Save" button in a modal
await page.locator('button').filter({ hasText: 'Save' }).click();
// Error: locator resolved to 2 elements. Strict mode violation.

Without strict mode, you'd get an unpredictable result. With it, you're forced to be specific:

typescript
// Option 1: Target within a specific container
await page.locator('.checkout-form').locator('button').filter({ hasText: 'Save' }).click();

// Option 2: Use role-based locators (more robust)
await page.locator('main').locator('role=button[name="Save"]').click();

// Option 3: Add a data-testid if neither above works
await page.locator('[data-testid="checkout-save"]').click();

The strictness catches you before deployment. That's the whole point. It's stricter than Selenium, Cypress, or Puppeteer by default, and that's a feature, not a limitation.

Narrow with filter or a container

The two main patterns to resolve strict mode violations are: chain a filter() method or narrow the scope to a specific container. Most of the time, one of these approaches works.

Using filter()

The filter() method lets you add additional constraints to a locator:

typescript
// Find all rows, then filter to the one containing "Acme Corp"
await page.locator('tr').filter({ hasText: 'Acme Corp' }).locator('button').click();

// Filter on visibility
await page.locator('input[type="text"]').filter({ visible: true }).fill('John');

// Chain multiple filters
await page.locator('div').filter({ hasText: 'Price' }).filter({ hasText: '$50' }).click();

Filters are chainable and composable. When building a locator for a table row, for example, filter first to the row that contains the data you care about, then interact with elements within that row.

Using container scope

If your page has multiple sections (a form, a sidebar, a modal), scope your locator to that section first:

typescript
// Instead of:
// await page.locator('label').filter({ hasText: 'Email' }).locator('~ input').fill('test@example.com');

// Do this:
const form = page.locator('form[data-testid="login"]');
await form.locator('label').filter({ hasText: 'Email' }).locator('~ input').fill('test@example.com');

// Or for a modal:
const modal = page.locator('[role="dialog"]');
await modal.locator('button').filter({ hasText: 'Confirm' }).click();

Scoping to a container makes your locators both more resilient and more readable. A future developer immediately understands: "We're clicking the Confirm button inside the dialog."

Using role-based locators

Role-based locators (using the role= selector) are often the most precise because they match accessibility semantics:

typescript
// Find a button named "Submit" (exact name match, respects aria-label)
await page.locator('role=button[name="Submit"]').click();

// Find a textbox named "Email"
await page.locator('role=textbox[name="Email"]').fill('user@example.com');

// Find the third button in the page (usually avoidable, but sometimes necessary)
await page.locator('role=button').nth(2).click();

Role-based locators match the accessibility tree, which is what screen readers see. They're often less brittle than CSS selectors because they ignore visual changes and class shuffling.

Use first() only when order is meaningful

Playwright provides first() and last() methods as explicit ways to pick an element by position. Use them only when the order is semantically meaningful to your test—for example, selecting the first item in a sorted list where order matters for the business logic.

Here's when first() is appropriate:

typescript
// Test: most recent order appears first in the list
const orders = page.locator('[data-testid="order-row"]');
await expect(orders.first()).toContainText('Order #5001');

// Test: first tab in a tab bar is selected by default
await page.locator('role=tab').first().click();
await expect(page.locator('role=tabpanel')).toBeVisible();

Here's when first() is a code smell:

typescript
// Bad: Why are there multiple? You're hiding the real problem.
await page.locator('button').filter({ hasText: 'Next' }).first().click();

// Better: Scope to the component that should have exactly one "Next" button.
await page.locator('[data-testid="pagination"]').locator('button').filter({ hasText: 'Next' }).click();

If you find yourself writing .first() to silence a strict mode error, stop. That's a sign your locator is still too broad. Go back and refine it. The goal is a locator that matches exactly one element, not one that matches many and you pick the first one.

Common mistakes

Mistake 1: Relying on text selectors without narrowing scope.

typescript
// Risky: "Delete" appears on many buttons across the page
await page.locator('button').filter({ hasText: 'Delete' }).click();

// Better: Scope to a specific row or section
await page.locator('tr').filter({ hasText: 'John Doe' }).locator('button').filter({ hasText: 'Delete' }).click();

Mistake 2: Using overly generic CSS or XPath selectors.

typescript
// Risky: Matches any button on the page
await page.locator('button').click();

// Better: Add specificity
await page.locator('button[data-testid="primary-action"]').click();

Mistake 3: Assuming strict mode is a limitation instead of a feature.

Some teams disable strict mode to make old tests "pass." This is a mistake. Strict mode exists to catch bugs. If your locators are ambiguous, your tests are fragile. Fix the locators, not the framework.

Mistake 4: Using .first() as a band-aid.

typescript
// Antipattern: Silently picks the first match
await page.locator('input').first().fill('username');

// Better: Make the locator specific
await page.locator('input[placeholder="Username"]').fill('username');

Key takeaways

  • Strict mode enforces exactly one match for actions. This is a feature, not a bug. It catches wrong targets during development, not in production.
  • Use filter() to add constraints: filter by text, visibility, or other attributes to narrow to a single element.
  • Scope locators to containers (forms, modals, sections) to isolate your target and make tests more readable.
  • Prefer role-based locators when possible; they're more resilient and semantically meaningful.
  • Reserve first() and last() for cases where order is semantically important. If you're using them to silence strict mode errors, refactor your locator instead.
  • Disabling strict mode trades short-term convenience for long-term pain. Keep it enabled and fix your locators.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles