XPath Cheat Sheet

Every pattern that matters for test automation, in one printable page. XPath 1.0, which is what browsers run.

Robustness Rules

Follow these rules for selectors that survive DOM changes:

DO

  • ✓ Use data-testid attributes
  • ✓ Use relative paths (//)
  • ✓ Anchor to landmarks, headings, forms
  • ✓ Target by unique attributes and text
  • ✓ Use normalize-space() for labels
  • ✓ Match class tokens with concat()
  • ✓ Keep two or three meaningful steps
  • ✓ Filter containers by content (.//)

DON'T

  • ✗ Use auto-generated ids or class hashes
  • ✗ Use absolute paths from /html
  • ✗ Anchor to layout divs
  • ✗ Use position indexes [1], [2] unless order is the point
  • ✗ Use text()='...' on formatted markup
  • ✗ Match exact multi-class strings
  • ✗ Chain five layout steps
  • ✗ Restart at the root inside predicates (//)

Basic Selectors

//tagname

All elements with this tag name, anywhere

/html/body

Absolute path from the root (avoid in tests)

//*

Every element (wildcard)

//ul/li

Direct children only

//form//input

Descendants at any depth

//div | //span

Union of two node-sets

.

Context node (use .//x inside predicates and element searches)

..

Parent node

//@href

Attribute nodes themselves

Attribute Selectors

//*[@id='value']

By ID

//*[@data-testid='login-btn']

By test id (recommended)

//input[@name='email']

By name (form fields)

//*[@aria-label='Close']

By ARIA label (accessible name)

//*[@role='dialog']

By ARIA role

//input[@type='text']

By attribute value (exact, case-sensitive)

//*[@data-testid]

Attribute exists, any value

//*[not(@disabled)]

Attribute absent

//*[@class='value']

Exact full class string (rarely what you want)

//*[contains(concat(' ', normalize-space(@class), ' '), ' btn ')]

Exact class token, like CSS .btn

Partial Matching

//*[contains(@class, 'btn')]

Attribute contains substring

//*[starts-with(@id, 'user-')]

Attribute starts with

//a[substring(@href, string-length(@href) - string-length('.pdf') + 1) = '.pdf']

Attribute ends with (no ends-with() in XPath 1.0)

//a[contains(@href, '/login')]

Href contains

//*[contains(@class, 'btn') and contains(@class, 'primary')]

Multiple class conditions

//input[starts-with(@id, 'mat-input-')]

Stable stem of a generated id (combine with context)

Text Content

//button[normalize-space()='Submit']

Visible label, trimmed, includes nested text (best default)

//button[contains(., 'Add')]

Partial match on whole string value

//button[text()='Submit']

Exact direct text node (fails on whitespace or nested spans)

//button[contains(text(), 'Add')]

Partial match on first text node only

//p[text()[contains(., '10:42')]]

Any of several text nodes contains

//li[normalize-space(text())='Fruit']

Element's own text, ignoring children

//button[starts-with(normalize-space(), 'Save')]

Text starts with

//button[translate(normalize-space(), 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz')='sign in']

Case-insensitive (no lower-case() in 1.0)

//a[text()="It's here"]

Apostrophe: switch quote type

//a[text()=concat('It', "'", 's ', '"quoted"')]

Both quote types: build with concat()

Position & Indexing

(//button)[1]

First button in the document (global)

//ul/li[1]

First li child of EACH ul (per parent)

(//ul/li)[last()]

Last li in the document

//ul/li[last()-1]

Second-to-last li of each ul

(//tr)[position() > 1]

All rows except the first

(//div)[position() <= 3]

First three divs

//li[position() mod 2 = 0]

Even items

//button/ancestor::div[1]

NEAREST ancestor div (reverse axis counts backwards)

//td/preceding-sibling::td[1]

Cell immediately to the left

Axes Navigation

//div/child::span

Direct children (same as //div/span)

//form/descendant::input

Descendants (same as //form//input)

//input/parent::div

Direct parent (same as ..)

//input/ancestor::form[1]

Nearest ancestor form

//label/following-sibling::input[1]

Next sibling input

//input/preceding-sibling::label[1]

Previous sibling label

//h2[normalize-space()='Billing']/following::input[1]

First input anywhere after the heading

//td/preceding::th

Everything before in document order

//*[self::button or self::a]

Self axis in a predicate (element is button or a)

Tables

//table/tbody/tr

All body rows

//table//tr[td]

Data rows (skip header rows with th)

//tr[td[normalize-space()='John']]

Row containing a cell with text

//tr[td[normalize-space()='John']]//button

Button in that row

//tr[td[1][normalize-space()='John']]/td[3]

Third cell of the John row (fixed column)

//tr[td[normalize-space()='John']]/td[count(//th[normalize-space()='Email']/preceding-sibling::th)+1]

Cell by row content and column header (survives reordering)

count(//th[normalize-space()='Name']/preceding-sibling::th)+1

Column index of a header

//tr[count(td) > 3]

Rows with more than three cells

(//tbody/tr)[last()]/td[2]

Second cell of the last row

Form Elements

//input[@type='email']

Email input

//input[@id=//label[normalize-space()='Email']/@for]

Input via label for/id association

//label[normalize-space()='Email']/following-sibling::input[1]

Input after its label

//label[contains(., 'Remember me')]//input[@type='checkbox']

Checkbox inside a wrapping label

//input[@type='radio'][@name='plan'][@value='pro']

Radio by group and value

//select[@name='country']/option[normalize-space()='Germany']

Option by visible text

//select[@name='country']/option[@selected]

Initially selected option

//button[@type='submit']

Submit button

//form[@id='login']//button[@type='submit']

Submit button scoped to a form

//input[@required]

Required fields

//input[not(@disabled)]

Enabled inputs

//input[@name='email']/following-sibling::*[contains(@class, 'error')]

Validation message next to a field

//input[@aria-invalid='true']

Fields flagged invalid

Lists, Cards, Dialogs & Navigation

//article[.//h3[normalize-space()='USB-C Hub']]//button

Control inside the card with that title

//li[.//span[normalize-space()='Pending']]//button

Control inside the list item with that badge

//span[normalize-space()='Sold out']/ancestor::article[1]

Card from something inside it

//*[@role='dialog']//button[normalize-space()='Save']

Button inside an open dialog, not the page duplicate

//*[@role='dialog'][.//h2[normalize-space()='Edit profile']]

Dialog identified by its title

//nav[@aria-label='Main']//a[normalize-space()='Pricing']

Link in a specific navigation landmark

//a[@aria-current='page']

Current page link

//*[@role='tab'][normalize-space()='Billing']

Tab by role and name

//*[@role='menuitem'][normalize-space()='Pending']

Item in an open menu

//*[@role='alert' or @role='status']

Toasts and live regions

//ul[count(li) > 5]

Lists with more than five items

SVG & Namespaces

//*[local-name()='svg']

Inline SVG (//svg matches nothing in HTML)

//*[local-name()='svg'][@data-icon='trash']

Icon by data attribute (attributes are not namespaced)

//button[.//*[local-name()='svg'][@data-icon='trash']]

Button containing an icon

//*[local-name()='svg'][@data-icon='trash']/ancestor::button[1]

From the icon up to its button

//*[local-name()='rect'][@data-quarter='Q3']

Element inside an SVG chart

//*[local-name()='item']

Namespaced XML elements (feeds, SOAP)

//*[namespace-uri()='http://www.w3.org/2000/svg']

Everything in the SVG namespace

Conditions & Logic

//input[@type='text' and @required]

AND

//button[@type='submit' or @type='button']

OR

//div[not(@hidden)]

NOT (a function, needs parentheses)

//input[@type='text'][@name='email']

Chained predicates (same as AND for attributes)

//tr[td[@class="error"]]

Has a child matching (existence)

//form[.//input[@type="password"]]

Contains somewhere inside (note the dot)

//div[not(.//input)]

Contains no inputs

//ul[not(li/@class='done')]

"None of the children equal" (do NOT use !=)

//ul[li/@class!='done']

"Some child differs" (existential; usually not what you mean)

//tr[td[3] > 100]

Numeric comparison on cell text

//input[string-length(normalize-space(@value)) > 0]

Non-empty value

XPath 1.0 Functions (all 27)

count(//li)

Node-set: count, last(), position(), id(), local-name(), namespace-uri(), name()

normalize-space(//h1)

String: string, concat, starts-with, contains, substring-before, substring-after, substring, string-length, normalize-space, translate

translate(., 'ABC', 'abc')

Map characters; delete those with no counterpart

substring(@id, 1, 4)

Substring is 1-based

substring-after(@id, 'row-')

Text after a separator

not(@disabled)

Boolean: boolean, not, true, false, lang

sum(//td[@class="qty"])

Number: number, sum, floor, ceiling, round

string(//title)

String value of the first node

boolean(//div[@role='alert'])

True if the node-set is non-empty

Not in XPath 1.0 (and the workaround)

ends-with(@href, '.pdf') → substring(@href, string-length(@href) - string-length('.pdf') + 1) = '.pdf'

ends-with() is XPath 2.0

lower-case(.) → translate(., 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz')

lower-case() / upper-case() are XPath 2.0

matches(@id, '^row-\d+$') → starts-with(@id, 'row-') plus checks in test code

matches() / replace() / tokenize() are XPath 2.0

if (...) then ... else ... → restructure with predicates or two locators

Conditionals are XPath 2.0

//a[@href $= '.pdf'] → see ends-with workaround

CSS attribute operators do not exist in XPath

//div.card → //div[contains(@class, 'card')]

CSS class syntax does not exist in XPath

Framework Syntax

driver.findElement(By.xpath("//button"))

Selenium Java (Python: By.XPATH; C#: By.XPath)

element.findElement(By.xpath(".//button"))

Selenium: search inside an element (the dot matters)

page.locator('xpath=//button') or page.locator('//button')

Playwright (auto-detects //; use the prefix for expressions starting with parentheses)

page.frameLocator('iframe[title=Login]').locator('//input')

Playwright inside an iframe

cy.xpath('//button')

Cypress with the cypress-xpath plugin

await page.$('xpath/.//button')

Puppeteer

await $('//button')

WebdriverIO (auto-detected)

Click Element xpath=//button

Robot Framework SeleniumLibrary

$x("//button")

DevTools console

driver.findElement(AppiumBy.xpath("//android.widget.Button[@text='LOG IN']"))

Appium Android (prefer accessibilityId)

tree.xpath('//h2/text()')

lxml / parsel / Scrapy (returns strings)

XPath Axes Diagram

ancestor:: (ancestor-or-self::)

↑

preceding:: ← preceding-sibling:: [ self:: ] following-sibling:: → following::

↓

child:: → descendant:: (descendant-or-self::)

attribute:: = @ | namespace:: = rarely used | reverse axes (ancestor, preceding, preceding-sibling) count from nearest: [1] is closest

Attribute Preference Order

1

data-testid, data-test, data-cy

Exists only for testing; nothing else depends on it

2

ARIA role plus accessible name

Changing it changes what assistive technology announces

3

name (form fields), aria-label

Developer-authored and semantic

4

id (if static, not generated)

Reliable when a human wrote it; watch for counters and hashes

5

Visible text via normalize-space()

Changes with copy; fine for controls users identify by label

6

Semantic attributes: type, href, placeholder

Stable but rarely unique alone

7

Class names (semantic only)

Never utility or generated classes

8

Position indexes and layout paths

Last resort; scope to a container