RizTech Academy logo
RizTech Academy
Web Automation with PlaywrightLesson 2 of 630 min

Locators: finding elements the robust way

A browser test has to find things — the email field to fill, the sign-in button to click, the row to check. How you find them decides whether your suite is robust or a maintenance nightmare. Find elements the wrong way and every small markup change breaks a dozen tests; find them the right way and your tests survive redesigns and read like a description of what a user does. This lesson is locators — Playwright's way of finding elements, and how to do it so tests do not rot.

What a locator is

A locator describes how to find an element on the page. In Playwright you create one and then act on it:

await page.getByLabel('Email').fill('admin@agentpay.test');
await page.getByRole('button', { name: 'Sign in' }).click();

A locator is lazy and auto-retrying: it does not find the element when you create it, but when you act on it — and Playwright waits for the element to be there and ready first (the actions lesson). That is why you rarely need manual waits.

Prefer user-facing locators

The single most important idea in this lesson: find elements the way a user perceives them, not by internal implementation details. Playwright's recommended locators, in order of preference:

  • getByRole(role, { name }) — by accessibility role and visible name. getByRole('button', { name: 'Sign in' }), getByRole('heading', { name: 'Transactions' }). This is the best default: it is how a user and a screen reader identify things, and it doubles as an accessibility check.
  • getByLabel('Email') — a form field by its label. The robust way to target inputs, because you target the label the user reads.
  • getByText('No transactions match these filters.') — by visible text.
  • getByPlaceholder(...), getByRole('link', { name }) — similar, user-facing.
  • getByTestId('page-indicator') — a data-testid attribute you add deliberately for testing. Use this when there is no good user-facing handle (a status region, a specific cell) — a stable hook that does not depend on styling or structure.

Every AgentPay test uses these. The login helper is entirely user-facing:

await page.goto('/login');
await page.getByLabel('Email').fill(email);
await page.getByLabel('Password').fill(password);
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();

Read it: it says exactly what a person does. That readability is not a bonus — it is what makes the test survive change.

Why not CSS selectors and XPath

The old way was CSS selectors (.btn-primary, #login-form > div:nth-child(2) > input) or XPath. Avoid them as your default, because they bind the test to how the page is built, which changes constantly:

  • A class renamed by a restyle (.btn-primary → .button--primary) breaks the test, though nothing a user cares about changed.
  • A nth-child or deep path breaks the moment a wrapper div is added.
  • They say nothing about what the element is, so the test is unreadable and its failures are cryptic.

User-facing locators break only when the thing the user interacts with actually changes (the button's label, the field's label) — which is exactly when a test should need updating. That alignment is the whole point: your tests should track user-visible behaviour, not internal markup.

Locating rows, cells and lists

Real screens have repeated elements — table rows, list items — and you often need a specific one or all of them. Playwright handles this:

  • getByRole('row') matches every row; toHaveCount(11) asserts how many (AgentPay checks header + 10 data rows).
  • Filtering — page.getByRole('row').filter({ hasText: 'failed' }) narrows to rows containing text.
  • .first(), .last(), .nth(n) — pick one from many.
  • Scoping — page.locator('tbody tr td:nth-child(6)') reads a specific column across rows (AgentPay reads the status column to check a filter). Here a CSS locator is reasonable, because column position is a stable, meaningful structure — the exception that proves the "prefer user-facing" rule: use CSS when the structure itself is what you mean.
  • Strictness — if a locator matches more than one element and you try to act on it, Playwright errors rather than guessing. This is a feature: it forces you to be specific and catches ambiguous locators early.

A habit: locators, then act

Structure every interaction as find, then do, with user-facing locators:

await page.getByLabel('Status').selectOption('failed');           // find the select, choose
await page.getByRole('button', { name: 'Next' }).click();          // find the button, click
await expect(page.getByTestId('page-indicator')).toContainText('Page 2'); // find, assert

If you find yourself reaching for a brittle CSS path, first ask whether the element has a role, a label, or visible text you could target instead — and if it genuinely does not, add a data-testid to the app. A small change to the app to make it testable is almost always better than a fragile locator in the test.

Check your work

A locator describes how to find an element; it is lazy and auto-retrying, so Playwright waits for readiness when you act — which is why manual waits are rarely needed.

Prefer user-facing locators, in order: getByRole(role,{name}) (best default — how users/screen readers see it, doubles as an a11y check), getByLabel (form fields), getByText, getByPlaceholder, and getByTestId (a deliberate stable hook when there is no user-facing handle). AgentPay's login is entirely these.

Avoid CSS/XPath as the default — they bind to how the page is built (classes, nth-child, wrappers), so they break on restyles and refactors that users never notice, and they are unreadable. User-facing locators break only when user-visible behaviour changes — exactly when a test should update.

Repeated elements — getByRole('row') + toHaveCount, .filter({ hasText }), .first/.nth, and scoped CSS for stable structure (a column position). Strictness: a multi-match locator errors rather than guessing — a feature.

Habit: find (user-facing) then act; if tempted by a brittle path, add a data-testid to the app instead.

Practice

  1. Write locators to reach the AgentPay Status filter, the Next button, and the Transactions heading — all user-facing.
  2. Replace a CSS selector like #login button with getByRole('button', { name: 'Sign in' }) and explain why it is more robust.
  3. Use getByRole('row') and toHaveCount to assert the transactions table shows a header plus ten rows.
  4. Filter the rows to only those with status "failed" and count them.
  5. Find one element with a deliberately ambiguous locator, watch Playwright's strictness error, then make it specific.
  6. Identify one place in AgentPay with no good user-facing handle and justify adding a data-testid.

Official documentation

Next: actions and web-first assertions, and why you never sleep().

Stuck on this lesson?

Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.

About the internship