XPath is what you reach for when CSS can't get you there. It's the only way to match by visible text, walk up the DOM tree, or pick the previous sibling of an element. It's also the locator strategy most likely to be written badly — the kind of XPath that passes today and breaks the moment a designer adds a wrapper <div>. This lesson is the long form on writing XPath that survives. We'll cover the syntax, the axes, the patterns experienced QA engineers reach for, and the absolute-XPath habit you must train yourself out of.
Absolute vs relative — never use absolute
XPath comes in two flavours. One is essentially always wrong:
// Absolute XPath — DON'T DO THIS. Generated by "Copy XPath" in DevTools.
driver.findElement(By.xpath("/html/body/div[3]/div/main/section[2]/form/button"));
// Relative XPath — what you should always write.
driver.findElement(By.xpath("//button[@type='submit']"));Absolute XPath starts with / and walks the entire DOM from <html> down. Any developer who adds a wrapper element anywhere along the path breaks it. The "Copy XPath" option Chrome DevTools offers is a trap for beginners — it produces absolute paths that are guaranteed to rot. Always write relative XPath, starting with //.
The XPath cheat sheet you'll actually use
Six patterns cover most of what you'll write:
// Match by attribute
driver.findElement(By.xpath("//button[@type='submit']"));
// Multiple attributes (AND)
driver.findElement(By.xpath("//div[@class='product' and @data-status='active']"));
// Partial attribute match — useful for hashed classes
driver.findElement(By.xpath("//div[contains(@class, 'product-card')]"));
// Starts-with
driver.findElement(By.xpath("//input[starts-with(@id, 'user-')]"));
// Match by visible text — XPath's superpower
driver.findElement(By.xpath("//button[text()='Submit']"));
// Match by partial text
driver.findElement(By.xpath("//button[contains(text(), 'Sign')]"));
// Normalised text — handles whitespace
driver.findElement(By.xpath("//label[normalize-space()='Email address']"));
// Nth element — 1-indexed (unlike most things in Java)
driver.findElement(By.xpath("(//div[@class='product-card'])[3]"));Three of these — text(), contains(text(), ...), and normalize-space() — are the reasons people install XPath at all. CSS can't match by visible text. If your dev team hasn't added data-testid attributes and the only stable identifier on a button is the word "Submit," XPath is the answer.
normalize-space() deserves a special mention: it strips leading/trailing whitespace and collapses internal runs. Real HTML often has subtle whitespace (newlines after the opening tag, indentation) that breaks naive text() comparisons. Default to normalize-space() whenever you match against rendered text:
// Brittle — fails if the dev formats with leading whitespace
By.xpath("//label[text()='Email address']");
// Robust — survives whitespace variation
By.xpath("//label[normalize-space()='Email address']");XPath axes — navigating the DOM tree
XPath sees the DOM as a tree. Axes are the directions you can walk along it. Six are worth knowing:
The axes earn their keep when the element you want has no stable selector of its own but sits next to something that does. The classic example is finding an input by its label:
// "The input that follows the label whose visible text is 'Email'"
WebElement emailInput = driver.findElement(By.xpath(
"//label[normalize-space()='Email']/following-sibling::input"
));Read it left to right: find any <label> whose normalised text is Email, then walk to its next sibling that's an <input>. Notice the user-mental-model: this is exactly how a person finds the field, anchoring on the visible label rather than the implementation.
Five XPath patterns you'll write a hundred times
// 1. Button containing specific text
By.xpath("//button[normalize-space()='Submit']");
By.xpath("//button[contains(., 'Sign in')]");
// 2. Input next to a label
By.xpath("//label[normalize-space()='Email']/following-sibling::input");
// 3. Element with multiple conditions
By.xpath("//div[@class='product' and @data-status='active']");
// 4. The nth instance of something — wrap with parens, then index
By.xpath("(//div[@class='product-card'])[3]");
// 5. Parent of an error message — useful for "the row containing this error"
By.xpath("//span[normalize-space()='Out of stock']/ancestor::tr[1]");Pattern 5 is the one Cypress and Playwright users miss most when they switch. In Cypress you'd write cy.contains('Out of stock').parents('tr'). In Selenium with CSS, you can't — CSS has no parent selector. XPath's ancestor:: axis is the way.
Testing XPath in the browser
You don't need to run a Java test to know if your XPath works. Open DevTools (F12 in Chrome) and use the console:
$x("//button[@type='submit']") // returns an array of matching elements
$x("//label[normalize-space()='Email']/following-sibling::input")$x(...) is built into Chrome and Firefox DevTools. If it returns [ element ] your XPath is good; if it returns [] you have a bug to fix. Iterate XPath in the console first, paste it into Java second. This single habit saves hours.
A test that uses every axis
package com.mycompany.tests.tests;
import io.github.bonigarcia.wdm.WebDriverManager;
import org.openqa.selenium.By;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.chrome.ChromeDriver;
import org.testng.Assert;
import org.testng.annotations.AfterMethod;
import org.testng.annotations.BeforeMethod;
import org.testng.annotations.Test;
public class XPathPatternsTest {
WebDriver driver;
@BeforeMethod
public void setup() {
WebDriverManager.chromedriver().setup();
driver = new ChromeDriver();
driver.get("https://www.saucedemo.com");
}
@Test
public void shouldFindUsernameInputByLabelText() {
// No standalone label here — this is illustrative of the pattern
WebElement username = driver.findElement(
By.xpath("//input[@id='user-name']")
);
Assert.assertTrue(username.isDisplayed());
}
@Test
public void shouldFindLoginButtonByVisibleText() {
WebElement loginBtn = driver.findElement(
By.xpath("//input[@type='submit' and @value='Login']")
);
Assert.assertTrue(loginBtn.isEnabled());
}
@Test
public void shouldFindThirdProductCardAfterLogin() {
// Log in first
driver.findElement(By.id("user-name")).sendKeys("standard_user");
driver.findElement(By.id("password")).sendKeys("secret_sauce");
driver.findElement(By.id("login-button")).click();
// Third product card via positional XPath
WebElement third = driver.findElement(
By.xpath("(//div[@class='inventory_item'])[3]")
);
Assert.assertTrue(third.isDisplayed());
}
@AfterMethod
public void teardown() {
if (driver != null) driver.quit();
}
}Each test exercises a different XPath pattern. Run all three; all three should pass.
Anti-patterns — XPath that will betray you
Some XPath looks reasonable and is actually a time bomb:
// Position-based — breaks the moment any element is added
By.xpath("//div[3]/div[2]/span[1]");
// Absolute — Copy XPath from DevTools produces this
By.xpath("/html/body/div[3]/div/main/form/button");
// Matching on a hashed class
By.xpath("//div[@class='css-1a2b3c']");
// Long chains that depend on every link surviving
By.xpath("//div[@class='wrap']/div/section/form/div/input[@type='email']");Each of these will pass today. Each of these will fail the next time the dev team merges a PR that adds <div class="container"> somewhere along the chain. The fix in every case is to anchor on something durable — an ID, a data-testid, a stable visible text — and use the minimum number of steps to reach the element.
The XPath & CSS selectors cheat sheet on qa.codes collects every function and axis worth memorising.
⚠️ Common mistakes
- Copy-pasting absolute XPath from DevTools. "Copy XPath" produces strings like
/html/body/div[3]/div[2]/main/form/button— guaranteed to break the next time the layout changes. Always rewrite as relative XPath anchored on a stable attribute or visible text. - Using
text()='exact'withoutnormalize-space(). The HTML often has whitespace your eyes don't see — newlines after the opening tag, indentation, trailing spaces.text()='Submit'fails when the actual text is\n Submit\n. Default tonormalize-space()whenever you match rendered text. - Reaching for XPath when CSS would do.
By.xpath("//div[@id='login-form']//input[@name='email']")works, butBy.cssSelector("#login-form input[name='email']")is shorter, faster, and just as readable. Pick CSS unless XPath gives you something CSS can't (text matching, parent walking, previous sibling).
🎯 Practice task
Master XPath on a real form. 30–40 minutes.
- Open https://practice.expandtesting.com/login (a public practice site). With DevTools open, use
$x(...)in the console to write XPath for each:- The Username input, anchored on the visible label "Username" via
following-sibling:: - The Password input, anchored on its visible label
- The Login button, by visible text using
normalize-space()
- The Username input, anchored on the visible label "Username" via
- Translate each XPath into a Selenium test:
XPathPracticeTestwith one method per locator, asserting.isDisplayed()on each. All three should pass. - Walk up the tree. After logging in (use
practice/SuperSecretPassword!as credentials), navigate to a page with a table or list. Pick any cell and write XPath that selects its parent row usingancestor::tr[1]. Confirm the row'sgetText()contains the cell's text. - Reproduce a flaky locator. Write a deliberately fragile XPath like
//div[3]/div[2]/form/input[1]. Run the test. Now open the page in a browser, inspect the DOM, and add or remove a<div>(you can edit the DOM live in DevTools). Refresh, re-run. Watch it break. Then rewrite it anchored on@idor@nameand watch it survive the same DOM edit. - Stretch — XPath axes drill. On any page with a navigation menu, write XPath that:
- Finds the active link using
[contains(@class, 'active')] - Finds the next sibling link using
following-sibling::a[1] - Finds the parent
<li>usingparent::li - Finds the entire nav using
ancestor::navWrite a test that asserts each is displayed. Now you can navigate any DOM tree using XPath.
- Finds the active link using
Next lesson: CSS selectors in equivalent depth. We'll cover the patterns CSS does support better than XPath, and finalise the rule for which to reach for first.