Skip to content
beginner Phase 7 · Locators & Waits

CSS and XPath Locator Basics

Select elements with CSS selectors and XPath and confirm locator uniqueness.

45m
0 problems
Topic Progress 0%

CSS Selectors: id, class, attributes, and hierarchy in one syntax

A locator is a question you ask the DOM: "which element do I mean?" CSS selectors answer it with the same syntax the browser already uses for styling, which is their whole advantage — if it styles the page, it can find the element, and the browser evaluates it natively at speed.

The basic forms, with what each matches:

#login-form                 /* id="login-form" — one element by definition */
.submit-btn                 /* class="submit-btn" */
input[type="email"]         /* attribute equality */
input[required]             /* attribute presence, any value */
a[href^="/docs"]            /* prefix match: href starts with /docs */
a[href$=".pdf"]             /* suffix match */
[data-testid="user-menu"]   /* any attribute, custom-namespace friendly */
form.login input            /* descendant: any input inside .login form */
form > input                 /* child: direct child only */
li:nth-child(2)             /* position among siblings — brittle */
button:disabled             /* state pseudo-class */

Read form.login input carefully: it means "an input that is a descendant of a form carrying class login" — spaces are descendants, > is direct children. This hierarchy power is what makes CSS adequate for most test locators: instead of the lone input, you anchor to its container, which disambiguates the page-login email field from the newsletter email field without counting pixels or positions.

Attribute matching gets subtle exactly where dynamic apps live:

[aria-label="Close dialog"]
[placeholder^="Search"]
input:not([type="hidden"])
th > td:nth-of-type(3)

Two syntax traps appear constantly in test code. First, class lists: class="btn primary large" means [class="btn"] does not match — the attribute equals the whole string. Use .btn (token match), [class~="btn"], or a substring match [class*="primary"] when classes are multi-valued. Second, special characters: an id like user:name or checkout-2.0 needs escaping (#user\:name) or, far better, bracket syntax ([id="user:name"]) which takes the raw string. When a locator that "looks right" finds nothing, an unescaped colon or dot is the usual culprit.

Where CSS is weak, note it honestly so you know when to leave: it cannot select by visible text ("the button that says Save"), it cannot walk to a parent ("the row containing this checkbox"), and it has no equivalent of following-sibling for "the input after this label" without pattern-matching hacks on markup structure. Those three gaps — text, upward navigation, sideways relationships — are exactly what XPath's axes and text() functions were built for, and recognizing them on sight is half of locator fluency.

A practical exercise — locate the primary CTA on a typical page and count candidates in DevTools:

question:       "the blue Sign up button in the hero"
naive:          .btn                    → 14 matches (every button on the page)
scoped:         .hero .btn              → 2 matches (hero + cookie banner)
precise:        .hero a.btn[data-action="signup"]  → 1 match

In DevTools: Cmd/Ctrl+F inside the Elements panel searches the DOM
using CSS or //xpath syntax and reports "1 of 1" — do this BEFORE committing.

The search-counter habit is the single most valuable locator discipline: it converts "I think this matches" into "this matches exactly one element, today". When CSS has no way to express your target — a node defined by its text, or an element identified only by what surrounds it — that is the signal to switch syntaxes, which is what XPath adds.

XPath When CSS Runs Out: Text, Axes, and Conditions CSS Cannot Express

CSS matches structure and attributes; it cannot ask "the element whose text says X" or "the cell in the row whose first cell says Acme". XPath can, because it is a query language over the document tree rather than a pattern syntax — it selects nodes by evaluating expressions against their position, with axes that walk up, down, and sideways.

Start with the forms test authors actually use:

//input[@id='login']                       // any input with id=login
//button[text()='Submit']                  // exact text
//button[contains(text(),'Sub')]           // partial text
//a[contains(@href, '/settings')]          // attribute contains
//label[text()='Email']/following-sibling::input   // the input AFTER the label
//table//tr[td[1]='Acme']/td[3]            // 3rd cell of the row starting with Acme
//*[@role='alert']                         // any element with role=alert

The axes are the superpower CSS lacks:

ancestor::       walk UP        (find the row containing this delete button)
following-sibling::  sibling AFTER  (label → its input)
preceding-sibling::  sibling BEFORE (amount cell → its row header)
descendant::     walk DOWN      (scoped search inside a container)
parent::         immediate parent

A canonical real-world case: a table with no ids, where each row is a user and you must click Delete in the Acme row. CSS cannot state "the button in the row whose first cell is Acme" without positional hacks. XPath does it directly:

//table[@data-testid='users']//tr[td[1]='Acme Corp']//button[@aria-label='Delete']

Axis of caution — absolute paths. // at the front means "search from the document root", which is relative and flexible; a leading / (/html/body/div[3]/form/div[2]/input) is an absolute path pinned to every ancestor's position — one wrapper div added by a refactor and the locator points at thin air. Absolute XPath is the canonical brittle locator; if you ever find yourself writing it, stop and find the semantic anchor instead. Positional indexes (//div[3]) have the same problem in a milder form.

Practical workflow. DevTools' element search accepts both syntaxes (type // or a CSS prefix — the panel auto-detects //xpath style queries when they start with //), and the "1 of N" counter is your uniqueness check. In the Elements panel you can also right-click a node → Copy → Copy XPath (browser-specific output — Chrome gives you //*[@id="x"], Firefox may give an absolute path; treat either as a draft to rewrite, not final text).

Syntax differences that bite in code: XPath uses @ for attributes and single quotes inside double-quoted strings; CSS uses bare attribute names and double quotes. In Java, a locator string containing single quotes plays well:

driver.findElement(By.xpath("//button[contains(text(),'Save')]"));
driver.findElement(By.cssSelector("button[data-action='save']"));

A note on where these run: both languages are evaluated by the browser's own engine through the WebDriver findElement call (or Playwright's locator resolution), so there is no parsing penalty to worry about — microseconds either way. What differs is expressiveness and, in strict-mode frameworks, how ambiguity is reported. In Playwright, an XPath locator is created explicitly with page.locator("//button") or the dedicated page.getByText(...) helpers when text is all you need — and those role/text helpers are usually the better answer than raw XPath for the text case, as the next topic argues.

XPath is not inherently worse than CSS — that claim circulates as folklore. The real ranking: a stable absolute path is worse than any stable semantic selector, while a well-written relative XPath (//tr[td[1]='Acme']//button) is more precise than a positional CSS hack (tbody tr:nth-child(7) button). Precision and stability come from what you key on (identity, text, semantics), not from which language you type it in — which is the subject of Robust Locator Strategy. Until then, the rule of this topic: every locator gets verified for uniqueness before it enters the repository.

Proving Uniqueness: The Pre-Commit Check That Prevents Tomorrow's Flake

Here is the failure these techniques exist to prevent: a test passes for three sprints, then starts failing on alternate runs with "2 elements matched". Nothing in the app broke — a new promotional banner simply added a second Continue button, and the locator that was unique the day it was written quietly stopped being so. Uniqueness is not a property you establish once; it is a property you verify, and the verification costs thirty seconds.

The DevTools check. Open DevTools, press Ctrl/Cmd+F in the Elements panel, paste your locator, and read the counter:

"1 of 1"   → unique right now — commit it
"1 of 14"  → you have a class shared across the page — scope it
"0 of 0"   → syntax error or wrong context — fix before debugging the test

If the search language matters: CSS in the plain box, // prefix for XPath. The same check exists in each framework as a runtime behavior — Playwright's strict mode fails the test when a selector resolves to multiple elements (strict mode violation: locator resolved to 2 elements), which is a feature: strictness converts a silent wrong-element click into a loud error. Selenium's findElement picks the first match without complaint, which is why Selenium suites need the manual habit more.

Common reasons "unique" locators are not:

1. Auto-generated classes   .sc-bdfBwQ, .css-1x2j5u   → hash on every build; NEVER key on these
2. Multiple similar nodes    two "Continue" buttons (banner + checkout)
3. Hidden duplicates         desktop + mobile versions of the same form in one DOM
4. Dynamic counts            an ad slot or "sponsored" row that appears some days
5. Per-run data              rows keyed to records that exist only in some environments

Hidden duplicates (case 3) deserve special suspicion: findElement may return the mobile copy whose bounding box is display:none on desktop, producing the famous "element not interactable" failure that makes no sense until you notice findElements returned 2.

Scope before you narrow. When the page-wide search shows 14 matches, resist adding positional junk (:nth-child(7)) — instead climb to the nearest semantic container and search inside it:

//div[@data-testid='checkout-summary']//button[text()='Continue']

If even the container yields two, the container itself is duplicated (modal vs. inline summary) — move up again or add the distinguishing attribute. The general shape: container → within it, target → verify 1 of 1.

Automate the check. The DevTools counter is manual; frameworks can enforce it permanently. Playwright's strict mode does this by default for every locator (turn it off only with locator.first() after consciously accepting the ambiguity). For Selenium, a one-line uniqueness assertion in a locator-review helper or a dedicated "locator lint" test over critical selectors catches regressions:

assertEquals(1, driver.findElements(By.cssSelector("[data-testid='user-menu']")).size(),
             "user-menu locator must be unique");

Where this fits in the lifecycle. Uniqueness is checked when the locator is born (DevTools), enforced while tests run (strict mode), and re-checked after UI changes (the lint test fails in the same PR that adds the duplicate button). That is the full loop — and the moment you find yourself scoping, escaping, and re-verifying selectors more than writing business assertions, you have outgrown raw CSS and XPath: the next topic promotes locators from a per-test craft to a team-wide strategy.

Quiz

1. A page has `class="btn primary large"`. Which selector correctly matches this element among others with class `btn` only?

Question 1 options

2. You need the Delete button in the table row whose first cell is 'Acme Corp'. Which locator expresses that relationship?

Question 2 options

3. DevTools search shows your CSS locator matches '3 of 3'. Playwright's strict mode then fails the test with a strict mode violation. What did the framework do for you?

Question 3 options

4. Which locator is definitionally brittle and should never enter the repository?

Question 4 options

Flashcards

Question

What are the core CSS selector forms for test locators, with one example each?

Answer

id: `#login-form`; class: `.submit-btn`; attribute: `input[type='email']`, presence `[required]`, prefix/suffix `[href^='/docs']`, substring `[href*='docs']`; descendant vs child: `form.login input` (any depth) vs `form > input` (direct); state: `button:disabled`; position: `li:nth-child(2)` (brittle — avoid).

Question

What can XPath express that CSS cannot, and name the axes involved.

Answer

XPath can select by text and navigate the tree: text()/contains(text(),'…') for text identity, and axes — ancestor (up), parent, descendant (down), following-sibling/preceding-sibling (sideways). That enables 'the Delete button in the row whose first cell is Acme' — relationships CSS can only approximate with positional hacks.

Question

Absolute vs relative XPath — the difference and the rule.

Answer

Absolute starts at the root with a single slash (/html/body/…) and pins every ancestor's position — any layout refactor breaks it; never commit one. Relative starts with //, searching semantically from anywhere by id, text, or attribute (//input[@id='login']). Rewrite browser-generated absolute paths before use.

Question

How do you verify a locator before committing it?

Answer

Ctrl/Cmd+F in DevTools' Elements panel, paste the locator, require '1 of 1'. Then rely on runtime enforcement: Playwright strict mode fails multi-match resolutions loudly; for Selenium, assert findElements(...).size() == 1 in a locator-lint test. Re-run the check whenever UI markup changes.

Question

Why does `class="btn primary large"` break `[class="btn"]`, and what matches instead?

Answer

CSS attribute equality compares the entire attribute value, and the full string is 'btn primary large', not 'btn'. Use `.btn` (matches any token in the list), `[class~='btn']` (token equality), or `[class*='primary']` (substring). Lesson: classes are multi-valued tokens — key on one token or, better, on data-testid.

Revision Notes

Key Takeaways

  • 1. CSS covers ids, classes, attributes, hierarchy, and state with browser-native speed — the everyday locator language for tests
  • 2. XPath earns its place for text-based and relational targets (row→button, label→input) via text() and the sibling/ancestor axes
  • 3. Absolute paths and positional indexes encode layout, not identity — one wrapper div added by a refactor breaks them silently
  • 4. Uniqueness is verified (DevTools 1-of-1), enforced (strict mode), and re-verified (locator lint) — the loop that prevents '2 elements matched' flakes
  • 5. Brittleness comes from keying on generated classes and positions; key on semantic anchors and attributes instead

Interview Tips

  • • "CSS or XPath — which do you prefer?" — CSS by default for structure/attributes; XPath when the target is defined by text or relationship; reject the folklore that XPath is inherently fragile — absolute paths are the problem, not the language
  • • "How do you handle dynamic elements?" — anchor on stable attributes (id, name, data-testid, aria), scope to containers, avoid generated classes; then mention strict-mode enforcement
  • • "A locator matched two elements — what happened?" — the page gained a sibling (banner/modal/mobile copy); show the fix ladder: scope by container → add semantic attribute → verify 1-of-1
  • • "Demonstrate finding a cell in a specific table row." — write //tr[td[1]='value']//button live, and explain why nth-child(7) fails on sort/insert

Cheat Sheet

CSS & XPath Locator Basics

CSS quick reference:

#id  .class  [attr='v']  [attr]  [attr^='p']  [attr$='s']  [attr*='x']
form.login input      /* descendant */
form > input          /* direct child */
button:disabled       /* state */
li:nth-child(2)       /* position — brittle */
  • class lists are tokens: .btn matches, [class='btn'] does NOT
  • escapes: [id='user:name'] beats #user\:name

XPath quick reference:

//input[@id='login']
//button[text()='Submit'] | //button[contains(text(),'Sub')]
//label[text()='Email']/following-sibling::input
//table//tr[td[1]='Acme Corp']//button[@aria-label='Delete']

Axes: ancestor · parent · descendant · following/preceding-sibling.
Never commit absolute paths (/html/body/…) or positional indexes as identity.

Uniqueness loop:

  1. DevTools Ctrl+F → demand 1 of 1 before committing
  2. Scope: container → target (//div[@data-testid='x']//button), never nth-child patches
  3. Enforce: Playwright strict mode (default) / Selenium findElements().size()==1 lint

Watch for: auto-generated classes (.css-1x2j5u), duplicate desktop/mobile nodes, 'Continue' everywhere, per-run data variance.

Ranking rule: stability comes from WHAT you key on (id, role, text, testid), not from CSS vs XPath language choice.

Continue Learning

Related Reading