Locator Strategies for UI Tests

A locator is the query a UI test uses to find an element — a button, a field, a row in a table. Choose it well and the test survives redesigns, refactors and a new CSS framework; choose it badly and the suite breaks every sprint for reasons that have nothing to do with defects.

This guide gives a priority order that works in Playwright, Selenium, Cypress and Testing Library, compares CSS and XPath, shows how to handle dynamic ids, iframes and shadow DOM, and ends with a worked example and a review checklist. It is part of the UI test automation guide, which covers waits, assertions and page objects.

What makes a locator good

A locator is best judged by what makes it break. A good one changes only when the behaviour a user relies on changes — not when a designer renames a class or a developer adds a wrapper div.

  • Unique — it matches exactly one element in the state the test expects. Playwright locators are strict: an action on a locator that matches several elements throws an error, which surfaces ambiguity early.
  • Stable — independent of layout, styling, DOM depth, element order and values generated at build or render time.
  • Readable — someone reading the test knows which element it means without opening the page.
  • Close to user intent — it names the element the way a user or assistive technology perceives it: the “Place order” button, the “Email” field.
  • Cheap to fix — when it breaks, the failure message makes the reason obvious.

The priority order: user-facing first, XPath last

Playwright’s documentation recommends role-based locators first, and Testing Library ranks its queries the same way: queries that reflect how users and assistive technology perceive the page come first, and test ids come last among its queries. Adding CSS and XPath for the cases the others cannot reach gives this order:

  • 1. Role and accessible name — getByRole("button", { name: "Place order" }). The role comes from the HTML element (button, link, textbox, checkbox, heading) or its ARIA role; the name comes from the label, content or aria-label, computed as the W3C Accessible Name specification describes.
  • 2. Label — getByLabel("Email") for form fields associated with a <label> element or aria-labelledby.
  • 3. Visible text, alt text, title and input hint text — getByText, getByAltText and getByTitle (plus a matching locator for an input’s hint text) for elements without a useful role and name. Text locators suit non-interactive content such as headings and messages; hint text is a weak substitute for a label.
  • 4. Test id — getByTestId("order-total") matches data-testid by default in Playwright (the attribute is configurable); Cypress recommends data-cy or a similar data-* attribute. Use it when user-facing attributes are ambiguous, missing or frequently changed, for example translated text.
  • 5. CSS selector — for structural cases: attributes, combinators, form inputs by name. Keep it short and anchored on stable attributes.
  • 6. XPath — when you must move to a parent or ancestor, or match text in a tool that has no text locator. Keep it relative and short.
Element: <button type="submit">Place order</button>

Playwright       page.getByRole("button", { name: "Place order" })
Testing Library  screen.getByRole("button", { name: "Place order" })
Selenium (Java)  driver.findElement(By.xpath("//button[normalize-space()='Place order']"))
                 driver.findElement(By.cssSelector("[data-testid=place-order]"))
Cypress          cy.contains("button", "Place order")
                 cy.get("[data-cy=place-order]")
Anti-patterns break on everyday changes; user-facing locators survive all but translated text, where a test id holds, and only role and label also expose a missing accessible name.
LocatorRestyleWrapper div addedItems added or re-sortedNew build or renderTranslated UIExposes a missing accessible nameside benefit
1 · Role and accessible nameSurvivesSurvivesSurvivesSurvivesBreaksit matches the English textYes
2 · LabelSurvivesSurvivesSurvivesSurvivesBreaksit matches the English textYes
3 · Visible textSurvivesSurvivesSurvivesSurvivesBreaksit matches the English textNo
4 · Test idSurvivesSurvivesSurvivesSurvivesSurvivesNo
5 · CSS selectorshort, on stable attributesSurvivesSurvivesSurvivesSurvivesSurvivesNo
6 · XPathrelative, matching textSurvivesSurvivesSurvivesSurvivesBreaksit matches the English textNo
Styling classBreaksSurvivesSurvivesSurvivesSurvivesNo
Index-basedSurvivesSurvivesWrong elementSurvivesSurvivesNo
Generated valueNot applicable: it already breaks on every renderNot applicable: it already breaks on every renderNot applicable: it already breaks on every renderBreaksNot applicable: it already breaks on every renderNo
Absolute XPathSurvivesBreaksCan breakif an ancestor’s position shiftsSurvivesSurvivesNo
  • Breaks, can break, or finds the wrong element
  • Anti-pattern
Recommended order: examples
1 · Role and accessible name getByRole("button", { name: "Place order" })
2 · Label getByLabel("Email")
3 · Visible text getByText("Sign in")
4 · Test id getByTestId("order-total")
5 · CSS selector input[name=terms]
6 · XPath //button[normalize-space()='Place order']
Anti-patterns: examples
Styling class .btn-primary
Index-based (//button)[2]
Generated value #input-4821
Absolute XPath /html/body/div[2]/div/div[3]/form/button

Examples from the page’s checkout form and bug titles. Text-based locators also work on a translated UI when each locale has its own expected strings. A generated value breaks on every render, whatever else changes (—).

Applying the order in Selenium and Cypress

Selenium has no built-in role locator. Its strategies are id, name, class name, tag name, link text, partial link text, CSS selector and XPath, and Selenium 4 adds relative locators (above, below, to the left of, to the right of, near). In practice the order becomes: a test id or a stable id, then name, then a short CSS selector, then XPath — with visible text reached through link text for links or a text-matching XPath for other elements. Many teams wrap these in page-object methods named after the user-facing element, so tests still read “click Place order”.

Cypress queries with cy.get (CSS) and cy.contains (text). Its best-practices guide recommends dedicated data-* attributes over tag, id and class selectors, and cy.contains for elements whose text is part of the behaviour under test. Teams that want role-based queries in Cypress often add the Testing Library integration, which adds findByRole and the other findBy and findAllBy queries.

Test ids: a contract with developers

A test id is an attribute added to the markup purely for automation, such as data-testid, data-test or data-cy. It is the most stable hook you can have, because nothing but the tests depends on it — as long as the team treats it as a contract rather than a decoration.

  • Agree one attribute name for the whole product and configure the tool to use it (for example Playwright’s testIdAttribute setting).
  • Name by purpose, not position or style: checkout-place-order, not button-3 or blue-btn.
  • Make ids unique within a page. For repeated items, put the id on the container or scope by content, then find the child by role — the row for order ORD-2026-000184, then its “Cancel” button.
  • Add test ids in the same pull request as the feature, and review their removal as you would review an API change.
  • Do not replace a clear role and name with a test id; the user-facing locator also shows that the element is exposed to assistive technology.
  • Whether to strip test ids from production builds is a team decision. Keeping them costs a few bytes and lets you run read-only smoke tests against production.
// Playwright: scope to the row for one order, then find its button by role
const row = page.getByRole("row").filter({ hasText: "ORD-2026-000184" });
await row.getByRole("button", { name: "Cancel" }).click();

// playwright.config.ts: use the attribute your team agreed on
export default defineConfig({ use: { testIdAttribute: "data-test" } });

CSS vs XPath

Both languages express most structural queries; the differences matter at the edges. Neither is “wrong”, but CSS is usually shorter for the common cases, and XPath is the only option for a few others.

  • Readability — CSS is shorter for ids, classes and attributes: [data-testid=cart] against //*[@data-testid="cart"].
  • Direction — XPath can move up to a parent or ancestor (.., ancestor::tr) and to preceding siblings; CSS traditionally moves only down and forward. The :has() pseudo-class, supported by current major browsers, covers some “parent of” cases.
  • Text — XPath can match text (normalize-space()="Pay now", contains(., "Pay")); CSS cannot match text content. Playwright and Cypress add their own text matching on top of CSS.
  • Shadow DOM — CSS selectors can be used inside open shadow roots, and Playwright’s CSS and role locators pierce them automatically; XPath does not cross shadow boundaries.
  • Version — browsers implement XPath 1.0, so XPath 2.0 functions such as matches() and ends-with() are not available.
  • Speed — both are fast enough for UI tests and are rarely the bottleneck. Choose for readability and stability.
Goal                           CSS                          XPath
Element by test id             [data-testid=cart]           //*[@data-testid="cart"]
Input by name                  input[name=email]            //input[@name="email"]
Id starting with a prefix      [id^="order-row-"]           //*[starts-with(@id,"order-row-")]
Attribute contains a value     [class*="error"]             //*[contains(@class,"error")]
Button with exact text         (not possible in CSS)        //button[normalize-space()="Pay now"]
Button in the row of an order  (CSS cannot match the text)  //tr[td[normalize-space()="ORD-2026-000184"]]//button
Parent of an element           (only via :has())            //input[@name="email"]/..

Dynamic ids and generated class names

Many frameworks and component libraries generate attribute values at build or render time: an id="input-4821" that changes on every render, class="Button_primary__3xK9a" from CSS modules, class="css-1q2w3e" from CSS-in-JS libraries. A locator that copies such a value passes today and fails after the next build or the next page load.

  • Recognise them — digits or hashes that differ between two loads of the same page. Save the markup from two sessions and compare them to spot the moving parts.
  • Prefer a user-facing locator or a test id — the durable fix is a stable hook, not a cleverer selector.
  • If you must match, anchor on the stable part — [id^="order-row-"] (starts with), [class*="Button_primary"] (contains) or XPath starts-with(). Keep the pattern tight enough to remain unique.
  • Match variable text with a pattern — for example getByText(/Order ORD-\d{4}-\d{6} confirmed/) in Playwright — and test the pattern against real samples before committing it.
  • Never hard-code values that come from test data — build the locator from the data the test created, such as the order number returned by the API call that set up the test.

Try it: Regex Tester Try it: Text Diff

Iframes and shadow DOM

An iframe is a separate document, so a locator on the main page cannot see inside it. Payment fields, embedded maps, chat widgets and rich-text editors are often iframes.

Shadow DOM encapsulates a web component’s internal markup. Open shadow roots can be reached by automation tools. Closed shadow roots are hidden by design and are not reachable with standard locators; test them through the component’s public behaviour or ask the developers for a test hook.

  • Selenium — switch into the frame with driver.switchTo().frame(…), act, then return with driver.switchTo().defaultContent(). For shadow DOM, find the host element, call getShadowRoot() (Java; shadow_root in Python), then search inside it with CSS selectors.
  • Playwright — page.frameLocator(…) or locator.contentFrame() gives a scope for locators inside the frame, with no switching. Playwright locators pierce open shadow roots by default, except XPath.
  • Cypress — shadow DOM is supported through the .shadow() command or the includeShadowDom configuration option. Iframes have no dedicated built-in command; the common approach is to reach the frame’s document body from the iframe element, and cross-origin frames add further limits.
  • Third-party frames — for a payment provider’s hosted card field, use the provider’s test mode and its published test data, and keep the number of tests that type into the frame small.
// Selenium (Java) — iframe: switch in, act, switch back
driver.switchTo().frame(driver.findElement(By.cssSelector("iframe[title='Card details']")));
driver.findElement(By.name("cardnumber")).sendKeys(testCardNumber);
driver.switchTo().defaultContent();

// Selenium (Java) — open shadow root, then CSS inside it
SearchContext shadow = driver.findElement(By.cssSelector("app-date-picker")).getShadowRoot();
shadow.findElement(By.cssSelector("button.next-month")).click();

// Playwright — frame scope, no switching; open shadow roots are pierced
const card = page.frameLocator("iframe[title='Card details']");
await card.getByLabel("Card number").fill(testCardNumber);
await page.locator("app-date-picker").getByRole("button", { name: "Next month" }).click();

Anti-patterns that make suites brittle

  • Absolute XPath — /html/body/div[2]/div/div[3]/form/button breaks when any ancestor changes. The browser’s “Copy XPath” often produces exactly this.
  • Index-based selectors — :nth-child(3), (//button)[2] or .first() without a reason. They depend on order and break when an item is added, removed or sorted differently.
  • Styling classes — .btn-primary, .mt-4 or .text-red-500 change with every visual redesign or utility-CSS refactor.
  • Generated values — ids and class hashes produced at build or render time.
  • Long chains — div.container > div.row > div.col-md-6 > form > div:nth-child(2) > input encodes the entire layout.
  • Text in the wrong language — a test that locates “Place order” fails when the same suite runs against the Arabic interface («إتمام الطلب»). Either give each locale its own expected strings or use test ids for elements whose text is translated.
  • Duplicated locators — the same selector pasted into twenty tests. Keep it in one page or component object.
  • Over-broad text matches — getByText("Save") matching the button, a toast and a help paragraph. Scope to a region or use a role.
Automation defects caused by locators (written like bug titles)
- checkout.spec fails after restyle: .btn-primary no longer matches "Place order"
- orders.spec cancels the wrong order when the list is sorted by date: locator (//button[text()="Cancel"])[1]
- login.spec fails on the Arabic build: getByText("Sign in") not found
- profile.spec fails intermittently: #input-4821 is regenerated on every render
- search.spec breaks after a wrapper div is added: /html/body/div[2]/div/div[3]/form/button

Locators and accessibility

Role and label locators use the same information a screen reader announces: the element’s role, from the HTML element or WAI-ARIA, and its accessible name. When getByRole("button", { name: "Delete" }) cannot find an icon-only delete button, the likely reason is that the button has no accessible name — a real accessibility defect, not a test problem. Fix the markup (visible text or aria-label), not the locator.

The same applies when a component library renders a div that behaves like a button: raise a defect for the missing semantics instead of writing CSS locators around it.

This is a side benefit, not an accessibility test. Passing role-based locators do not show that a page is accessible; keyboard, screen reader and automated accessibility checks are still needed.

Worked example: locators for a checkout form

The markup below comes from a fictional checkout page. For each element, the list gives the recommended locator, the Selenium equivalent and what to avoid.

  • Email field — getByLabel("Email"). Selenium: By.name("email"). Avoid #email-1293 (generated) and form > input:nth-of-type(1).
  • Terms checkbox — getByRole("checkbox", { name: "I accept the terms" }). Selenium: By.cssSelector("input[name=terms]").
  • Order total — getByTestId("order-total"); its visible text changes with the amount, so the text is what you assert, not how you locate. Selenium: By.cssSelector("[data-testid=order-total]").
  • Submit button — getByRole("button", { name: "Place order" }). Selenium: an XPath on normalised text, or a test id if the team adds one. Avoid .btn-primary and //form/button[1].
  • The form itself — do not use #checkout-7f3a or .Checkout_form__x9Kp2. If you need a scope, give the form an accessible name and use getByRole("form", { name: … }), or add a test id.
<form id="checkout-7f3a" class="Checkout_form__x9Kp2">
  <label for="email-1293">Email</label>
  <input id="email-1293" name="email" type="email">
  <label><input type="checkbox" name="terms"> I accept the terms</label>
  <div class="summary">
    <span>Total</span> <span data-testid="order-total">199.20 SAR</span>
  </div>
  <button type="submit" class="btn btn-primary mt-4">Place order</button>
</form>

Try it: HTML Formatter

Locator review checklist

  • Does it match exactly one element in the state the test expects?
  • Would it survive a restyle, a reordering of items and a change in DOM depth?
  • Is it role/label/text first, test id second, CSS third, XPath last — and if not, is the reason written down?
  • Is it free of generated ids, hashed classes, indexes and absolute paths?
  • Does it live in one page or component object rather than in the test body?
  • Will it work in every locale the suite runs in?
  • If it failed tomorrow, would the error message say which element was missing?

Frequently asked questions

What is the best locator strategy for UI tests?

Locate elements the way users perceive them — by role and accessible name, label or visible text — then fall back to a dedicated test id, then a short CSS selector, and use XPath only when nothing else works. Playwright and Testing Library recommend the user-facing part of this order, with test ids after it; adding CSS and then XPath makes it work with any tool.

Is XPath slower than CSS?

The difference is rarely significant in UI tests, where page loads and network calls dominate. The real reasons to prefer CSS are that it is shorter and easier to read; XPath remains useful for moving to parents and matching text.

Should I use data-testid or getByRole?

Use getByRole when the element has a clear role and name, because it matches what users see and also exposes missing accessible names. Use a test id when the role and name are ambiguous, missing or change often, such as translated text or repeated items.

How do I handle dynamic IDs?

Ask for a stable hook (a test id) or use a user-facing locator. If you must match the generated value, anchor on its stable part with starts-with or contains, and build locators from the test’s own data rather than hard-coding values.

How do I locate elements inside an iframe?

In Selenium, switch into the frame with switchTo().frame and back with switchTo().defaultContent. In Playwright, use frameLocator or contentFrame to get a scope for locators inside the frame; no switching is needed.

Why does Playwright say my locator resolved to multiple elements?

Playwright locators are strict: an action requires exactly one match. Make the locator more specific — add the accessible name, scope it to a region or row, or use filter — rather than adding .first(), which hides the ambiguity.

Can I use locators copied from browser developer tools?

Treat them as a starting point only. “Copy XPath” and “Copy selector” usually produce absolute paths or index-based selectors that break with small layout changes; rewrite them using the priority order before committing.

References

  • Locators — PlaywrightMicrosoft (Playwright)How Playwright finds elements, why locators re-resolve and auto-wait, and why role-based locators are recommended first.
  • About Queries — Testing LibraryTesting LibraryQuery types and their priority — role and label before text, test ids last — so tests find elements the way users and assistive technology do.
  • Locator strategies — SeleniumSelenium ProjectThe locator types WebDriver supports (id, name, CSS selector, XPath, link text and others) and how to choose between them.
  • Best Practices — CypressCypress.ioCypress guidance on selecting elements with dedicated attributes, avoiding fixed waits and keeping tests independent.
  • Best Practices — PlaywrightMicrosoft (Playwright)Test user-visible behaviour, keep tests isolated, prefer resilient locators and web-first assertions, and debug with traces.
  • Accessible Name and Description Computation 1.2W3C · 2026How browsers compute the accessible name and description of an element from labels, ARIA attributes and content (a W3C Working Draft).
  • Accessible Rich Internet Applications (WAI-ARIA) 1.2W3C · 2023The W3C Recommendation that defines ARIA roles, states and properties — how custom widgets expose their meaning to assistive technology.

Practice exercise

Review the locators in a pull request

Your task

You are the automation reviewer on the team of a fictional electronics web shop with an English and an Arabic interface. A colleague opened pull request #412 with the first UI tests for the "My orders" page: filtering the list, tracking and cancelling an order, downloading an invoice and opening the support chat. The eight locators the tests use are listed in the material below, together with the trimmed markup of the page. Seven of the eight pass on the author’s machine against the English build; L8 times out, and the author asks for help with it.

Review the locators on paper with the guide’s priority order, its anti-patterns and its review checklist. Write every finding as you would write it in the pull request: what breaks the locator, what to use instead, and what has to change in the markup rather than in the test. Allow 20–30 minutes.

Material
Electronics web shop (fictional) - English and Arabic UI
Page "My orders", markup of the English build, trimmed. Nightly UI suite:
Playwright (TypeScript), run against both languages.
Generated at build time: ids such as search-5521 and row-7f21c change with every
deployment; classes such as Filters_bar__9kQ2z come from CSS modules.
The icon button in each row downloads the invoice ("Download invoice" in the design).
Arabic UI: Status = «الحالة», Apply = «تطبيق», Track = «تتبّع», Cancel = «إلغاء»,
Processing = «قيد المعالجة», Download invoice = «تنزيل الفاتورة»,
Search orders = «البحث في الطلبات», Support chat = «محادثة الدعم».

<main>
  <h1>My orders</h1>
  <form class="Filters_bar__9kQ2z">
    <input id="search-5521" name="q" type="search" placeholder="Search orders">
    <label for="status-5522">Status</label>
    <select id="status-5522" name="status">
      <option value="all">All</option>
      <option value="open">Open</option>
      <option value="delivered">Delivered</option>
    </select>
    <button type="submit" class="btn btn-outline">Apply</button>
  </form>
  <table class="Table_root__x1Ab9">
    <thead>
      <tr><th>Order</th><th>Date</th><th>Total</th><th>Status</th><th>Actions</th></tr>
    </thead>
    <tbody>
      <tr id="row-7f21c" class="Table_row__2kX9q">
        <td>ORD-2026-004417</td><td>12 Sep 2026</td><td>249.00 SAR</td>
        <td><span class="badge badge-green">Delivered</span></td>
        <td>
          <button type="button" class="btn btn-link">Track</button>
          <button type="button" class="btn-icon"><svg aria-hidden="true"></svg></button>
        </td>
      </tr>
      <tr id="row-7f21d" class="Table_row__2kX9q">
        <td>ORD-2026-004431</td><td>28 Sep 2026</td><td>89.50 SAR</td>
        <td><span class="badge badge-amber">Processing</span></td>
        <td>
          <button type="button" class="btn btn-link">Track</button>
          <button type="button" class="btn btn-link">Cancel</button>
          <button type="button" class="btn-icon"><svg aria-hidden="true"></svg></button>
        </td>
      </tr>
    </tbody>
  </table>
  <iframe title="Support chat" src="https://chat.example.com/widget"></iframe>
</main>

Pull request #412 "orders.spec: first UI tests for My orders" - its eight locators
L1  page.locator('#search-5521')                        // search field
L2  page.locator('.Filters_bar__9kQ2z select')          // status filter
L3  page.locator('.btn-outline')                        // "Apply"
L4  page.locator('xpath=/html/body/main/table/tbody/tr[2]/td[5]/button[2]')
                                                        // "Cancel", ORD-2026-004431
L5  page.getByText('Track').first()                     // "Track", ORD-2026-004417
L6  page.locator('.badge-amber')                        // status, ORD-2026-004431
L7  page.locator('tr:nth-child(2) .btn-icon')           // invoice button, ORD-2026-004431
L8  page.getByRole('button', { name: 'Start chat' })    // inside the chat widget
Author's note: L1-L7 pass on my machine (English build). L8 times out - help?
Steps
  1. For each of L1–L8, write what will break it or why it fails, naming the reason from the guide (generated value, styling class, absolute XPath, index, over-broad text match, text in the wrong language, iframe). Note which ones pass today anyway.
  2. Rewrite each locator in the priority order, in Playwright: scope repeated items to the row of the order whose number the test created itself, and make the result work for the Arabic run too.
  3. Mark the places where a locator is not the right fix: what you ask the developers to add (a test id, a label, an accessible name), and which of these you report as a defect.
  4. Write Selenium (Java) equivalents for the status filter and for the "Cancel" button of a given order.
  5. Run the guide’s checklist on your result: uniqueness, locale, and whether a failure message would name the missing element.
Show the model answer
Model answer

L1–L3, the filter form. All of L1–L7 pass today, as the author says; the review is about what will break them. L1 #search-5521 is a generated id: it changes with every deployment, so the test passes today and fails after the next release. The field has no <label>; its only accessible name comes from the hint text, because browsers use the placeholder as a last-resort name when nothing else names a text field, and Playwright follows the same rule. So page.getByRole("searchbox", { name: "Search orders" }) finds it today, and so does page.getByPlaceholder("Search orders"), level 3 in the guide’s order. Either way the test rests on hint text that disappears as soon as the user types, which the guide calls a weak substitute for a label: use the role locator and ask the developers for a visible label (the locator keeps working if the label reads "Search orders"). L2 .Filters_bar__9kQ2z select copies a CSS-modules class whose hash changes with every build; the select has a label, so the locator is page.getByLabel("Status"). L3 .btn-outline is a styling class that disappears at the next restyle, although it matches exactly one element today; use page.getByRole("button", { name: "Apply" }).

L4 and L5, the table rows. L4 is an absolute XPath and index-based twice over: tr[2] depends on the sort order and on which orders the account has, and button[2] is "Cancel" only in rows that have a Cancel button — in the delivered row the second button is the invoice icon, so a reordering silently cancels the wrong order or clicks the wrong button. Chrome DevTools’ "Copy full XPath" produces exactly this path; plain "Copy XPath" would anchor on the generated row id instead (//*[@id="row-7f21d"]/td[5]/button[2]), which is no better. Rewrite by scoping to the row by its content and then finding the button by role: const row = page.getByRole("row").filter({ hasText: orderNumber }) and row.getByRole("button", { name: "Cancel" }), where orderNumber is the value the test’s own set-up created, not a hard-coded ORD-2026-004431. L5 getByText("Track").first(): getByText("Track") matches two buttons, so without .first() Playwright’s strict mode would refuse the click; .first() hides the ambiguity instead of resolving it and clicks whichever order happens to be sorted first. In the nightly Arabic run it fails outright, because the button reads «تتبّع» there. Same fix: row.getByRole("button", { name: "Track" }) on the row of ORD-2026-004417.

L6 and L7, the status badge and the icon button. L6 .badge-amber locates the status by its colour class: it breaks at the next restyle, it is not scoped to an order (a second order in processing makes it ambiguous), and the colour changes with the status — a delivered order’s badge is badge-green — so once the test has cancelled the order, the locator no longer matches the badge it wants to check, and the failure says the element was not found instead of naming the status it saw. The status text is what the test asserts, so, as with the order total in the guide’s worked example, it must not be how the test locates: ask for data-testid="order-status" on the badge, use it only inside the row scope because it repeats once per row, and assert with row.getByTestId("order-status") and toHaveText(...) with the expected status in the language of the run. L7 tr:nth-child(2) .btn-icon is index-based and styling-based, but the deeper problem is in the markup: the button contains only an aria-hidden icon and has no accessible name, so getByRole("button", { name: "Download invoice" }) cannot find it and a screen reader announces it as just "button". That is an accessibility defect to report; once the developers add aria-label="Download invoice" (or visually hidden text), row.getByRole("button", { name: "Download invoice" }) is the locator. Do not write a CSS locator around the missing name.

L8, the chat widget. page.getByRole("button", { name: "Start chat" }) times out because the button lives inside an iframe, and a locator on the main page cannot see into another document. Playwright needs a frame scope: page.frameLocator("iframe[title='Support chat']").getByRole("button", { name: "Start chat" }); the title attribute is a stable, user-facing hook for the frame itself. The widget is a third party’s document, so keep to one or two tests that interact with it and do not assert on its internal markup.

What changes in the markup, not in the test. Three requests go to the developers, in the same pull request if possible: a visible <label> for the search field; aria-label="Download invoice" on the icon button; and data-testid="order-status" on the status badge (the team’s agreed attribute, named by purpose, used inside the row). The first two go into the tracker as accessibility defects — the test only exposed them: the icon button has no accessible name at all, and the search field is named only by hint text that vanishes as the user types. The test id is a contract, so from now on its removal is reviewed like an API change. Nothing else needs a test id: "Cancel", "Track", "Apply" and "Status" have clear roles and names, and the guide says not to replace those with test ids.

The Arabic run. Every name the locators use is translated: "Status" is «الحالة», "Apply" «تطبيق», "Track" «تتبّع», "Cancel" «إلغاء», "Download invoice" «تنزيل الفاتورة», and the status text "Processing" «قيد المعالجة» as well. The guide offers two ways and lists a third as an anti-pattern: keep one strings table per locale and read the names from it, or use test ids for elements whose text is translated; what fails is a hard-coded English string in a suite that also runs in Arabic. Role and name with per-locale strings is the better default here, because it also checks what Arabic users see; data-testid="order-cancel" is acceptable if the team records the reason. The title of the iframe and the hint text of the search field are translated too, so they belong in the same table.

Selenium (Java). Status filter: new Select(driver.findElement(By.name("status"))).selectByValue("open"); the name and the option value are stable and not translated, so the same line works in both languages, while By.id("status-5522") is generated. "Cancel" of a given order: Selenium has no role locator, so anchor a relative XPath on the row’s content and the button’s text, By.xpath("//tr[td[normalize-space()='" + orderNumber + "']]//button[normalize-space()='" + cancelLabel + "']"), with cancelLabel read from the strings table ("Cancel" or «إلغاء»); with a test id on the button it becomes //tr[td[normalize-space()='…']]//*[@data-testid='order-cancel']. Both are relative, anchored on data the test owns, and readable; a page-object method such as cancelOrder(orderNumber) keeps them in one place.

The checklist on the result. Uniqueness: every rewritten locator matches one element in the expected state; the row scope is what makes "Cancel", "Track" and the status badge unique, and Playwright’s strict mode would tell you if it were not. Survives a restyle, a re-sort and a wrapper div: yes, because nothing refers to classes, indexes or depth. Priority order: role or label everywhere, one test id with a written reason (the status text is asserted, not located), XPath only in Selenium where no role locator exists; the search field’s name rests on hint text until the label arrives, and the review comment says so. Locale: every user-facing name comes from the per-locale strings table, so the Arabic run uses the same locators. Lives in one page object: OrdersPage.row(orderNumber), cancel(orderNumber) and so on, not in the test body. Error messages: when row.getByRole("button", { name: "Cancel" }) fails, Playwright reports the row filter and the missing "Cancel" button; when /html/body/main/table/tbody/tr[2]/td[5]/button[2] fails, it reports a path and nobody knows which element was meant.

If your answers differ, check them against the guide’s two questions: would the locator change only when the behaviour a user relies on changes, and does it match one element in the expected state? A different choice with a recorded reason is fine — getByPlaceholder("Search orders") until the label exists, a test id on "Cancel" for the two-language suite, or getByRole("combobox", { name: "Status" }) instead of getByLabel — but a generated id, a styling class, an index or an absolute path is not, and neither is a locator that works around a missing accessible name.

Your progress on this topic

Part of the QA Roadmap

Tools mentioned in this guide

Beautify HTML with proper indentation of nested elements, preserving pre, script and style blocks.

Formatting

Test regular expressions live with match highlighting, capture groups and replace preview.

Utilities

Compare two texts line by line or word by word and highlight differences.

Formatting

How Mutqan Soft can help

More guides

More guides