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?
2. You need the Delete button in the table row whose first cell is 'Acme Corp'. Which locator expresses that relationship?
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?
4. Which locator is definitionally brittle and should never enter the repository?
Flashcards
Question
What are the core CSS selector forms for test locators, with one example each?
Click to reveal answer
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.
Click to reveal answer
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.
Click to reveal answer
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?
Click to reveal answer
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?
Click to reveal answer
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:
.btnmatches,[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:
- DevTools Ctrl+F → demand
1 of 1before committing - Scope: container → target (
//div[@data-testid='x']//button), never nth-child patches - Enforce: Playwright strict mode (default) / Selenium
findElements().size()==1lint
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.