Blog

Dynamic XPath in Selenium Explained With Practical Examples

Adwitiya Pandey

Published on

Table of contents

Lorem ipsum

Lorem ipsum

Lorem ipsum

Lorem ipsum

Lorem ipsum

Dynamic XPath lets Selenium find elements whose attributes keep changing. It works, but it comes at a cost. The expressions get complicated, they need constant attention, and every one of them is still a single guess about where an element lives. Functions like contains(), starts-with() and the XPath axes help you survive dynamic pages, but they're workarounds for a deeper problem: relying on one locator in an application that never stops changing.

This guide walks through the XPath techniques that actually hold up in Selenium, with working examples for each. It also covers where XPath runs out of road, and why more teams are moving to element identification that doesn't depend on a single expression.

What is Dynamic XPath in Selenium?

Dynamic XPath is an XPath expression that uses functions, conditions and partial matching to find elements whose attributes change. A static XPath depends on fixed values. A dynamic one targets the part of the element that stays the same, so it can cope with auto-generated IDs, shifting class names and unstable page structures.

The Dynamic Element Problem

Modern applications built with React, Vue or Angular often generate element attributes at runtime. Here's the same button in two different sessions:

<!-- Session 1 -->
<button id="submit_btn_47392" class="btn btn-primary css-1x9f3ka">Sign in</button>

<!-- Session 2 -->
<button id="submit_btn_85120" class="btn btn-primary css-7qp2mzd">Sign in</button>
<!-- Session 1 -->
<button id="submit_btn_47392" class="btn btn-primary css-1x9f3ka">Sign in</button>

<!-- Session 2 -->
<button id="submit_btn_85120" class="btn btn-primary css-7qp2mzd">Sign in</button>
<!-- Session 1 -->
<button id="submit_btn_47392" class="btn btn-primary css-1x9f3ka">Sign in</button>

<!-- Session 2 -->
<button id="submit_btn_85120" class="btn btn-primary css-7qp2mzd">Sign in</button>
<!-- Session 1 -->
<button id="submit_btn_47392" class="btn btn-primary css-1x9f3ka">Sign in</button>

<!-- Session 2 -->
<button id="submit_btn_85120" class="btn btn-primary css-7qp2mzd">Sign in</button>

A static XPath like this works once and then breaks:

//button[@id='submit_btn_47392']
//button[@id='submit_btn_47392']
//button[@id='submit_btn_47392']
//button[@id='submit_btn_47392']

Dynamic XPath targets the stable part of the ID instead:

//button[contains(@id, 'submit_btn')]
//button[contains(@id, 'submit_btn')]
//button[contains(@id, 'submit_btn')]
//button[contains(@id, 'submit_btn')]

Why XPath Matters in Selenium

XPath (XML Path Language) is the most powerful locator strategy in Selenium because it:

  • Move in any direction through the DOM, including up to parents and ancestors, across to siblings, and down to children and descendants

  • Combine several conditions with and, or and not()

  • Find elements by their visible text when attributes are unreliable

  • Handle complex nested structures where simple CSS selectors struggle

Modern browsers now support the CSS :has() selector, which closes some of that gap. XPath is still the better fit for text matching and multi-step relationships, though. The trade-off is that without discipline, XPath expressions quickly become unreadable and fragile.

XPath Types: Absolute vs Relative

  1. Absolute XPath

Absolute XPath spells out the full path from the root <html> element down to the target:

/html/body/div[2]/div[1]/form/div[3]/button
/html/body/div[2]/div[1]/form/div[3]/button
/html/body/div[2]/div[1]/form/div[3]/button
/html/body/div[2]/div[1]/form/div[3]/button

Problems:

  • Breaks when page structure changes

  • Verbose and unreadable

  • Slower execution as Selenium traverses entire path

  • Maintenance nightmare in dynamic applications

When to use: Never in production automation. Only for one-off debugging.

  1. Relative XPath

Relative XPath starts with // and searches from anywhere in the document:

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

The benefits are:

  • It survives most structural changes around the element

  • It's readable, and describes the element by what it is

  • It's the standard for all maintainable automation scripts

Best practice: Always start with // and anchor the expression to something meaningful and stable.

Dynamic XPath Functions and Techniques

  1. Using contains() for Partial Matches

contains() matches elements where an attribute includes a given substring. It's the go-to function for dynamic IDs and classes.

Syntax: contains(@attribute, 'value')

Example 1: Dynamic ID

//button[contains(@id, 'submit_btn')]
//button[contains(@id, 'submit_btn')]
//button[contains(@id, 'submit_btn')]
//button[contains(@id, 'submit_btn')]

This matches submit_btn_47392, submit_btn_85120 and any other variant.

Example 2: Dynamic Class with Multiple Values

//div[contains(@class, 'alert-error')]
//div[contains(@class, 'alert-error')]
//div[contains(@class, 'alert-error')]
//div[contains(@class, 'alert-error')]

Watch out here. contains(@class, 'alert') would also match alert-error, alert-success and no-alert. To match one whole class name exactly, pad the class list with spaces:

//div[contains(concat(' ', normalize-space(@class), ' '), ' alert ')]
//div[contains(concat(' ', normalize-space(@class), ' '), ' alert ')]
//div[contains(concat(' ', normalize-space(@class), ' '), ' alert ')]
//div[contains(concat(' ', normalize-space(@class), ' '), ' alert ')]

Example 3: Combining Multiple Contains Conditions

//div[contains(concat(' ', normalize-space(@class), ' '), ' alert ')]
//div[contains(concat(' ', normalize-space(@class), ' '), ' alert ')]
//div[contains(concat(' ', normalize-space(@class), ' '), ' alert ')]
//div[contains(concat(' ', normalize-space(@class), ' '), ' alert ')]

Caution: contains() can match more than you intended. Always check the expression returns exactly one element, either in the browser console with $x("your xpath") or with driver.findElements().

  1. Using starts-with() for Predictable Prefixes

starts-with() matches elements where an attribute begins with a specific value.

Syntax: starts-with(@attribute, 'value')

Example 1: ID with Consistent Prefix

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

Example 2: Data Attributes

//div[starts-with(@data-testid, 'product-card-')]
//div[starts-with(@data-testid, 'product-card-')]
//div[starts-with(@data-testid, 'product-card-')]
//div[starts-with(@data-testid, 'product-card-')]

When to use it: When the dynamic part of the value always appears at the end. It's more precise than contains(), because it can't accidentally match the same text in the middle of a value.

  1. Using text() for Content-Based Location

text() finds elements by their text content, which is often the most stable thing about an element because it's what users actually see.

Syntax: text()='exact text'

Example 1: Exact Text Match

//button[text()='Sign in']
//button[text()='Sign in']
//button[text()='Sign in']
//button[text()='Sign in']

Example 2: Partial text match

//a[contains(text(), 'Forgot')]
//a[contains(text(), 'Forgot')]
//a[contains(text(), 'Forgot')]
//a[contains(text(), 'Forgot')]

Example 3: Case-Sensitive Matching

XPath text matching is always case-sensitive:

//button[text()='Login']   <!-- matches "Login" -->
//button[text()='login']   <!-- does NOT match "Login" -->
//button[text()='Login']   <!-- matches "Login" -->
//button[text()='login']   <!-- does NOT match "Login" -->
//button[text()='Login']   <!-- matches "Login" -->
//button[text()='login']   <!-- does NOT match "Login" -->
//button[text()='Login']   <!-- matches "Login" -->
//button[text()='login']   <!-- does NOT match "Login" -->

Whitespace gotcha: text()='Login' won't match " Login " with surrounding spaces or line breaks, which templating frameworks often add. Use normalize-space() instead:

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

There's a second, subtler gotcha. text() only looks at the element's own text nodes. If the text sits inside a child element, like <button><span>Login</span></button>, then //button[text()='Login'] finds nothing. normalize-space() with no argument reads all the text inside the element, including children, so it handles both cases.

  1. XPath Axes for Navigating Relationships

Axes locate elements by their relationship to other elements, rather than by their own attributes. This is especially useful when the target element has nothing stable about it, but something next to it does.

Following-Sibling Axis

This selects siblings that come after the reference element.

Syntax: following-sibling::tagname

Example 1: Select the input after a label
//label[normalize-space()='Email']/following-sibling::input
//label[normalize-space()='Email']/following-sibling::input
//label[normalize-space()='Email']/following-sibling::input
//label[normalize-space()='Email']/following-sibling::input
Example 2: Select the second button after a heading
//span[normalize-space()='Actions']/following-sibling::button[2]
//span[normalize-space()='Actions']/following-sibling::button[2]
//span[normalize-space()='Actions']/following-sibling::button[2]
//span[normalize-space()='Actions']/following-sibling::button[2]

Preceding-Sibling Axis

This selects siblings that come before the reference element.

//button[normalize-space()='Next']/preceding-sibling::button
//button[normalize-space()='Next']/preceding-sibling::button
//button[normalize-space()='Next']/preceding-sibling::button
//button[normalize-space()='Next']/preceding-sibling::button

Parent Axis

This moves up to the direct parent.

//input[@name='email']/parent::div
//input[@name='email']/parent::div
//input[@name='email']/parent::div
//input[@name='email']/parent::div

You can also write it as //input[@name='email']/..

Ancestor Axis

This selects any ancestor, such as the parent, grandparent and so on up to the root. It's very useful for tables:

//button[normalize-space()='Delete']/ancestor::tr
//button[normalize-space()='Delete']/ancestor::tr
//button[normalize-space()='Delete']/ancestor::tr
//button[normalize-space()='Delete']/ancestor::tr

A more practical version finds the Delete button in the row for a specific customer:

//td[normalize-space()='Jane Smith']/ancestor::tr//button[normalize-space()='Delete']
//td[normalize-space()='Jane Smith']/ancestor::tr//button[normalize-space()='Delete']
//td[normalize-space()='Jane Smith']/ancestor::tr//button[normalize-space()='Delete']
//td[normalize-space()='Jane Smith']/ancestor::tr//button[normalize-space()='Delete']

Child and Descendant Axes

child:: selects direct children only. descendant:: selects anything nested at any depth.

//ul[@id='main-menu']/child::li
//table[@id='orders']/descendant::td[normalize-space()='Paid']
//ul[@id='main-menu']/child::li
//table[@id='orders']/descendant::td[normalize-space()='Paid']
//ul[@id='main-menu']/child::li
//table[@id='orders']/descendant::td[normalize-space()='Paid']
//ul[@id='main-menu']/child::li
//table[@id='orders']/descendant::td[normalize-space()='Paid']

/ is shorthand for child::, and // is shorthand for descendant-or-self::.

  1. Logical Operators: AND, OR, NOT

These combine conditions for more precise targeting.

and operator

//input[@type='text' and @name='username']
//input[@type='text' and @name='username']
//input[@type='text' and @name='username']
//input[@type='text' and @name='username']

or operator

//button[@id='submit' or @name='submit']
//button[@id='submit' or @name='submit']
//button[@id='submit' or @name='submit']
//button[@id='submit' or @name='submit']

not() function

//input[not(@disabled)]
//div[contains(@class, 'item') and not(contains(@class, 'archived'))]
//input[not(@disabled)]
//div[contains(@class, 'item') and not(contains(@class, 'archived'))]
//input[not(@disabled)]
//div[contains(@class, 'item') and not(contains(@class, 'archived'))]
//input[not(@disabled)]
//div[contains(@class, 'item') and not(contains(@class, 'archived'))]

Complex Combination

This finds the Edit button in any table row marked Active that isn't archived:

This finds the Edit button in any table row marked Active that isn't archived:
This finds the Edit button in any table row marked Active that isn't archived:
This finds the Edit button in any table row marked Active that isn't archived:
This finds the Edit button in any table row marked Active that isn't archived:
  1. XPath Indexing for Multiple Matches

When an expression matches several elements, indexing picks one.

Syntax: (xpath)[index]. XPath indexing starts at 1, not 0.

(//button[normalize-space()='Add to cart'])[2]
(//table[@id='orders']//tr)[last()]
(//button[normalize-space()='Add to cart'])[2]
(//table[@id='orders']//tr)[last()]
(//button[normalize-space()='Add to cart'])[2]
(//table[@id='orders']//tr)[last()]
(//button[normalize-space()='Add to cart'])[2]
(//table[@id='orders']//tr)[last()]

Important: indexing inside and outside the brackets behaves differently.

//ul/li[1]      <!-- the first <li> in EVERY <ul> on the page -->
(//ul/li)[1]    <!-- the first <li> on the whole page -->
//ul/li[1]      <!-- the first <li> in EVERY <ul> on the page -->
(//ul/li)[1]    <!-- the first <li> on the whole page -->
//ul/li[1]      <!-- the first <li> in EVERY <ul> on the page -->
(//ul/li)[1]    <!-- the first <li> on the whole page -->
//ul/li[1]      <!-- the first <li> in EVERY <ul> on the page -->
(//ul/li)[1]    <!-- the first <li> on the whole page -->

This catches out a lot of people. Wrap the expression in brackets when you mean "the first match overall".

Advanced Dynamic XPath Patterns

  1. Combining Multiple Functions

This finds the Buy button inside the pricing card titled "Pro Plan", even though the button's ID is generated:

//div[contains(@class, 'pricing-card') and .//h3[normalize-space()='Pro Plan']]//button[starts-with(@id, 'buy_')]
//div[contains(@class, 'pricing-card') and .//h3[normalize-space()='Pro Plan']]//button[starts-with(@id, 'buy_')]
//div[contains(@class, 'pricing-card') and .//h3[normalize-space()='Pro Plan']]//button[starts-with(@id, 'buy_')]
//div[contains(@class, 'pricing-card') and .//h3[normalize-space()='Pro Plan']]//button[starts-with(@id, 'buy_')]
  1. Wildcard for Unknown Tag Names

* matches any element:

//*[@data-testid='checkout-button']
//*[@data-testid='checkout-button']
//*[@data-testid='checkout-button']
//*[@data-testid='checkout-button']

Caution: Wildcards (*) force Selenium to search the entire DOM, causing performance issues. Use specific tag names when possible.

//button[@data-testid='checkout-button']
//button[@data-testid='checkout-button']
//button[@data-testid='checkout-button']
//button[@data-testid='checkout-button']
  1. Handling Dynamic Attributes with Multiple Conditions

When no single attribute is reliable, several partial signals together can pin the element down:

//input[contains(@id, 'qty') and @type='number' and ancestor::div[@data-sku='SKU-1042']]
//input[contains(@id, 'qty') and @type='number' and ancestor::div[@data-sku='SKU-1042']]
//input[contains(@id, 'qty') and @type='number' and ancestor::div[@data-sku='SKU-1042']]
//input[contains(@id, 'qty') and @type='number' and ancestor::div[@data-sku='SKU-1042']]
  1. Case-Insensitive Text Matching

XPath 1.0, which is what browsers support, has no lower-case() function. The workaround is translate():

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

It works, but it's hard to read. If you only need to handle a couple of known variations, or is clearer:

//button[normalize-space()='Sign in' or normalize-space()='Sign In']
//button[normalize-space()='Sign in' or normalize-space()='Sign In']
//button[normalize-space()='Sign in' or normalize-space()='Sign In']
//button[normalize-space()='Sign in' or normalize-space()='Sign In']

XPath Optimization for Performance

Performance Best Practices

  1. Prefer Specific Tag Names Over Wildcards

//*[@id='search']          <!-- checks every element -->
//input[@id='search']      <!-- checks only inputs -->
//*[@id='search']          <!-- checks every element -->
//input[@id='search']      <!-- checks only inputs -->
//*[@id='search']          <!-- checks every element -->
//input[@id='search']      <!-- checks only inputs -->
//*[@id='search']          <!-- checks every element -->
//input[@id='search']      <!-- checks only inputs -->
  1. Use Stable Attributes

Test-specific and accessibility attributes change far less often than generated IDs or styling classes:

//button[@data-testid='place-order']
//input[@aria-label='Email address']
//input[@name='email']
//button[@data-testid='place-order']
//input[@aria-label='Email address']
//input[@name='email']
//button[@data-testid='place-order']
//input[@aria-label='Email address']
//input[@name='email']
//button[@data-testid='place-order']
//input[@aria-label='Email address']
//input[@name='email']
  1. Minimize Axis Traversal

Every step through the tree is another thing that can break:

<!-- Fragile: depends on three levels of structure -->
//label[text()='Email']/ancestor::div[2]/following-sibling::div//input

<

<!-- Fragile: depends on three levels of structure -->
//label[text()='Email']/ancestor::div[2]/following-sibling::div//input

<

<!-- Fragile: depends on three levels of structure -->
//label[text()='Email']/ancestor::div[2]/following-sibling::div//input

<

<!-- Fragile: depends on three levels of structure -->
//label[text()='Email']/ancestor::div[2]/following-sibling::div//input

<

  1. Avoid Complex Predicates

Deeply nested conditions are slow to evaluate and slower to understand. If an expression needs more than two or three conditions, look for a better anchor, or ask the developers to add a data-testid.

Readability vs Performance Trade-offs

In most real test suites, the performance difference between two reasonable XPath expressions is tiny compared with page load and wait times. Readability matters far more, because someone will have to fix the expression when it breaks.

<!-- Short but cryptic -->
//div[3]/div/span[2]/a

<

<!-- Short but cryptic -->
//div[3]/div/span[2]/a

<

<!-- Short but cryptic -->
//div[3]/div/span[2]/a

<

<!-- Short but cryptic -->
//div[3]/div/span[2]/a

<

Rule: Write for the next person who has to maintain the test, unless profiling shows a real bottleneck.

Testing Dynamic XPath in Real Environments

  1. What Actually Differs Between Browsers

Selenium evaluates XPath using the browser's own XPath 1.0 engine, and that engine behaves the same way in Chrome, Edge, Firefox and Safari. When an XPath works in one browser and fails in another, the expression is rarely the cause. The DOM is.

The usual culprits are:

  • Different markup: Some sites serve different HTML to different browsers or devices.

  • Responsive layouts: On smaller viewports, menus collapse and elements get hidden or duplicated, so an expression can match nothing or match twice.

  • Shadow DOM: XPath can't look inside a shadow root. Selenium 4 lets you reach shadow content with getShadowRoot(), but only CSS selectors work inside it.

  • Iframes: Elements inside an iframe are invisible to XPath until you switch into the frame.

Here's how to handle the last two in Selenium 4:

// Iframe: switch in, find the element, switch back
driver.switchTo().frame("payment-frame");
driver.findElement(By.xpath("//input[@name='card-number']")).sendKeys("4242424242424242");
driver.switchTo().defaultContent();

// Shadow DOM: XPath doesn't work inside, so use CSS
WebElement host = driver.findElement(By.cssSelector("app-login"));
SearchContext shadowRoot = host.getShadowRoot();
shadowRoot.findElement(By.cssSelector("input[name='email']")).sendKeys("jane@example.com");
// Iframe: switch in, find the element, switch back
driver.switchTo().frame("payment-frame");
driver.findElement(By.xpath("//input[@name='card-number']")).sendKeys("4242424242424242");
driver.switchTo().defaultContent();

// Shadow DOM: XPath doesn't work inside, so use CSS
WebElement host = driver.findElement(By.cssSelector("app-login"));
SearchContext shadowRoot = host.getShadowRoot();
shadowRoot.findElement(By.cssSelector("input[name='email']")).sendKeys("jane@example.com");
// Iframe: switch in, find the element, switch back
driver.switchTo().frame("payment-frame");
driver.findElement(By.xpath("//input[@name='card-number']")).sendKeys("4242424242424242");
driver.switchTo().defaultContent();

// Shadow DOM: XPath doesn't work inside, so use CSS
WebElement host = driver.findElement(By.cssSelector("app-login"));
SearchContext shadowRoot = host.getShadowRoot();
shadowRoot.findElement(By.cssSelector("input[name='email']")).sendKeys("jane@example.com");
// Iframe: switch in, find the element, switch back
driver.switchTo().frame("payment-frame");
driver.findElement(By.xpath("//input[@name='card-number']")).sendKeys("4242424242424242");
driver.switchTo().defaultContent();

// Shadow DOM: XPath doesn't work inside, so use CSS
WebElement host = driver.findElement(By.cssSelector("app-login"));
SearchContext shadowRoot = host.getShadowRoot();
shadowRoot.findElement(By.cssSelector("input[name='email']")).sendKeys("jane@example.com");
  1. Cross-Browser Validation Strategy

Run the same locators across every browser you support, ideally through Selenium Grid or a cloud device provider:

import org.openqa.selenium.*;
import org.openqa.selenium.chrome.ChromeOptions;
import org.openqa.selenium.edge.EdgeOptions;
import org.openqa.selenium.firefox.FirefoxOptions;
import org.openqa.selenium.remote.RemoteWebDriver;

import java.net.URL;
import java.util.List;

public class CrossBrowserXPathCheck {

    public static void main(String[] args) throws Exception {
        List<Capabilities> browsers = List.of(
            new ChromeOptions(),
            new FirefoxOptions(),
            new EdgeOptions()
        );

        String xpath = "//button[normalize-space()='Sign in']";

        for (Capabilities options : browsers) {
            WebDriver driver = new RemoteWebDriver(new URL("http://localhost:4444"), options);
            try {
                driver.get("https://example.com/login");
                int matches = driver.findElements(By.xpath(xpath)).size();
                System.out.println(options.getBrowserName() + ": " + matches + " match(es)");
            } finally {
                driver.quit();
            }
        }
    }
}
import org.openqa.selenium.*;
import org.openqa.selenium.chrome.ChromeOptions;
import org.openqa.selenium.edge.EdgeOptions;
import org.openqa.selenium.firefox.FirefoxOptions;
import org.openqa.selenium.remote.RemoteWebDriver;

import java.net.URL;
import java.util.List;

public class CrossBrowserXPathCheck {

    public static void main(String[] args) throws Exception {
        List<Capabilities> browsers = List.of(
            new ChromeOptions(),
            new FirefoxOptions(),
            new EdgeOptions()
        );

        String xpath = "//button[normalize-space()='Sign in']";

        for (Capabilities options : browsers) {
            WebDriver driver = new RemoteWebDriver(new URL("http://localhost:4444"), options);
            try {
                driver.get("https://example.com/login");
                int matches = driver.findElements(By.xpath(xpath)).size();
                System.out.println(options.getBrowserName() + ": " + matches + " match(es)");
            } finally {
                driver.quit();
            }
        }
    }
}
import org.openqa.selenium.*;
import org.openqa.selenium.chrome.ChromeOptions;
import org.openqa.selenium.edge.EdgeOptions;
import org.openqa.selenium.firefox.FirefoxOptions;
import org.openqa.selenium.remote.RemoteWebDriver;

import java.net.URL;
import java.util.List;

public class CrossBrowserXPathCheck {

    public static void main(String[] args) throws Exception {
        List<Capabilities> browsers = List.of(
            new ChromeOptions(),
            new FirefoxOptions(),
            new EdgeOptions()
        );

        String xpath = "//button[normalize-space()='Sign in']";

        for (Capabilities options : browsers) {
            WebDriver driver = new RemoteWebDriver(new URL("http://localhost:4444"), options);
            try {
                driver.get("https://example.com/login");
                int matches = driver.findElements(By.xpath(xpath)).size();
                System.out.println(options.getBrowserName() + ": " + matches + " match(es)");
            } finally {
                driver.quit();
            }
        }
    }
}
import org.openqa.selenium.*;
import org.openqa.selenium.chrome.ChromeOptions;
import org.openqa.selenium.edge.EdgeOptions;
import org.openqa.selenium.firefox.FirefoxOptions;
import org.openqa.selenium.remote.RemoteWebDriver;

import java.net.URL;
import java.util.List;

public class CrossBrowserXPathCheck {

    public static void main(String[] args) throws Exception {
        List<Capabilities> browsers = List.of(
            new ChromeOptions(),
            new FirefoxOptions(),
            new EdgeOptions()
        );

        String xpath = "//button[normalize-space()='Sign in']";

        for (Capabilities options : browsers) {
            WebDriver driver = new RemoteWebDriver(new URL("http://localhost:4444"), options);
            try {
                driver.get("https://example.com/login");
                int matches = driver.findElements(By.xpath(xpath)).size();
                System.out.println(options.getBrowserName() + ": " + matches + " match(es)");
            } finally {
                driver.quit();
            }
        }
    }
}

Anything other than exactly one match in any browser is worth investigating before it causes a flaky test.

Best Practices for Current Selenium XPath Users

If You Must Use XPath

  1. Prefer Stable Attributes

Ask your developers to add data-testid attributes to key elements. It's the single most effective thing you can do to reduce locator maintenance.

//button[@data-testid='submit-order']
//button[@data-testid='submit-order']
//button[@data-testid='submit-order']
//button[@data-testid='submit-order']
  1. Keep Expressions Simple

If you can't tell what an expression does at a glance, it's too complex. Break it up or find a better anchor.

  1. Document Your XPath Strategy

Keep locators in one place, with names that say what the element is. The Page Object pattern does this well:

public class LoginPage {

    // Locators live in one place, with meaningful names
    private final By emailField    = By.xpath("//input[@data-testid='login-email']");
    private final By passwordField = By.xpath("//input[@data-testid='login-password']");
    private final By signInButton  = By.xpath("//button[normalize-space()='Sign in']");

    private final WebDriver driver;

    public LoginPage(WebDriver driver) {
        this.driver = driver;
    }

    public void signIn(String email, String password) {
        driver.findElement(emailField).sendKeys(email);
        driver.findElement(passwordField).sendKeys(password);
        driver.findElement(signInButton).click();
    }
}
public class LoginPage {

    // Locators live in one place, with meaningful names
    private final By emailField    = By.xpath("//input[@data-testid='login-email']");
    private final By passwordField = By.xpath("//input[@data-testid='login-password']");
    private final By signInButton  = By.xpath("//button[normalize-space()='Sign in']");

    private final WebDriver driver;

    public LoginPage(WebDriver driver) {
        this.driver = driver;
    }

    public void signIn(String email, String password) {
        driver.findElement(emailField).sendKeys(email);
        driver.findElement(passwordField).sendKeys(password);
        driver.findElement(signInButton).click();
    }
}
public class LoginPage {

    // Locators live in one place, with meaningful names
    private final By emailField    = By.xpath("//input[@data-testid='login-email']");
    private final By passwordField = By.xpath("//input[@data-testid='login-password']");
    private final By signInButton  = By.xpath("//button[normalize-space()='Sign in']");

    private final WebDriver driver;

    public LoginPage(WebDriver driver) {
        this.driver = driver;
    }

    public void signIn(String email, String password) {
        driver.findElement(emailField).sendKeys(email);
        driver.findElement(passwordField).sendKeys(password);
        driver.findElement(signInButton).click();
    }
}
public class LoginPage {

    // Locators live in one place, with meaningful names
    private final By emailField    = By.xpath("//input[@data-testid='login-email']");
    private final By passwordField = By.xpath("//input[@data-testid='login-password']");
    private final By signInButton  = By.xpath("//button[normalize-space()='Sign in']");

    private final WebDriver driver;

    public LoginPage(WebDriver driver) {
        this.driver = driver;
    }

    public void signIn(String email, String password) {
        driver.findElement(emailField).sendKeys(email);
        driver.findElement(passwordField).sendKeys(password);
        driver.findElement(signInButton).click();
    }
}

When a locator breaks, you fix it once instead of hunting through every test.

  1. Implement Retry Logic

Dynamic frameworks often re-render elements, which causes StaleElementReferenceException. A FluentWait that ignores stale references retries automatically:

import org.openqa.selenium.support.ui.FluentWait;
import org.openqa.selenium.support.ui.Wait;
import java.time.Duration;

Wait<WebDriver> wait = new FluentWait<>(driver)
    .withTimeout(Duration.ofSeconds(10))
    .pollingEvery(Duration.ofMillis(500))
    .ignoring(StaleElementReferenceException.class)
    .ignoring(NoSuchElementException.class);

WebElement button = wait.until(d ->
    d.findElement(By.xpath("//button[normalize-space()='Place order']"))
);
button.click();
import org.openqa.selenium.support.ui.FluentWait;
import org.openqa.selenium.support.ui.Wait;
import java.time.Duration;

Wait<WebDriver> wait = new FluentWait<>(driver)
    .withTimeout(Duration.ofSeconds(10))
    .pollingEvery(Duration.ofMillis(500))
    .ignoring(StaleElementReferenceException.class)
    .ignoring(NoSuchElementException.class);

WebElement button = wait.until(d ->
    d.findElement(By.xpath("//button[normalize-space()='Place order']"))
);
button.click();
import org.openqa.selenium.support.ui.FluentWait;
import org.openqa.selenium.support.ui.Wait;
import java.time.Duration;

Wait<WebDriver> wait = new FluentWait<>(driver)
    .withTimeout(Duration.ofSeconds(10))
    .pollingEvery(Duration.ofMillis(500))
    .ignoring(StaleElementReferenceException.class)
    .ignoring(NoSuchElementException.class);

WebElement button = wait.until(d ->
    d.findElement(By.xpath("//button[normalize-space()='Place order']"))
);
button.click();
import org.openqa.selenium.support.ui.FluentWait;
import org.openqa.selenium.support.ui.Wait;
import java.time.Duration;

Wait<WebDriver> wait = new FluentWait<>(driver)
    .withTimeout(Duration.ofSeconds(10))
    .pollingEvery(Duration.ofMillis(500))
    .ignoring(StaleElementReferenceException.class)
    .ignoring(NoSuchElementException.class);

WebElement button = wait.until(d ->
    d.findElement(By.xpath("//button[normalize-space()='Place order']"))
);
button.click();
  1. Use Explicit Waits

Never use Thread.sleep() to wait for dynamic elements. Wait for the specific condition you need:

import org.openqa.selenium.support.ui.ExpectedConditions;
import org.openqa.selenium.support.ui.WebDriverWait;
import java.time.Duration;

WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(10));

WebElement confirmation = wait.until(
    ExpectedConditions.visibilityOfElementLocated(
        By.xpath("//div[contains(@class, 'toast') and contains(normalize-space(), 'Order confirmed')]")
    )
);
import org.openqa.selenium.support.ui.ExpectedConditions;
import org.openqa.selenium.support.ui.WebDriverWait;
import java.time.Duration;

WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(10));

WebElement confirmation = wait.until(
    ExpectedConditions.visibilityOfElementLocated(
        By.xpath("//div[contains(@class, 'toast') and contains(normalize-space(), 'Order confirmed')]")
    )
);
import org.openqa.selenium.support.ui.ExpectedConditions;
import org.openqa.selenium.support.ui.WebDriverWait;
import java.time.Duration;

WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(10));

WebElement confirmation = wait.until(
    ExpectedConditions.visibilityOfElementLocated(
        By.xpath("//div[contains(@class, 'toast') and contains(normalize-space(), 'Order confirmed')]")
    )
);
import org.openqa.selenium.support.ui.ExpectedConditions;
import org.openqa.selenium.support.ui.WebDriverWait;
import java.time.Duration;

WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(10));

WebElement confirmation = wait.until(
    ExpectedConditions.visibilityOfElementLocated(
        By.xpath("//div[contains(@class, 'toast') and contains(normalize-space(), 'Order confirmed')]")
    )
);

Hybrid Approach: XPath + AI-Augmented Testing

Many enterprise teams don't switch overnight. A common pattern looks like this:

  • Keep existing Selenium XPath tests for the critical paths that are stable today

  • Build new tests on an AI-native platform

  • Gradually move the highest-maintenance XPath tests across

  • Measure the maintenance time saved, and expand from there

The Fundamental Limitations of XPath Approach

  1. Single Locator Dependency

XPath expressions, no matter how sophisticated, rely on a single locator strategy. When the identified attribute changes, tests break. Dynamic XPath only delays the inevitable.

The Reality:

  • 62% of testers use Selenium

  • Only 19.3% achieve over 50% automation coverage

  • 80% of time spent on maintenance, 10% on authoring

  • XPath brittleness is a primary maintenance driver

  1. Maintenance Burden at Scale

Consider an enterprise test suite with 1,000 test cases:

  • Average 10 XPath expressions per test = 10,000 XPath locators

  • If 5% of DOM changes per release (conservative) = 500 broken locators

  • Average 15 minutes to fix each locator = 125 hours maintenance per release

  • Monthly releases = 1,500 hours per year on XPath maintenance alone

This doesn't include time spent debugging why tests failed, validating fixes, or regression testing.

  1. The Complexity Trap

As applications get more dynamic, expressions grow to keep up, until you end up with something like this:

//div[contains(@class,'grid')]/div[not(contains(@class,'hidden'))][.//span[contains(normalize-space(),'Invoice')]]//following-sibling::div[1]//button[starts-with(@id,'act_') and not(@disabled)][last()]
//div[contains(@class,'grid')]/div[not(contains(@class,'hidden'))][.//span[contains(normalize-space(),'Invoice')]]//following-sibling::div[1]//button[starts-with(@id,'act_') and not(@disabled)][last()]
//div[contains(@class,'grid')]/div[not(contains(@class,'hidden'))][.//span[contains(normalize-space(),'Invoice')]]//following-sibling::div[1]//button[starts-with(@id,'act_') and not(@disabled)][last()]
//div[contains(@class,'grid')]/div[not(contains(@class,'hidden'))][.//span[contains(normalize-space(),'Invoice')]]//following-sibling::div[1]//button[starts-with(@id,'act_') and not(@disabled)][last()]

Expressions like this are:

  • Hard to understand and risky to change

  • Fragile to small DOM changes anywhere along the chain

  • Slow to evaluate

  • Very hard to debug when they fail, because the error only tells you nothing matched

  1. Framework Specific Challenges

  • ‍React Applications: Components re-render constantly, generating new element references. Even stable XPath expressions can target stale elements.‍

  • Angular Applications: Two-way data binding causes frequent DOM updates. XPath must constantly re-evaluate after every interaction.‍

  • Vue Applications: Virtual DOM updates make XPath especially brittle. Elements may appear identical but have different internal references.

How AI-Augmented Element Identification Eliminates XPath Brittleness

  1. Multiple Identifiers Instead of One Locator

AI-native test platforms don't bet everything on a single expression. They record many signals about each element, then use whichever ones still hold when the page changes.

A traditional Selenium approach depends on one path:

driver.findElement(By.xpath("//div[@class='form-group'][2]//input[@id='email_83721']"));
driver.findElement(By.xpath("//div[@class='form-group'][2]//input[@id='email_83721']"));
driver.findElement(By.xpath("//div[@class='form-group'][2]//input[@id='email_83721']"));
driver.findElement(By.xpath("//div[@class='form-group'][2]//input[@id='email_83721']"));

An AI-augmented approach stores a richer description of the same element. Conceptually, it looks something like this:

{
  "element": "Email field",
  "signals": {
    "label": "Email",
    "placeholder": "you@company.com",
    "name": "email",
    "id_pattern": "email_*",
    "type": "email",
    "position": "first input in login form",
    "nearby_text": "Sign in to your account"
  }
}
{
  "element": "Email field",
  "signals": {
    "label": "Email",
    "placeholder": "you@company.com",
    "name": "email",
    "id_pattern": "email_*",
    "type": "email",
    "position": "first input in login form",
    "nearby_text": "Sign in to your account"
  }
}
{
  "element": "Email field",
  "signals": {
    "label": "Email",
    "placeholder": "you@company.com",
    "name": "email",
    "id_pattern": "email_*",
    "type": "email",
    "position": "first input in login form",
    "nearby_text": "Sign in to your account"
  }
}
{
  "element": "Email field",
  "signals": {
    "label": "Email",
    "placeholder": "you@company.com",
    "name": "email",
    "id_pattern": "email_*",
    "type": "email",
    "position": "first input in login form",
    "nearby_text": "Sign in to your account"
  }
}

When the application changes, the platform still has several ways to find the element. If the ID changes, it can use the label, name and position. If the layout changes, it can use the label and surrounding text.

  1. Self-Healing You Can Review

When an element changes, platforms like Virtuoso QA locate it using the other signals and update the test, instead of failing the run. The difference that matters at enterprise scale is visibility. Virtuoso QA logs every heal, showing what changed and why, so your team can accept or reject each fix. The suite keeps running, and nothing changes without a record. That record also feeds the release evidence for each run, so you can show what was tested and what was repaired.

  1. A Natural Language Layer

AI-native platforms let you write tests in plain English, which removes raw XPath from the test entirely. Compare the two approaches.

driver.findElement(By.xpath("//input[contains(@id,'email')]")).sendKeys("jane@example.com");
driver.findElement(By.xpath("//input[@type='password']")).sendKeys("S3cure!pass");
driver.findElement(By.xpath("//button[normalize-space()='Sign in']")).click();
new WebDriverWait(driver, Duration.ofSeconds(10)).until(
    ExpectedConditions.visibilityOfElementLocated(
        By.xpath("//h1[contains(normalize-space(),'Welcome back')]")));
driver.findElement(By.xpath("//input[contains(@id,'email')]")).sendKeys("jane@example.com");
driver.findElement(By.xpath("//input[@type='password']")).sendKeys("S3cure!pass");
driver.findElement(By.xpath("//button[normalize-space()='Sign in']")).click();
new WebDriverWait(driver, Duration.ofSeconds(10)).until(
    ExpectedConditions.visibilityOfElementLocated(
        By.xpath("//h1[contains(normalize-space(),'Welcome back')]")));
driver.findElement(By.xpath("//input[contains(@id,'email')]")).sendKeys("jane@example.com");
driver.findElement(By.xpath("//input[@type='password']")).sendKeys("S3cure!pass");
driver.findElement(By.xpath("//button[normalize-space()='Sign in']")).click();
new WebDriverWait(driver, Duration.ofSeconds(10)).until(
    ExpectedConditions.visibilityOfElementLocated(
        By.xpath("//h1[contains(normalize-space(),'Welcome back')]")));
driver.findElement(By.xpath("//input[contains(@id,'email')]")).sendKeys("jane@example.com");
driver.findElement(By.xpath("//input[@type='password']")).sendKeys("S3cure!pass");
driver.findElement(By.xpath("//button[normalize-space()='Sign in']")).click();
new WebDriverWait(driver, Duration.ofSeconds(10)).until(
    ExpectedConditions.visibilityOfElementLocated(
        By.xpath("//h1[contains(normalize-space(),'Welcome back')]")));

The natural language equivalent:

Write "jane@example.com" in the "Email" field
Write "S3cure!pass" in the "Password" field
Click "Sign in"
Look for "Welcome back" on the page
Write "jane@example.com" in the "Email" field
Write "S3cure!pass" in the "Password" field
Click "Sign in"
Look for "Welcome back" on the page
Write "jane@example.com" in the "Email" field
Write "S3cure!pass" in the "Password" field
Click "Sign in"
Look for "Welcome back" on the page
Write "jane@example.com" in the "Email" field
Write "S3cure!pass" in the "Password" field
Click "Sign in"
Look for "Welcome back" on the page

This brings several benefits:

  • Tests read like business steps, not technical locators

  • Nobody needs XPath expertise to write or review them

  • When the DOM changes, the element identification updates while the test steps stay the same

Migration Path from XPath to AI-Augmented Testing

When to Migrate

  • ‍High XPath Maintenance Burden: If more than 30% of test maintenance involves updating broken XPath expressions.‍

  • Complex Application Architectures: SPAs, progressive web apps, and microservices-based UIs with dynamic rendering.‍

  • Limited Automation Coverage: When XPath brittleness prevents scaling beyond 30-40% automation coverage.‍

  • Frequent Release Cycles: When weekly or daily releases mean constant XPath maintenance overhead.
    ‍

Visit our Selenium migration page to see how Virtuoso QA supports seamless test migration while training your team to adopt AI-native testing effectively.

Technical Migration Approach

Modern AI native platforms offer agentic test generation that converts existing Selenium suites:

Step 1: Automated Script Analysis‍

AI analyzes existing Selenium scripts to understand test intent and element identification patterns.

Step 2: Natural Language Conversion‍

XPath-based interactions become plain English steps.

Before:

driver.findElement(By.xpath("//a[normalize-space()='Orders']")).click();
driver.findElement(By.xpath("//input[starts-with(@id,'search_')]")).sendKeys("ORD-10442");
driver.findElement(By.xpath("//button[@data-testid='search-submit']")).click();
driver.findElement(By.xpath("//a[normalize-space()='Orders']")).click();
driver.findElement(By.xpath("//input[starts-with(@id,'search_')]")).sendKeys("ORD-10442");
driver.findElement(By.xpath("//button[@data-testid='search-submit']")).click();
driver.findElement(By.xpath("//a[normalize-space()='Orders']")).click();
driver.findElement(By.xpath("//input[starts-with(@id,'search_')]")).sendKeys("ORD-10442");
driver.findElement(By.xpath("//button[@data-testid='search-submit']")).click();
driver.findElement(By.xpath("//a[normalize-space()='Orders']")).click();
driver.findElement(By.xpath("//input[starts-with(@id,'search_')]")).sendKeys("ORD-10442");
driver.findElement(By.xpath("//button[@data-testid='search-submit']")).click();

After:

Click "Orders"
Write "ORD-10442" in the "Search" field
Click "Search"
Click "Orders"
Write "ORD-10442" in the "Search" field
Click "Search"
Click "Orders"
Write "ORD-10442" in the "Search" field
Click "Search"
Click "Orders"
Write "ORD-10442" in the "Search" field
Click "Search"

Step 3: Comprehensive Element Modeling‍

AI builds multi-dimensional element models replacing brittle XPath expressions.

Step 4: Validation and Parallel Execution‍

Migrated tests run in parallel with existing Selenium tests to validate accuracy.

Conclusion: The Inevitable Evolution Beyond XPath

Dynamic XPath techniques like contains(), starts-with(), normalize-space() and axes genuinely help Selenium cope with changing elements. Every Selenium engineer should know them. But they're ways of surviving change, not removing its cost. As applications get more dynamic, the expressions get longer, and the maintenance grows with them.

The real problem isn't XPath syntax. It's depending on a single locator in an application that changes every sprint. Identifying elements from many signals, healing tests in a way your team can review, and writing tests in plain English all tackle that problem at the root. For teams spending more time fixing locators than adding coverage, that's where the effort is better spent.

Related Reads

Frequently Asked Questions

What is the difference between static XPath and dynamic XPath?

How do you write dynamic XPath for elements with changing IDs?

What is the syntax for contains() in XPath?

Should I use absolute or relative XPath in Selenium?

How do I locate elements by text content in XPath?

Should I migrate from Selenium XPath to AI native testing?

See what your next release looks like with Virtuoso

Book a walkthrough on your applications and your workflows. Bring a requirement, a user journey, or a brittle Selenium script, and watch the loop run on something you recognise.

See what your next release looks like with Virtuoso

Book a walkthrough on your applications and your workflows. Bring a requirement, a user journey, or a brittle Selenium script, and watch the loop run on something you recognise.

See what your next release looks like with Virtuoso

Book a walkthrough on your applications and your workflows. Bring a requirement, a user journey, or a brittle Selenium script, and watch the loop run on something you recognise.

Virtuoso QA is establishing the standard of proof for software releases. Its governed QA loop turns business requirements into tests for any browser-based application: AI proposes, a deterministic engine executes, a person approves what matters, and every decision leaves evidence.

Trust Center

AICPA

SOC

WAVE STRONG PERFORMER

@ Copyright 2026 SpotQA, Creators of Virtuoso QA

Virtuoso QA is establishing the standard of proof for software releases. Its governed QA loop turns business requirements into tests for any browser-based application: AI proposes, a deterministic engine executes, a person approves what matters, and every decision leaves evidence.

Trust Center

AICPA

SOC

WAVE STRONG PERFORMER

@ Copyright 2026 SpotQA, Creators of Virtuoso QA

Virtuoso QA is establishing the standard of proof for software releases. Its governed QA loop turns business requirements into tests for any browser-based application: AI proposes, a deterministic engine executes, a person approves what matters, and every decision leaves evidence.

Trust Center

AICPA

SOC

WAVE STRONG PERFORMER

@ Copyright 2026 SpotQA, Creators of Virtuoso QA