Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

iTechGuides is reader-supported. When you buy through links on our site, we may earn an affiliate commission. As an Amazon Associate I earn from qualifying purchases. Learn more

Reliable Playwright tests start with locators that express what a user can recognize or what your team has deliberately declared as a testing contract. Prefer a role plus accessible name for semantic controls, use labels for form fields, scope repeated components to a meaningful parent, and finish actions with awaited web-first assertions. Playwright’s auto-waiting can handle timing and rerenders, but it cannot make an ambiguous selector correct or prove that the intended business outcome occurred.

If you are asking “How do I write reliable Playwright locators?” or “How do I avoid flaky Playwright tests?”, the practical answer is to make every locator unique for the right reason, then wait for the user-visible result of the action.

What makes a Playwright locator resilient?

A locator is a query that Playwright resolves when it needs to perform an action or assertion. It is not a permanently captured element. When a page rerenders, the next operation resolves against the current DOM, which is safer for dynamic applications than retaining an old element handle.

Free tools Windows power users keep installed

One-click scans. No signup required.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Playwright describes locators as the central piece of its auto-waiting and retry-ability. Before an action such as click(), Playwright waits for the target to be actionable under its built-in checks. A locator can therefore tolerate normal rendering delays and many component rerenders. That benefit depends on the query being meaningful and unambiguous: auto-waiting does not repair a selector that matches the wrong control or several controls.

Use four tests for every locator:

  • User-facing meaning: does it follow a role, accessible name, label, visible text, or another interface concept a person would recognize?
  • Contract stability: if it uses a test ID, has the application team intentionally promised to keep that hook stable for tests?
  • Uniqueness: does it identify exactly one intended element in the relevant region?
  • Maintenance cost: will a layout refactor or routine copy edit break it unnecessarily?

The best strategy is the one that expresses the intended target uniquely. No locator method is universally superior.

Choose locators by intent, not by convenience

Situation Preferred approach Reason
Button, link, checkbox, heading, or other semantic control getByRole(role, { name }) Matches the role and accessible name exposed to users and assistive technology.
Form field with a label getByLabel(label) Follows the field’s accessible label instead of implementation markup.
Stable, deliberate application test hook getByTestId(id) Makes the app-to-test contract explicit and independent of visible copy.
Element identified by placeholder, alt text, title, or visible text getByPlaceholder(), getByAltText(), getByTitle(), or getByText() Uses a user-facing attribute when role and label are not the right abstraction.
Repeated card, row, or component with a child action Locate the parent, filter it, then locate the child Encodes the relationship instead of relying on page-wide position.

Role locators are usually the first choice for interactive controls because they test the same semantic interface that accessibility tools consume. Include an accessible name whenever one distinguishes the target. A page-wide getByRole('button') is rarely a complete locator on a real application.

When to use a test ID

A test ID is appropriate when the team wants a deliberate testing contract that should remain stable even if visible wording, translation, or layout changes. The default attribute is data-testid. Playwright can be configured to use another attribute when your project has an established convention. A test ID should identify the intended component, not become a blanket replacement for semantic locators.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Use a test ID for a canvas-like widget, a complex composite control, or a deliberately stable state marker when no user-facing attribute describes the target well. Document ownership of the hook so a refactor does not silently remove it.

How do I avoid strict-mode failures?

Playwright locators are strict for operations that require one target. If a locator matches multiple elements, an action such as click() throws instead of guessing. This is useful feedback: the test has not expressed which element the scenario means.

Refine the accessible name

await page.getByRole('button', { name: 'Save changes' }).click();

Names can be exact or pattern-based according to your project’s needs, but they should distinguish the intended control. Avoid solving a strictness error by immediately selecting the first match.

Scope to a meaningful region

Dialogs, navigation areas, table rows, list items, and cards provide natural boundaries. First locate that region, then query within it:

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
const dialog = page.getByRole('dialog', { name: 'Delete account' });
await dialog.getByRole('button', { name: 'Confirm' }).click();

This prevents a similarly named button elsewhere on the page from satisfying the query.

Filter repeated objects by their own content

For repeated products or rows, use filter({ hasText }) or filter({ has }) on the parent. Then locate the child action inside that parent:

const product = page.getByRole('listitem').filter({ hasText: 'Product 2' });
const addToCart = product.getByRole('button', { name: 'Add to cart' });

await expect(product).toHaveCount(1);
await addToCart.click();
await expect(page.getByRole('status')).toHaveText('Added to cart');

The parent locator identifies the product, the child role and name identify its action, and the final assertion checks the user-visible outcome. The exact roles and status text must match your application’s semantics.

Use positional methods only when order is the requirement

first(), last(), and nth() can resolve an intentionally ordered collection, but they can silently target a different element after a new control is inserted. Use them only when the scenario explicitly means “the third item” or “the first result,” and protect that assumption with an assertion about count or order where useful.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

How locator chaining and filtering model component relationships

Chaining is more robust than reconstructing a long CSS or XPath path through implementation details. A parent locator can represent a domain object, while a child locator represents an operation on that object. This mirrors how a person describes the task: “In the row for invoice 1042, click Download.”

Use filter({ hasText: '...' }) when visible content identifies the parent. Use filter({ has: childLocator }) when the presence of a descendant distinguishes it. Keep the filter close to the object it describes, and assert uniqueness if duplicate content would be a defect.

const invoiceRow = page.getByRole('row').filter({ hasText: 'Invoice 1042' });
await expect(invoiceRow).toHaveCount(1);
await invoiceRow.getByRole('link', { name: 'Download' }).click();

Do not scope to a parent merely because it is convenient. Choose a region that is stable in the product’s information architecture. A generic container whose class name changes with styling provides little protection.

Wait for outcomes with web-first assertions

After an action, assert the state the user is supposed to observe. Playwright’s web-specific async matchers retry until the expected condition is met or the assertion timeout is reached. The current official Assertions guide states a default assertion timeout of 5 seconds (accessed 2026-10-13); project configuration can override it.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Good synchronization

await page.getByRole('button', { name: 'Submit' }).click();
await expect(page.getByRole('alert')).toHaveText('Order submitted');

Other useful web-first assertions include toBeVisible(), toBeHidden(), toHaveText(), toHaveValue(), toBeEnabled(), and toHaveCount(). Pick the assertion that represents the state your test actually needs.

Why fixed sleeps are a poor substitute

A fixed delay waits the same amount of time whether the application is ready or not. A short delay races the application; a long delay slows every run. An assertion tied to a meaningful UI state waits only as long as necessary and fails with a useful explanation when the state never arrives.

Separate readiness from business completion

Auto-waiting can wait for an element to be attached, visible, enabled, and otherwise actionable. It cannot prove that a save reached the server, that a calculation completed, or that a toast reports the correct result. Assert the business-facing state after the action rather than treating a successful click as proof of completion.

Understand the limits of immediate reads and enumeration

Locator APIs that read a current snapshot do not automatically wait for a future value. If synchronization matters, prefer an awaited web-first assertion over a one-time state check.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

locator.all() is especially easy to misuse. It returns the matches that exist at that moment and does not wait for a dynamic list to settle. On a list that is still loading, refreshing, or receiving items, the returned collection can be incomplete or unpredictable.

Stable enumeration pattern

  1. Locate a meaningful readiness signal, such as a loaded heading, an empty-state message, or the expected number of rows.
  2. Await an assertion for that signal.
  3. Call all() only after the list’s expected state is established.
  4. If each item has a required property, assert that property while iterating rather than assuming the snapshot is correct.
await expect(page.getByRole('heading', { name: 'Search results' })).toBeVisible();
await expect(page.getByRole('listitem')).toHaveCount(10);
const results = await page.getByRole('listitem').all();

The exact readiness condition depends on the application. Do not use a heading merely because it is easy to find if it can appear before the data is ready.

Common flaky-locator patterns and their fixes

Failure pattern Why it flakes or fails Fix
getByRole('button').click() on a page with several buttons The locator is strict and matches more than one target. Add an accessible name and, if needed, scope to a dialog, card, row, or navigation region.
first() or nth() used to silence strictness Insertion, sorting, or responsive layout changes can shift the index. Express identity with role, name, content, and scope; use position only when order is intentional.
Deep CSS or XPath tied to classes and nesting Markup and styling refactors change implementation details without changing behavior. Prefer user-facing locators, explicit test IDs, and chained relationships. Use CSS or XPath only when no clearer contract exists.
One-time state read used as synchronization The read observes the current value and does not retry for the future state. Use an awaited web-first assertion for the condition that must eventually hold.
all() while a list is changing It returns immediately and can capture a partial list. Wait for an explicit loaded or stable condition first.
ElementHandle-first code with manual waits Retained handles become stale across rerenders and duplicate synchronization logic. Keep Locator objects, act through them, and use web-first assertions. Many explicit waits are unnecessary because of auto-waiting.

Should I use getByRole or getByTestId?

Use getByRole when the role and accessible name accurately describe the control and that user-facing contract is what the test should protect. This catches regressions such as removing a label or changing a control’s semantics.

Use getByTestId when visible wording is deliberately changeable, when the element has no useful semantic representation, or when the team has defined a stable hook for a complex component. A test ID can reduce coupling to copy and localization, but it will not by itself guarantee that the selected element is unique or that the component behaves correctly.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

When both are plausible, compare the expected maintenance cost with the contract you intend to test. A role locator protects the accessible interface; a test ID protects an explicit implementation-to-test interface. Choose consciously rather than applying one method to every element.

Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Support on Ko-Fi

A practical workflow for writing and reviewing locators

  1. Describe the user task. Write the action in domain language: “Add Product 2 to the cart,” not “click the second blue button.”
  2. Identify the semantic target. Check for a role and accessible name, a field label, useful visible text, or another user-facing attribute.
  3. Define the smallest meaningful scope. Locate the dialog, row, card, list item, or region that owns the target.
  4. Filter the parent. Use text or a child locator to identify the specific repeated object.
  5. Verify uniqueness. Add toHaveCount(1) when duplicate matches would indicate a defect or an unsafe assumption.
  6. Perform the action through the locator. Avoid converting it to an element handle or adding a sleep first.
  7. Assert the outcome. Await the visible status, changed value, navigation result, enabled state, or other business-relevant condition.
  8. Review change sensitivity. Ask whether a routine copy, layout, sorting, or localization change would make the locator select a different element.

Designing application markup for testability

Locator quality is partly an application-design concern. Give interactive controls correct semantic roles and accessible names. Associate form controls with labels. Ensure repeated objects expose enough identifying content to distinguish them. For complex widgets that cannot be described reliably through user-facing attributes, add a documented test ID and keep its meaning stable.

Test IDs should be specific enough to represent a component or state, not generic hooks such as “button-1.” Avoid encoding transient indexes unless index is part of the product requirement. If the project changes the test-ID attribute from data-testid, configure Playwright consistently and document the convention for application and test authors.

Debugging a locator that still fails

  • Strictness error: inspect every match, then add the missing name, scope, or filter. Do not default to first().
  • No matches: verify the role and accessible name as rendered, check that the relevant region has loaded, and confirm the locator is evaluated after the action that creates it.
  • Intermittent wrong target: look for duplicate labels, repeated cards, responsive variants, and positional selection. Add a parent relationship and uniqueness assertion.
  • Assertion timeout: determine whether the application reached the expected state, whether the assertion targets the correct region, and whether the project’s timeout is appropriate. Increasing a timeout cannot fix a wrong locator or a missing business outcome.
  • Unstable list iteration: replace immediate all() with a readiness assertion, then enumerate.

Migration principles for older Playwright tests

When modernizing tests built around element handles, replace retained handles with Locator objects that can resolve against the current DOM. Replace manual sleeps and many explicit waits with actions followed by web-first assertions. Preserve the original business intent while improving the selector’s meaning and scope.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Migration is not a reason to convert every selector to a role locator mechanically. If a role is absent or inaccurate, fix the application semantics or use an intentional test ID. The goal is a stable contract, not a particular method name.

Frequently Asked Questions

Why is my Playwright locator strict?

The locator matches more than one element for an operation that requires one target. Add the intended accessible name, scope it to the relevant region, or filter the parent object. Use first(), last(), or nth() only when order is explicitly part of the scenario.

Does Playwright auto-waiting eliminate flaky tests?

No. Auto-waiting helps with actionability, rendering delays, and many rerenders, but it cannot resolve an ambiguous selector or verify that the application completed the intended business operation. Use a unique locator and an awaited assertion for the resulting state.

What is the default Playwright assertion timeout?

The current official Assertions guide states a default of 5 seconds, configurable by the project. Treat that as a retry window for a meaningful assertion, not as a substitute for correcting a locator or application-state problem.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

When should I call locator.all()?

Call it only after an explicit assertion establishes that a dynamic list is loaded or otherwise stable. locator.all() returns current matches immediately and does not wait for the list to settle.

Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.