Reference
The Locator Cheat Sheet.
The locators you choose decide how stable your suite is. Here is every strategy at a glance, with copy-ready examples and the rules that keep tests from flaking. Click any selector to copy it.
Pick in this order
This is the fallback ladder for a raw CSS or XPath selector. Start at one, and only move down when the level above does not exist and cannot be added. If your test tool has role or text queries (see the modern order below), start there instead.
[data-testid="submit"] Dedicated test IDs never change by accident. Ask your developers for them; most will say yes. #submit-button IDs are unique and fast, when they exist and are not auto-generated. [name="email"] Form fields almost always have stable name attributes. button.btn-primary Tag plus a meaningful class. Fine when classes describe purpose, fragile when they describe styling. button=Submit order Text selectors (WebdriverIO syntax) read like the user thinks, but break when copy changes. //div[@class="cart"]//button[2] XPath with position is the last resort. One layout change and it points at the wrong element. The modern order: locate like a person
The raw CSS and XPath below are the foundation. Testing Library and Playwright both suggest reaching first for roles and accessible names, the same things a screen reader exposes. Work down this list and your tests resemble how people actually use the page.
getByRole('button', { name: 'Save' }) By role + name. Matches what a person, and a screen reader, perceives. Start here for almost everything. getByLabel('Email address') By label. Resolves the link between a label and its field, the way assistive tech does. getByPlaceholder('Search') By placeholder. A fallback for fields that have no associated label. getByText('Add to cart') By text. Content a person can read on the page. getByAltText('Company logo') By alt or title. Images and titled elements. Semantic, but narrower. getByTestId('checkout-cta') By test id. The fallback. Use it only when role or text cannot identify the element, or the text is dynamic. Implicit ARIA roles
What getByRole('…') resolves to. Semantic HTML gives you these for free.
<button> button <a href> link <input type="text"> textbox <input type="search"> searchbox <input type="checkbox"> checkbox <input type="radio"> radio <select> combobox <h1> to <h6> heading (levels 1 to 6) <ul> / <ol> list <li> listitem <table> table <tr> row <td> cell <th scope="col"> columnheader <nav> navigation <img alt="…"> img Accessible name
The name in getByRole(role, { name }) is computed in this order of precedence (W3C accname):
- 1
aria-labelledby, the ids of elements that label this one - 2
aria-label, a literal string on the element - 3an associated
<label>for form fields - 4the element's own text content
- 5
placeholderortitle, as a last fallback
That is why role plus name keeps working after a refactor: it depends on meaning, not the exact markup.
CSS selectors
#id By ID .class By class input[name="email"] By attribute input[placeholder*="Search"] Attribute contains a[href^="https"] Attribute starts with img[src$=".png"] Attribute ends with .parent > .child Direct child .list li:first-child First in a list .list li:nth-child(3) Third in a list .a, .b Either of two XPath patterns
//button[text()="Save"] Exact text //button[contains(text(), "Save")] Text contains //input[@name="email"] By attribute //div[contains(@class, "error")] Class contains //label[text()="Email"]/following-sibling::input Sibling of a label //tr[td[text()="Julia"]] Row containing a cell //ul[@id="cart"]/li[last()] Last item WebdriverIO shorthand
await $('[data-testid="submit"]') Single element await $$('.cart-item') All matching elements await $('button=Submit') Exact link/button text await $('button*=Sub') Partial text await $('android=new UiSelector().text("Login")') Android (Appium) Do
- Prefer attributes that describe purpose, not appearance
- Ask for data-testid attributes on anything you automate
- Keep every selector in one place (page objects), never inline in tests
- Test your selector in dev tools before writing the test
- Use the shortest selector that is still unique
Don't
- Don't chain long paths: .a > div > div:nth-child(2) > span
- Don't select by index unless order is the thing you're testing
- Don't use auto-generated classes (css-1x2y3z) or IDs (ember-123)
- Don't copy the full XPath from dev tools; it breaks on the next layout change.
- Don't select by visible copy that marketing can change
From foundation to framework
Selectors are the foundation. Every modern tool adds its own conveniences on top, such as auto-waiting, scoping and accessible-name queries. Same idea, different surface. Pick a tool to see how each one builds on the basics.
Playwright
getByRole, getByLabel, getByText and getByTestId, plus page.locator() for CSS and XPath.
Prefer role and text locators. CSS and XPath are fallbacks. Locators are strict, so they throw if more than one element matches.
Auto-waits for actionability. Narrow with .filter({ has, hasText }), or combine with .and() and .or().
Cypress
cy.get() for CSS, cy.contains() for text, cy.find() and .within() to scope.
The official guidance is to add data-* attributes such as data-cy and select on those. Avoid tag, class and id used for styling.
@testing-library/cypress adds cy.findByRole() and friends. There is no native XPath, so it needs a plugin.
WebdriverIO
$ and $$ with CSS, XPath, link text (=text, *=text), aria/Name and [role].
Use stable attributes and the aria/ accessible-name selector. Chain $().$() to scope.
It pierces shadow DOM, and drives mobile through Appium (~accessibilityId, android=, ios predicate strings).
Tricky cases
Four situations where a plain selector needs help, and what each tool does about it.
Shadow DOM
CSS can reach into an open shadow root; XPath cannot. Playwright and WebdriverIO pierce it for you, Cypress needs extra setup.
Inside an iframe
A selector only sees its own document. Switch in first: Playwright frameLocator(), WebdriverIO switchToFrame(), Cypress via the iframe plugin.
Text that changes
Copy edits and translations break text selectors. For anything that gets translated, prefer a role with a stable name, or a test id.
One match at a time
Most actions expect a single element. Playwright treats more than one match as an error, which is useful: it flags a selector that is too broad.
Practice
The Locator Playground.
Read the task, write a selector, then run it. The tool highlights what your selector matches in the sample app, tells you whether you hit the right element, grades how resilient it is, and shows how Playwright, Cypress and WebdriverIO would target the same thing. There is usually more than one correct answer. Anything that matches counts.
Sign in
Users
| Name | Role | Status | Actions | |
|---|---|---|---|---|
| Mary Seacole | mary@yaad.io | Admin | Active | |
| Marcus Garvey | marcus@yaad.io | Editor | Active | |
| Louise Bennett | louise@yaad.io | Viewer | Suspended |
Invite teammate
Activity feed
- Ada posted a photo
- Grace posted a link
- Linus posted an update
Loading…
How the frameworks target this CSS and XPath are the foundation. This is the modern way.
The page only shows the visible UI. To see the attributes, roles and test ids you write selectors against, right-click an element in this app and choose Inspect (or press F12) to open your browser dev tools.