Skip to content
intermediate Phase 7 · Locators & Waits

Robust Locator Strategy

Prefer test ids and accessible names, and document a team-wide locator convention.

55m
0 problems
Topic Progress 0%

data-testid: Decoupling Tests from Styling, and the Argument About It

The className locator fails for a reason worth stating precisely: classes are styling contracts. A redesign renames btn-primary to button--brand, the visual result is identical, and every test keyed on the class breaks — for a change that altered nothing a user could perceive. Test selectors and CSS classes answer to different owners, and conflating them is the root cause of a large share of maintenance toil. data-testid exists to break that coupling: an attribute whose only purpose is to be a stable handle for tests.

<!-- markup -->
<button class="btn button--brand" data-testid="checkout-submit">Place order</button>
// Playwright
await page.getByTestId('checkout-submit').click();
// Selenium
await driver.findElement(By.cssSelector("[data-testid='checkout-submit']")).click();

Now the redesign renames every class; the test is untouched. That is the entire pitch — and it is not uncontested, so an interview answer must carry both sides.

The case for test ids: complete decoupling from styling and copy (rename Place order → Pay now freely); one unambiguous handle per interaction target, which kills positional and nth-child hacks; and a naming convention (checkout-submit, user-menu-toggle) that documents intent for both QA and new developers. Frameworks agree: Playwright ships data-testid as the getByTestId default (configurable via testIdAttribute), and the wider ecosystem (Testing Library's data-testid, Cypress's data-cy convention) standardized on the same idea.

The case against: test ids are dead markup — invisible to users, adding noise to the DOM and to component snapshots; they can lie (the id says submit, the button now cancels); and if tests use the id while humans use role and label, nobody notices when the accessible name is emptied. Teams in regulated or accessibility-sensitive domains therefore prefer roles first and reserve test ids for elements with no stable semantic identity — data grids, canvas widgets, decorative-but-interactive components.

The synthesis that wins arguments: prefer the user's own contract (role + accessible name) when the element has one; add a test id when it does not, or when the accessible name is intentionally volatile (a dynamic label like Delete Acme Corp). Also note the honest cost: test ids must be maintained. An id pointing at a removed feature is as stale as a bad class selector, so some teams lint orphaned test ids the same way they lint orphaned locators.

Where you land matters less than landing it once, in writing. The convention worth adopting for a typical web app:

Priority 1: getByRole(role, { name })      semantic, a11y-checked
Priority 2: getByLabel(text)               form controls
Priority 3: getByTestId(id)                only where 1–2 cannot express the target
Forbidden:  generated classes, absolute XPath, bare nth-child positions

The last line is where the leverage is: a team that agrees only on "prefer good selectors" still ships brittle ones; a team that bans three specific forms does not. Note also who should own the ids: when QA and developers share a component library, adding data-testid becomes part of building a component — reviewed in the same pull request as the markup — rather than a follow-up ticket that quietly never happens. Ownership is what keeps ids from appearing only on the screens somebody happened to test, an inconsistency that reads as chaos to the next engineer. The mechanism for holding the line across the suite — role-based location — is the next chapter, because roles do double duty as accessibility verification.

getByRole: Locators That Verify Accessibility as a Side Effect

Playwright's role-based locators are presented as a convenience and are actually a test strategy: locating by what the element is to assistive technology means every green test has also confirmed that a screen-reader user could find and operate that control. A test suite written this way audits accessibility on every run without an accessibility scanner in sight.

// The big five, in the order they cover most UI:
await page.getByRole('button', { name: 'Place order' }).click();
await page.getByRole('link', { name: 'Pricing' }).click();
await page.getByRole('textbox', { name: 'Email address' }).fill('qa@example.com');
await page.getByRole('heading', { name: 'Order confirmed' }).toBeVisible();
await page.getByRole('checkbox', { name: 'Remember me' }).check();

name matches the accessible name — the text an assistive tech announces — which is computed from visible text, aria-label, aria-labelledby, an associated <label>, or an image alt. That computation is the accessibility standard itself, so getByRole('button', { name: 'Close' }) fails if a developer strips the label from a button; the failure message reads like an accessibility bug report. Two clarifications interviewers like to probe: a <button> element maps to role=button implicitly (no ARIA needed — good markup gives roles for free), and name matching is case-insensitive and whitespace-normalized by default, so exact string anxiety is unnecessary.

Roles also force an important question when a locator cannot be expressed: "what role does this element advertise?" Finding that the modal's close control has no role, no label, and no keyboard path is a defect discovered by writing a test, which is the strategy working as intended. Where semantics genuinely do not apply — a canvas-drawn chart, a virtualized grid cell, an SVG handle — fall back to test ids rather than resurrecting positional selectors.

Contrast with the alternatives to make the trade-off explicit:

Locator Keys on Breaks when Also verifies
getByRole semantics + accessible name role or name changes (a real contract change) accessibility
getByLabel label association label copy changes form a11y
getByTestId a data attribute test id changes (rare) nothing
text/getByText visible copy copy rewording nothing
.class styling restyle/rename nothing
nth-child position any markup insertion nothing

When the visible text is dynamic (Delete Acme Corp), match partially with name regexes or use getByRole('button', { name: /delete/i }) — the pattern keeps the semantic anchor while tolerating the variable part, and it beats string-concatenating selectors. For lists and tables, pair the row's text with the action inside it (getByRole('row').filter({ hasText: 'Acme' }).getByRole('button', { name: 'Delete' })) — relational locators in fluent form, no XPath required.

For Selenium teams the same principle applies through the standard attributes: locate by By.name, By.id, or CSS on [aria-label='…'] / [role='button'], and prefer aria-label/aria-labelledby targets where you control the markup. The principle travels even when the helper does not: locate by meaning, and let styling be free. Holding that line across a team is a documentation problem, which is the third piece of this topic.

Writing the Locator Convention That Makes Every Suite Read the Same

Two engineers on one team can each write perfectly stable locators and still produce a suite that reads like two dialects: one uses getByRole everywhere, the other concatenates [data-testid] strings with XPath for everything. Neither is broken — but every reviewer pays a translation tax, and new members copy whichever file they opened first. The fix is not more talent; it is a one-page convention, agreed once, enforced by review and linting.

What the page actually contains. Not philosophy — an ordered list, with examples from your codebase:

# Locator Convention — web-ui

1. getByRole(role, { name })        → buttons, links, headings, inputs, dialogs
2. getByLabel(text)                 → labeled form controls
3. getByTestId(id)                  → dynamic-named or non-semantic targets
4. getByText / text=                → assert-only (rare), never for actions

Forbidden:
- absolute XPath            /html/body/…
- generated classes         .sc-bdfBwQ, .css-1x2j5u, emotion/styled hashes
- bare positional           //div[3], ul > li:nth-child(4) as identity

Test-id naming: <area>-<element>[-<qualifier>]   checkout-submit, user-menu-toggle
Adding a test id: PR to the component + note in the PR why roles could not cover it.

Specificity is the point: "prefer semantic selectors" is not enforceable; "list starts at getByRole, three forbidden forms, ids named area-element" is. Include the why in one sentence per rule — reviewers enforce rules they understand, and a new hire learns the system by reading the page once.

Enforcement has three layers. First, review: the PR checklist item is one line — "locators follow the convention" — cheap to check because the convention is a short list. Second, static linting: rules like eslint-plugin-playwright's no-conditional-in-test siblings, or a custom rule that flags nth-child, absolute // paths with /html, and known generated-class prefixes in locator strings. Third, runtime: strict mode (Playwright) turning ambiguity into errors, and a periodic locator audit — grep the suite for xpath, nth-child, and getByTitle to find stragglers and measure your progress as a percentage. Numbers make conventions stick: "we are down from 41 brittle selectors to 6" is a reviewable metric.

Handle the two hard cases explicitly in the document, because they are where conventions die: what if the accessible name is unstable (dynamic content: Delete Acme Corp) — answer: partial-name regex on role, or test id; what if there is no semantic element (custom widget, canvas) — answer: test id, with a note requesting the component add proper roles when feasible. Named escape hatches prevent ad-hoc ones.

When markup changes, the convention should make the repair mechanical: roles and labels changing means the user-facing contract changed and tests should be updated deliberately; a test id disappearing means someone removed a component's contract and the test update is part of the same PR by definition. Either way the change lands in the page-object layer from Page Object Basics — locators live there, tests reference intent — so the convention effectively governs two files per screen instead of two hundred test lines. Get the strategy right first, and the page object becomes a thin, stable map rather than a copy of the DOM.

The convention also decides what happens when a locator breaks in review: if a PR adds a second element that makes a locator ambiguous, the failing strict-mode test is a conversation about semantics — which of these two controls is the real one — not a hunt for a cleverer selector. Teams that internalize this stop treating locator failures as test-fragility evidence and start treating them as design feedback, which is precisely the shift from writing scripts to owning a framework.

Quiz

1. A redesign renames every CSS class (`btn-primary` → `button--brand`) with no visual or behavioral change. Which locator approach was unaffected?

Question 1 options

2. What is the strongest argument FOR data-testid over getByRole?

Question 2 options

3. While writing getByRole locators, you discover the modal's close control is a bare <div> with no role, no accessible name, and no keyboard path. What did writing the test accomplish?

Question 3 options

4. Which item belongs in a team locator convention document?

Question 4 options

Flashcards

Question

The recommended locator preference order for web UI tests?

Answer

1) getByRole(role, {name}) — semantic, verifies accessibility; 2) getByLabel(text) — form controls; 3) getByTestId for dynamic-named or non-semantic targets; 4) text matchers for assertions only. Forbidden: generated classes, absolute XPath, positional nth-child identity.

Question

Why does getByRole double as accessibility verification?

Answer

The name option matches the accessible name (visible text, aria-label, aria-labelledby, or associated <label>) — the exact string a screen reader announces. So a passing test proves the control has a usable role and name; stripping the label breaks the test like an a11y bug report. Implicit roles come from correct native elements (<button> → button).

Question

The main trade-off between data-testid and role/label locators?

Answer

Test ids decouple tests from copy and styling (rename freely, one stable handle) but are invisible to users, can go stale silently, and verify nothing about the user's experience. Role/label locators tie tests to the real contract — they break on genuine contract changes and audit accessibility as a side effect. Use roles first, test ids where roles cannot express the target.

Question

How do you locate 'Delete' in the row for 'Acme Corp' without XPath?

Answer

Relational fluent locators: page.getByRole('row').filter({ hasText: 'Acme Corp' }).getByRole('button', { name: 'Delete' }) — row anchored by content, action anchored semantically. For dynamic names use a regex: getByRole('button', { name: /delete/i }).

Question

How is a locator convention enforced beyond good intentions?

Answer

Three layers: (1) review checklist line pointing at the one-page convention; (2) static linting banning nth-child, absolute /html paths, generated-class prefixes in locator strings; (3) runtime strict mode plus periodic audits (grep for xpath/nth-child) reported as a brittle-selector count trending down.

Revision Notes

Key Takeaways

  • 1. Classes are styling contracts; tests need their own contract — that is the precise reason class-based selectors rot on redesign
  • 2. getByRole with accessible name is the rare tool that stabilizes tests and audits accessibility in the same green run
  • 3. Test ids are an escape hatch with a cost (dead markup, silent staleness), not the default — roles first is the defensible order
  • 4. A convention is only real when it is an ordered list with named bans and escape hatches, enforced by review, lint, and strict mode
  • 5. Locators belong in page objects, so the convention governs a handful of files instead of hundreds of test lines

Interview Tips

  • • "How do you write stable locators?" — give the ordered list (role → label → testid), name the three bans, and explain roles double as accessibility checks; concreteness separates you from 'use unique selectors'
  • • "data-testid — good or bad?" — hold both sides: decoupling vs dead markup; land on 'roles first, test ids by exception, one written convention'
  • • "How would you enforce locator standards on a team?" — three layers: review checklist, static lint bans, runtime strict mode plus a brittle-selector audit metric
  • • "Locate the delete button in Acme's row." — write the filter chain live, then explain why nth-child(7) fails on sort/insert — shows relational thinking without XPath

Cheat Sheet

Robust Locator Strategy

Preference order (convention core):

1. getByRole(role, { name })   buttons/links/headings/inputs/dialogs + a11y proof
2. getByLabel(text)            labeled form controls
3. getByTestId(id)             dynamic names / non-semantic widgets
4. text matchers               assertions only, never actions

BANNED: absolute XPath (/html/body/…) · generated classes (.css-1x2j5u)
        bare positions (//div[3], nth-child as identity)
Test-id naming: <area>-<element>[-<qualifier>]  →  checkout-submit

testid vs role: testid = decoupled from copy/styling, invisible to users, can go stale · role+name = follows the user's contract, fails when contract changes, audits a11y each run. Roles first; test ids as documented escape hatch.

Dynamic names: getByRole('button', { name: /delete/i }) or filter chains:

page.getByRole('row').filter({ hasText: 'Acme' })
   .getByRole('button', { name: 'Delete' })

Enforcement: review checklist → lint bans (nth-child, /html, hash-class prefixes) → strict mode + audit metrics (brittle selectors: 41 → 6).

Hard cases (name them in the doc): unstable accessible name → regex or testid · no semantic element → testid + request roles upstream · markup change → update in the page object, same PR.

Continue Learning