If you have ever watched a Playwright test fail because a button moved, a class name changed, or an element matched the wrong node, you already know that locators are the real foundation of UI automation. The rest of the test can be clean, but if the locator is brittle, the suite becomes noisy very quickly.

I treat locator design as one of the most important parts of test engineering. In Playwright, locators are not just selectors with a nicer API. They are a structured way to describe user intent, with better filtering, auto-waiting, and stricter defaults than most older browser automation tools. That is why a solid Playwright locators tutorial should cover more than syntax, it should cover strategy.

In this article, I will walk through role selectors, text selectors, CSS selectors, chained locators, common locator mistakes, and the practical choices I make when writing tests that need to survive UI churn.

What a Playwright locator actually is

A Playwright locator is a live query for an element, not a one-time element snapshot. That difference matters.

With many older tools, you find an element once, store it, and hope it stays valid. In Playwright, the locator keeps resolving at action time. That lets Playwright wait for the DOM to settle, retry actions, and reduce a class of timing failures that make flaky tests so frustrating.

Good locators describe intent, not implementation details.

That intent can be something like:

  • the primary signup button
  • the row for a specific customer email
  • the first enabled submit button in a dialog
  • the field labeled “Password”

This is why getByRole() is usually the first thing I reach for. It lines up with how a user sees the page and how accessibility APIs expose it.

The locator hierarchy I use in practice

When I write Playwright tests, I usually think about locators in this order:

  1. Accessible role and name with getByRole()
  2. Text-based matching with getByText() or filter({ hasText })
  3. Label-based fields with getByLabel()
  4. Test IDs with getByTestId()
  5. CSS selectors when the DOM has no better hook
  6. XPath only as a last resort, and usually not at all

That order is not dogma, but it is a practical default. The earlier options are usually clearer, more stable, and easier to review.

Role selectors: the default choice for most UI actions

Role selectors are the best starting point for many buttons, links, checkboxes, radio buttons, tabs, menu items, and similar controls.

Example: click a button by role

import { test, expect } from '@playwright/test';
test('submits the form', async ({ page }) => {
  await page.goto('https://example.com/signup');
  await page.getByRole('button', { name: 'Create account' }).click();
  await expect(page.getByText('Welcome')).toBeVisible();
});

This is better than targeting a CSS class such as .btn-primary because it survives most styling changes. It is also better than targeting the text directly when the element is truly a button, because it makes the test intention more specific.

Why getByRole() is so effective

Playwright can query elements using the browser’s accessibility tree. That gives you a selector that maps well to user-facing semantics. If a button is visible and has the accessible name “Create account”, then the test is saying exactly that.

That has two benefits:

  • the test is easier to read
  • the test often fails for meaningful reasons, not accidental DOM changes

Common role selector patterns

typescript

await page.getByRole('link', { name: 'Pricing' }).click();
await page.getByRole('textbox', { name: 'Email' }).fill('qa@example.com');
await page.getByRole('checkbox', { name: 'Remember me' }).check();
await page.getByRole('tab', { name: 'Billing' }).click();

If you are writing tests and the app has decent accessibility labels, getByRole() usually becomes your workhorse.

Text selectors, useful, but use them carefully

Text selectors are very handy when the visible text is stable and meaningful. They are great for headings, error messages, status messages, and list items.

Example: verify a message and click a matching item

typescript

await expect(page.getByText('Your settings have been saved')).toBeVisible();
await page.getByText('Acme Enterprise Plan').click();

The danger with text selectors is that they can become too broad. A string like “Save” might appear in a button, a confirmation message, and a tooltip. That is where strictness helps, but only if your selector is precise enough.

Better text selector patterns

If you need to narrow down context, use a parent locator and then filter within it.

typescript

const productCard = page.locator('.product-card').filter({ hasText: 'Acme Enterprise Plan' });
await productCard.getByRole('button', { name: 'Select' }).click();

This pattern is much safer than searching the whole page for a generic word.

If the text is not unique, use context. If the context is not stable, use a more semantic hook.

CSS selectors, still useful, but not the first choice

CSS selectors are not bad. They are just often overused.

I still use CSS selectors when:

  • the app has no accessible labels yet
  • I need a structural query
  • I am targeting a test-specific attribute such as data-testid
  • I need a concise relationship like “a button inside this container”

Example: CSS selector with stable test attributes

typescript

await page.locator('[data-testid="submit-order"]').click();
await page.locator('form#billing input[name="zip"]').fill('94105');

CSS selectors become fragile when they rely on deep DOM structure or styling classes.

Avoid selectors like these when possible

typescript

await page.locator('div > div > div:nth-child(2) > button').click();
await page.locator('.MuiButton-root.MuiButton-containedPrimary').click();

These may work today, but they couple the test to layout and implementation details that change often.

getByRole, getByText, getByLabel, and getByTestId, when to use each

A good Playwright locators tutorial should be opinionated here, because this is where teams waste time.

getByRole()

Use this for interactive elements that have a clear accessibility role, such as buttons, links, tabs, checkboxes, radio buttons, comboboxes, and dialogs.

getByLabel()

Use this for form fields where the label is visible and correctly associated.

typescript

await page.getByLabel('First name').fill('Ava');
await page.getByLabel('Password').fill('secret123');

This is often more readable than targeting placeholder text, which tends to be less stable and less accessible.

getByText()

Use this for visible copy, headings, error messages, and non-interactive content.

getByTestId()

Use this when the UI has no stable accessible hook and you need a purpose-built test attribute.

typescript

await page.getByTestId('checkout-total').toHaveText('$42.00');

Test IDs are a pragmatic compromise. They are not user-facing, but they can be excellent for stability when applied consistently.

Locator chaining and scoping, the difference between good and noisy tests

One of the easiest ways to make a locator more stable is to scope it to a meaningful container before searching inside it.

Example: locate a row, then click within it

typescript

const row = page.getByRole('row', { name: /acme corp/i });
await row.getByRole('button', { name: 'Edit' }).click();

Here, the locator does two things well:

  • it identifies the specific row by business data
  • it finds the relevant action inside that row

That is much better than clicking the first button with text “Edit” on the page.

Example: use filter({ hasText })

typescript

const card = page.locator('.plan-card').filter({ hasText: 'Pro' });
await card.getByRole('button', { name: 'Choose plan' }).click();

This pattern is especially useful when repeated components share the same structure.

Strict mode, why Playwright often saves you from ambiguous locators

Playwright locators are strict by default in many action contexts, which means a locator that matches multiple elements usually throws instead of guessing.

That is a good thing.

Ambiguity is a common source of test bugs. If your locator matches three buttons, and the test clicks one of them, you may not notice the mistake until much later. Strictness forces you to express intent more clearly.

Example of a bad ambiguous locator

typescript

await page.getByText('Save').click();

If there are two “Save” buttons on the page, that is not good enough. Better options include:

typescript

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

or scoping it:

typescript

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

Practical locator examples I would actually use

Here are some realistic Playwright locator examples that show the style I prefer.

Login form

typescript

await page.getByLabel('Email').fill('qa@example.com');
await page.getByLabel('Password').fill('secret123');
await page.getByRole('button', { name: 'Sign in' }).click();

Search and select a result

typescript

await page.getByRole('textbox', { name: 'Search' }).fill('billing');
await page.getByRole('option', { name: 'Billing settings' }).click();

Assert an error message near a field

typescript

const emailField = page.getByLabel('Email');
await emailField.fill('not-an-email');
await expect(page.getByText('Enter a valid email address')).toBeVisible();

Work with repeated rows

typescript

const invoiceRow = page.getByRole('row', { name: /invoice #1842/i });
await invoiceRow.getByRole('link', { name: 'View' }).click();

These examples show a pattern I recommend often, find the smallest meaningful container first, then search inside it.

Common locator mistakes that create flaky tests

Most locator failures I debug fall into a small set of patterns.

1. Targeting classes generated by CSS frameworks

Framework classes often change when a component library updates. They are usually too implementation-specific.

2. Using text that changes by locale or A/B experiment

If a product changes copy for marketing or localization, a text selector can fail even though the feature works. In those cases, a test ID or structural locator may be better.

3. Using the first matching element without scoping

This is especially dangerous on dashboards, tables, and modals. The page may grow over time, and your selector will quietly become too broad.

4. Assuming placeholders are labels

Placeholder text is often not reliable enough for automation. It can disappear once the user starts typing, and it may not be associated with accessibility semantics.

5. Overusing XPath

XPath can solve problems, but it usually signals that the page lacks a better testing hook. If I can replace an XPath selector with a role, label, or test ID, I usually do.

What makes a locator maintainable

I usually check locators against four criteria.

1. Is it user-centric?

If a human would identify the element by role, label, or visible text, that is usually a good sign.

2. Is it unique enough?

A locator should identify one meaningful target, not a family of similar targets.

3. Is it stable across UI refactors?

Ask whether a CSS redesign, layout change, or component library upgrade would break it.

4. Is it readable in six months?

A test should be understandable by someone who did not write it. That is one reason I prefer getByRole('button', { name: 'Sign in' }) over a cryptic selector chain.

A simple locator strategy for teams

If you are standardizing Playwright across a team, I suggest a few rules:

  • prefer getByRole() and getByLabel() first
  • use getByText() for visible content, not generic clicks
  • add data-testid for important elements that are hard to target otherwise
  • scope repeated components before interacting with them
  • avoid CSS structure selectors unless there is no better option
  • review locator uniqueness as part of test code review

These rules are simple, but they prevent a lot of maintenance work later.

Debugging locator problems

When a locator fails, I usually ask three questions:

  1. Does the element exist at all?
  2. Is the accessible name what I think it is?
  3. Is the locator too broad, too narrow, or in the wrong scope?

Playwright’s inspector and trace viewer are useful here, but even without them, the fix often comes down to improving the selector intent.

If a button is visible in the browser but the test cannot find it, check the accessible name first. Sometimes the text on the screen is not the same as the accessibility name exposed to the browser.

Locator design and CI stability

In CI/CD, locator quality matters more than most teams expect. A test that passes locally but fails in a headless pipeline often has one of these issues:

  • timing assumptions
  • overly broad selectors
  • hidden duplicate elements
  • UI changes that altered the accessibility tree

Stable locators reduce reruns, but they also make failures more actionable. When the test says, “could not find the button named Create account,” that is much easier to understand than a vague CSS failure.

Where Endtest fits if you want less locator design work

If your team wants to reduce manual locator design, Endtest’s AI Test Creation Agent is worth a look. It uses agentic AI to turn a plain-English scenario into editable Endtest steps with stable locators, which can be useful when you want broad test coverage without spending as much time hand-crafting selectors.

I would still reach for Playwright when I need full code-level control, but this kind of AI-assisted workflow can be a practical alternative for teams that want to spend less time maintaining locators and more time on coverage.

Final thoughts

A good Playwright test is rarely about clever code, it is about good intent expressed clearly. That is why locator selection deserves deliberate effort. Use roles and labels when the app supports them, use text when the content is stable, use test IDs when you need a stable contract, and use CSS only when it is the right tool for the job.

If you treat locators as part of your product quality strategy, not just a technical detail, you will get tests that are easier to read, easier to debug, and much less likely to become flaky over time.

The shortest path to better Playwright tests is often not a new helper function or a retry wrapper. It is choosing better locators.