Writing Robust XPath Expressions

9 min read

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' without normalize-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 to normalize-space() whenever you match rendered text.
  • Reaching for XPath when CSS would do. By.xpath("//div[@id='login-form']//input[@name='email']") works, but By.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.

  1. 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()
  2. Translate each XPath into a Selenium test: XPathPracticeTest with one method per locator, asserting .isDisplayed() on each. All three should pass.
  3. 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 using ancestor::tr[1]. Confirm the row's getText() contains the cell's text.
  4. 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 @id or @name and watch it survive the same DOM edit.
  5. 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> using parent::li
    • Finds the entire nav using ancestor::nav Write a test that asserts each is displayed. Now you can navigate any DOM tree using XPath.

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.

// tip to track lessons you complete and pick up where you left off across devices.