ScreenshotNeo

BlogGuides

Playwright Locators: How to Find Elements in Tests

Choose reliable Playwright locators with roles, labels, text, test IDs, and scoped filters. Learn strictness, auto-waiting, troubleshooting, and runnable examples.

By the ScreenshotNeo team4 October 20269 min read

To find an element in a Playwright test, start with what a user or assistive technology can identify: use getByRole() with an accessible name for buttons and links, getByLabel() for labeled form controls, and getByText() for visible non-interactive content. When several elements match, scope the locator to the relevant row or card. Use a test ID when you need an explicit testing hook. Playwright locators auto-wait for readiness during actions, but they cannot tell whether you chose the right target.

This guide uses JavaScript with Playwright’s test runner. The locator concepts also apply to Playwright in other supported languages; see the official locator guide for language-specific APIs.

1. Start with a user-facing locator

A locator describes how to find an element. Prefer a locator that expresses the control’s purpose, since it is easier to understand and can make the test check the same role or name users depend on.

import { test, expect } from '@playwright/test';

test('signs in', async ({ page }) => {
  await page.goto('https://example.com/login');

  await page.getByLabel('Email').fill('dev@example.com');
  await page.getByLabel('Password').fill('correct-horse-battery-staple');
  await page.getByRole('button', { name: 'Sign in' }).click();

  await expect(page.getByRole('heading', { name: 'Account' })).toBeVisible();
});

The example is runnable once @playwright/test is installed and the URL and expected page content match your application. Locators are evaluated when used, so a locator can be created before the matching element is attached to the page.

Choose by purpose

Locator Good fit Example
getByRole(role, options) Interactive controls and semantic content such as headings page.getByRole('button', { name: 'Save' })
getByLabel(text) Form controls associated with a visible or accessible label page.getByLabel('Password')
getByText(text) Visible non-interactive text page.getByText('Changes saved')
getByPlaceholder(text) An input whose meaningful identifying contract is its placeholder page.getByPlaceholder('Search')
getByAltText(text) An image identified by its alternative text page.getByAltText('Company logo')
getByTitle(text) An element identified by its title attribute page.getByTitle('Close')
getByTestId(value) A deliberate, stable test hook page.getByTestId('checkout-submit')

Use a role locator for a button instead of searching for its text alone. Text can appear in multiple places, and a text match does not express that the target must be a button. Use exact matching where partial text could be ambiguous:

const saveButton = page.getByRole('button', {
  name: 'Save',
  exact: true,
});
await saveButton.click();

Text matching normalizes whitespace. Exact matching is available when you need the whole text to match. For form controls, a label usually makes a more useful contract than placeholder text, because a placeholder may be absent or change independently of the field’s purpose.

2. Narrow repeated matches with scope and filters

Pages commonly repeat the same action, such as one “Add to cart” button per product. First locate the container using meaningful content, then find the action within that container. A filter’s inner locator is evaluated relative to each candidate outer match.

const product = page
  .getByRole('listitem')
  .filter({ hasText: 'Product 2' });

await product.getByRole('button', { name: 'Add to cart' }).click();

You can also filter a container using a child locator:

const featuredProduct = page
  .getByRole('listitem')
  .filter({ has: page.getByRole('heading', { name: 'Product 2' }) });

await featuredProduct.getByRole('button', { name: 'Add to cart' }).click();

Use a container that reflects the page’s content, such as a list item, article, or named region. Avoid broad text filters if the same words may appear in unrelated descendants. After composing the locator, make sure the final action still resolves to one intended element.

3. Use test IDs as an explicit testing contract

A test ID is useful when a target has no meaningful accessible name, or when the team wants a stable hook independent of copy. By default, Playwright’s getByTestId() looks for data-testid.

// Application markup
<button data-testid="checkout-submit">Place order</button>

// Playwright test
await page.getByTestId('checkout-submit').click();

To use a different attribute, configure the test ID attribute in Playwright:

// playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  use: {
    testIdAttribute: 'data-pw',
  },
});

Then use the same locator API:

await page.getByTestId('checkout-submit').click();

A test ID does not check that a control has the expected role, accessible name, or user-facing text. If those semantics matter to the test, use a role or label locator, or assert the semantics separately.

4. CSS, XPath, and positional locators

Playwright supports CSS and XPath through page.locator(). These are useful when the target has no suitable user-facing locator or test hook, but selectors tightly coupled to class names, ancestry, or position can break after implementation changes.

// A concise CSS selector for a specific attribute
await page.locator('[data-state="open"]').click();

// XPath is supported, though a semantic locator may be clearer
await page.locator('xpath=//button[@type="submit"]').click();

Prefer a semantic locator or explicit test ID when it conveys the intended target more clearly. Avoid long selectors that encode incidental layout details.

first(), last(), and nth(index) can select by position. Use them only when order is itself part of the requirement and is stable. For example, selecting the first result is appropriate only if the test is specifically verifying first-result behavior. Otherwise identify the result by its content and scope the action within it.

5. Strictness and auto-waiting

Actions that require one target, such as click(), are strict: they fail if the locator matches multiple elements. This protects against silently acting on an arbitrary match. Refine the locator by role and name or by scoping it to the right container.

Playwright’s documentation describes locators as “the central piece of Playwright’s auto-waiting and retry-ability.” For a click, Playwright waits for the target to resolve uniquely and be visible, stable, enabled, and able to receive events. If those checks do not pass before the timeout, the action times out. Auto-waiting checks readiness; it does not validate that the test selected the correct business target.

const submit = page.getByRole('button', { name: 'Submit order' });
await submit.click(); // waits for required actionability checks

For assertions, use Playwright’s retrying web-first assertions rather than reading a value once and checking it immediately:

await expect(page.getByRole('status')).toHaveText('Order submitted');

Do not add an arbitrary sleep to compensate for a locator timeout. Check whether the target exists, is visible and enabled, is unobscured, and is uniquely identified. If the page has a known asynchronous state, wait for that state using a locator or assertion that represents it.

6. Generate and review locators with codegen

Playwright’s code generator can inspect a page and propose locators. Its best-practices guidance says it prioritizes role, text, and test IDs. Treat generated code as a starting point: verify that the locator expresses the intended target and uniquely identifies it in the state under test.

npx playwright codegen https://example.com

Codegen can help discover accessible names and candidate selectors, but it cannot decide which page contract your test should protect. Review selectors after UI changes and prefer clear intent over the shortest generated expression.

7. Troubleshooting locator failures

Symptom Likely cause Fix
Strict mode violation A single-target action matched more than one element. Add a role and accessible name, use exact matching when appropriate, or scope to the relevant row or card.
Timeout waiting for action The target was missing, hidden, disabled, unstable, obscured, or not uniquely matched before the timeout. Inspect the page state and locator matches; wait for the meaningful UI state, and fix the selector or obstruction rather than adding a blind delay.
Locator stopped working after a redesign It depended on CSS classes, ancestry, or other implementation details that changed. Use a user-facing role, label, text, or a maintained test ID contract.
Text locator targets the wrong thing The same text appears in several places or belongs to a control whose role was not specified. For an interactive element, locate it by role and accessible name; scope it if repeated.
nth() now selects a different item List ordering changed, so the positional assumption no longer identifies the same content. Find the item by identifying text or a stable test hook, then locate the action inside it.
Test ID lookup finds nothing The markup uses a different attribute, the configured attribute does not match, or the hook is absent in this page state. Check the rendered markup and testIdAttribute configuration; use the agreed attribute consistently.

8. Keep locator tests reliable and efficient

  • Make intent visible. Name locators after the element or content they represent, and use roles and names that describe the expected behavior.
  • Keep targeting specific. Resolve ambiguity with semantic names and container scope, not an arbitrary index.
  • Wait for state, not time. Locator actions and web-first assertions retry against changing page state; fixed sleeps add delay without proving readiness.
  • Use hooks deliberately. Keep test IDs stable and limited to targets where a testing contract is useful. Decide with the application team whether changes to user-facing copy should fail the test.
  • Consider runtime costs. A locator does not require a separate browser query at declaration time; it is resolved when an operation uses it. Excessive fixed waits and repeated page setup can increase suite runtime, while meaningful locators and assertions keep synchronization tied to actual page state.

There is no locator strategy that guarantees a test cannot become flaky. Reliability depends on identifying the right target and synchronizing with the state the test cares about. Role and name are strong defaults for interactive behavior; test IDs provide explicit hooks; CSS and XPath are available when the page’s semantics do not give a suitable target.

9. Or skip the browser setup

If your goal is to inspect a page visually rather than interact with it in a browser test, ScreenshotNeo provides a website screenshot API and MCP server. A single GET request returns a PNG, JPEG, WebP, or PDF. See the API documentation for options.

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests

r = requests.get(
    "https://api.screenshotneo.com/v1/shot",
    params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"},
    timeout=90,
)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot request failed: ${res.status}`);
await import('node:fs/promises').then(fs => fs.writeFile('shot.webp', Buffer.from(await res.arrayBuffer())));

ScreenshotNeo accepts cookie and consent banners like a visitor and removes more than 60 known consent platforms, newsletter popups, and chat widgets before capture; each step can be turned off. Bot checks, blank pages, failed loads, timeouts, and cache hits are not billed, and response headers report the page verdict and billing status. Its MCP server provides take_screenshot, get_page_info, and capture_pdf tools for Claude, Cursor, and other MCP clients. The free plan includes 1,000 shots per month with no card; paid plans start at $5 for 3,000 shots.

Sign up free for 1,000 screenshots a month, no card required.

10. FAQ

How do I select a button by text or role?

Use page.getByRole('button', { name: 'Button name' }). This targets a button by its accessible name and confirms the expected role.

Should I use role locators or test IDs?

Use role locators when the test should identify the control as users do. Use a test ID when an explicit stable hook is more appropriate and user-facing semantics are not the contract being tested.

Can I create a locator before the element appears?

Yes. Locators are resolved when an operation uses them, so they can describe elements that are attached later. Actions and assertions then apply their waiting behavior.

When is nth() acceptable?

When position itself is meaningful to the behavior under test and the ordering is an intentional, stable part of that behavior. Otherwise identify the desired item by content or a test hook.

Primary sources