Playwright foundations · Locator index

Playwright Locator Lab

Choose resilient Playwright locators from user-visible contracts, then practise semantic scoping, filtering, and intentional position selectors.

PRACTICE FIXTURE — intentionally built for automation practice

Locator quality determines whether a browser test describes product behavior or merely survives today’s markup. Playwright locators retry while the page changes, enforce strictness when an action should target one element, and combine selection with web-first assertions. Those capabilities work best when the DOM exposes the same names and relationships a user perceives.

Begin with accessible roles and names for interactive controls. Labels are the strongest field contract. Visible text, alternative text, titles, and placeholders each fit narrower content types. Test IDs and CSS remain useful when a technical state has no honest user-facing selector, but their presence should be deliberate rather than automatic.

Repeated content needs context. Filter a meaningful collection by a child or visible text, or enter a named region before finding its action. Position helpers communicate order-sensitive behavior; they should not conceal duplicate or missing identity. The paired examples below keep awkward markup visible so you can compare the cost of each choice.

getByRole

Role locators follow the accessibility tree and reflect how people understand controls. Add an accessible name to distinguish similar roles. This is the strongest default for buttons, links, headings, form controls, dialogs, and status regions.

Good DOM

Awkward DOM Intentional

Create report

getByText

Text locators suit non-interactive copy and unique visible outcomes. Use exact matching when nearby text overlaps. Avoid using body-wide text as a shortcut for an interactive control that already has a meaningful role.

Good DOM

Payment method updated

Awkward DOM Intentional

Updated
Updated

getByLabel

A real label creates a durable relationship between field name and control. It improves click targets and accessibility at the same time. Missing or visually nearby text is not an equivalent relationship.

Good DOM

Awkward DOM Intentional

Email address

getByPlaceholder

Placeholder locators can be useful when the product genuinely exposes a unique prompt, but placeholders disappear during typing and are not labels. Treat them as a fallback or as a deliberate exercise requirement.

Good DOM

Awkward DOM Intentional

getByAltText

Alternative text identifies meaningful images and image controls. Write alt text for purpose rather than file appearance. Decorative images should use an empty alt and should not be targeted as meaningful content.

Good DOM

Testheon Practice Lab mark

Awkward DOM Intentional

getByTitle

Title locators cover elements whose title attribute is part of the observable contract. Since titles are often hidden until hover and unavailable on touch, prefer visible names when you control the markup.

Good DOM

API

Awkward DOM Intentional

ii

getByTestId

Test IDs are a deliberate escape hatch when user-facing semantics cannot identify a stable technical target. Keep them rare, meaningful, and documented; they should not replace an accessible name that users need.

Good DOM

Build 4803 complete

Awkward DOM Intentional

locator() with CSS

CSS is useful for structural or state assertions that have no user-facing equivalent. Prefer a stable attribute owned by the fixture. Long descendant chains couple tests to styling and incidental wrappers.

Good DOM

Release candidate

Awkward DOM Intentional

Ready

filter({ hasText, has })

Filtering starts with a meaningful collection and narrows it by visible text or a descendant locator. It is ideal for repeated cards and rows. Make the outer collection semantic before applying the filter.

Good DOM

  • Apollo

  • Gemini

Awkward DOM Intentional

Apollo Open
Gemini Open

nth()

nth() selects by zero-based position. Use it only when order itself is the behavior under test, such as a ranked list. If an item has an identity, filter by that identity instead.

Good DOM

  1. Run A
  2. Run B
  3. Run C

Awkward DOM Intentional

Item
Item
Item

first()

first() communicates that the first matching item matters. Pair it with a scoped, meaningful list so unrelated matches cannot move ahead of it. Do not use first() merely to silence strict-mode ambiguity.

Good DOM

  1. Build complete
  2. Review ready

Awkward DOM Intentional

Alert
Alert

last()

last() is appropriate when the newest or final item is the contract. Assert the collection context and count when that matters. A locator that happens to be last today can become ambiguous after unrelated content changes.

Good DOM

  1. Started
  2. Validated
  3. Deployed

Awkward DOM Intentional

Log
Log
Log

Locator chaining

Chaining keeps each step readable: locate a region, then a card, then its action. Each link in the chain should express product meaning. Avoid chains made only of implementation classes and child indexes.

Good DOM

Visa ending 1111

Awkward DOM Intentional

Remove

Frame-free scoping

Named regions, forms, dialogs, rows, and cards provide scope without iframe complexity. Begin at the smallest stable container and locate within it. This resolves duplicate labels while keeping the exercise in one document.

Good DOM

Awkward DOM Intentional

Edit
Edit

Passing examples

Start with observable behavior

These snippets match executable tests stored inexamples/playwright-ts/.

Role and dialog scope
const dialog = page.getByRole('dialog', { name: 'Delete project' });
await dialog.getByRole('button', { name: 'Confirm delete' }).click();
Filter repeated cards
const project = page.getByRole('listitem').filter({ hasText: 'Apollo' });
await project.getByRole('button', { name: 'Open' }).click();
Position helpers
const runs = page.getByRole('list', { name: 'Ranked runs' }).getByRole('listitem');
await expect(runs.first()).toContainText('Run A');
await expect(runs.nth(1)).toContainText('Run B');
await expect(runs.last()).toContainText('Run C');
Frame-free region scope
const billing = page.getByRole('region', { name: 'Billing address' });
await billing.getByRole('button', { name: 'Edit' }).click();