Blog

XPath contains() in Selenium: Syntax, Examples & Tips

Adwitiya Pandey

Published on

Table of contents

Lorem ipsum

Lorem ipsum

Lorem ipsum

Lorem ipsum

Lorem ipsum

The XPath contains() function is one of the most useful tools a Selenium engineer has for dynamic web applications, and also one of the most overused. Written carefully, a contains() locator survives dynamic IDs, generated class names and small DOM changes. Written carelessly, it matches too many elements, runs slowly and breaks in ways that are hard to trace.

This guide explains what contains() does, how to use it on attributes and text, how to combine it with normalize-space(), translate() and XPath axes, and how it compares with starts-with() and CSS selectors. It also covers the mistakes that cause most contains() failures, how to keep locators fast, and how to debug them, with Java and Python examples throughout.


A Locator That Kept Breaking

Picture a test that targets a button with //button[contains(@class, 'btn')]. It passes for weeks, then fails after a release that added an unrelated button higher up the page. The fix makes the expression more specific, the next release adds another button, and the locator grows longer each time until nobody can read it.

That pattern is common, because contains() sits on the line between a resilient locator and a fragile one. The rest of this guide shows how to stay on the resilient side, starting with what the function actually does.


What contains() Does

XPath (XML Path Language) is a language for finding elements and attributes in an XML or HTML document. Within it, contains() is a string function that returns true when one string contains another. Its form is contains(haystack, needle), and it returns a boolean.

Inside a predicate, the part of an XPath in square brackets, contains() filters elements by whether an attribute or text value includes a substring:

This finds any button whose class attribute includes primary. A button with class="btn primary large" matches, and so does class="btn-primary". A button with class="prim" doesn’t.

Two rules shape everything that follows. The match is case-sensitive, and whitespace is compared exactly as it appears in the document.

Absolute and Relative Paths

An absolute path starts at the root and names every ancestor, such as /html/body/div[1]/form/button. It breaks as soon as any element in that chain changes, so it’s rarely the right choice.

A relative path starts with // and finds the element by its own characteristics, such as //button[contains(@class, 'primary')]. Relative paths cope far better with layout changes, and contains() is almost always used inside them.


Why contains() Matters on Modern Front Ends

Applications built with React, Angular, Vue and similar frameworks often generate attribute values at runtime. A button might render as id="btn-submit-47392" today and id="btn-submit-82910" tomorrow.

An exact-match locator such as //button[@id='btn-submit-47392'] works once and then fails. A contains() locator that targets the part of the value that doesn’t change keeps working:

The same approach works for generated class names, hashed selectors, framework data-* attributes and any other value where a stable fragment sits inside a changing one.


Three Ways to Use contains() in Selenium

Matching Attribute Values

The most common use is matching an element whose attribute includes a known fragment, typically in a class, id, name, data-* attribute or aria-label:





In Java:

WebElement submit = driver.findElement(
    By.xpath("//button[contains(@class, 'btn-primary')]"));
submit.click();

WebElement email = driver.findElement(
    By.xpath("//input[contains(@name, 'email')]"));
email.sendKeys("jane@example.com");
WebElement submit = driver.findElement(
    By.xpath("//button[contains(@class, 'btn-primary')]"));
submit.click();

WebElement email = driver.findElement(
    By.xpath("//input[contains(@name, 'email')]"));
email.sendKeys("jane@example.com");
WebElement submit = driver.findElement(
    By.xpath("//button[contains(@class, 'btn-primary')]"));
submit.click();

WebElement email = driver.findElement(
    By.xpath("//input[contains(@name, 'email')]"));
email.sendKeys("jane@example.com");
WebElement submit = driver.findElement(
    By.xpath("//button[contains(@class, 'btn-primary')]"));
submit.click();

WebElement email = driver.findElement(
    By.xpath("//input[contains(@name, 'email')]"));
email.sendKeys("jane@example.com");

In Python:

from selenium.webdriver.common.by import By

submit = driver.find_element(By.XPATH, "//button[contains(@class, 'btn-primary')]")
submit.click()

email = driver.find_element(By.XPATH, "//input[contains(@name, 'email')]")
email.send_keys("jane@example.com")
from selenium.webdriver.common.by import By

submit = driver.find_element(By.XPATH, "//button[contains(@class, 'btn-primary')]")
submit.click()

email = driver.find_element(By.XPATH, "//input[contains(@name, 'email')]")
email.send_keys("jane@example.com")
from selenium.webdriver.common.by import By

submit = driver.find_element(By.XPATH, "//button[contains(@class, 'btn-primary')]")
submit.click()

email = driver.find_element(By.XPATH, "//input[contains(@name, 'email')]")
email.send_keys("jane@example.com")
from selenium.webdriver.common.by import By

submit = driver.find_element(By.XPATH, "//button[contains(@class, 'btn-primary')]")
submit.click()

email = driver.find_element(By.XPATH, "//input[contains(@name, 'email')]")
email.send_keys("jane@example.com")

This handles values where a stable name is wrapped in a generated suffix, session ID or build hash.

Matching Text Content

The second use matches elements by the text they show:





In Java, with a wait so the element has time to render:

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

WebElement forgot = wait.until(ExpectedConditions.elementToBeClickable(
    By.xpath("//a[contains(text(), 'Forgot password')]")));
forgot.click();
WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(10));

WebElement forgot = wait.until(ExpectedConditions.elementToBeClickable(
    By.xpath("//a[contains(text(), 'Forgot password')]")));
forgot.click();
WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(10));

WebElement forgot = wait.until(ExpectedConditions.elementToBeClickable(
    By.xpath("//a[contains(text(), 'Forgot password')]")));
forgot.click();
WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(10));

WebElement forgot = wait.until(ExpectedConditions.elementToBeClickable(
    By.xpath("//a[contains(text(), 'Forgot password')]")));
forgot.click();

This helps when an element has no stable attributes but always shows the same words.

How text() behaves matters here. It selects an element’s direct text nodes only, not text inside child elements. When contains() receives several text nodes, XPath 1.0 uses only the first one. So a button written as <button><span class="icon"></span> Submit</button> may fail to match, because its first text node is empty or the text sits inside a child.

The fix is to use a dot (.), which stands for the element’s full string value, including the text of every descendant:

The . form copes with text split across nested elements, but it’s also broader, so it can match more elements than you expect. Choose based on how the HTML is structured.

Matching Several Conditions

The third use combines contains() with and, or and not() to narrow the match:





In Java:

WebElement save = driver.findElement(By.xpath(
    "//button[contains(@class, 'btn') and contains(., 'Save')]"));
save.click();

List<WebElement> activeProductLinks = driver.findElements(By.xpath(
    "//a[contains(@href, '/products') and not(contains(@class, 'disabled'))]"));
WebElement save = driver.findElement(By.xpath(
    "//button[contains(@class, 'btn') and contains(., 'Save')]"));
save.click();

List<WebElement> activeProductLinks = driver.findElements(By.xpath(
    "//a[contains(@href, '/products') and not(contains(@class, 'disabled'))]"));
WebElement save = driver.findElement(By.xpath(
    "//button[contains(@class, 'btn') and contains(., 'Save')]"));
save.click();

List<WebElement> activeProductLinks = driver.findElements(By.xpath(
    "//a[contains(@href, '/products') and not(contains(@class, 'disabled'))]"));
WebElement save = driver.findElement(By.xpath(
    "//button[contains(@class, 'btn') and contains(., 'Save')]"));
save.click();

List<WebElement> activeProductLinks = driver.findElements(By.xpath(
    "//a[contains(@href, '/products') and not(contains(@class, 'disabled'))]"));

A page might have fifty buttons with the class btn, but only one with that class and the text Save. Adding a second condition is often the quickest way to make a locator unique.

contains() and text() Compared

These two come up constantly when locating by text. text()= requires the text to match exactly, while contains(text(), ...) accepts a partial match:

<!-- Matches only if the text is exactly "Login". A trailing space breaks it -->
//button[text()='Login']

<!-- Matches "Login", "Login now", "Please Login" and more -->

<!-- Matches only if the text is exactly "Login". A trailing space breaks it -->
//button[text()='Login']

<!-- Matches "Login", "Login now", "Please Login" and more -->

<!-- Matches only if the text is exactly "Login". A trailing space breaks it -->
//button[text()='Login']

<!-- Matches "Login", "Login now", "Please Login" and more -->

<!-- Matches only if the text is exactly "Login". A trailing space breaks it -->
//button[text()='Login']

<!-- Matches "Login", "Login now", "Please Login" and more -->

Use text()= for fixed labels, such as main navigation, where an exact match is safe and you want the test to fail if the wording changes. Use contains() for text that varies, such as Welcome, John!, where only Welcome stays the same.


Advanced contains() Patterns

Ignoring Stray Whitespace with normalize-space()

HTML text often carries leading spaces, trailing line breaks or double spaces left by source formatting. A locator looking for Submit fails if the real text is " Submit " followed by a line break.

normalize-space() trims leading and trailing whitespace and collapses internal runs to a single space:





In Java:

WebElement welcome = driver.findElement(By.xpath(
    "//span[contains(normalize-space(.), 'Welcome User')]"));
WebElement welcome = driver.findElement(By.xpath(
    "//span[contains(normalize-space(.), 'Welcome User')]"));
WebElement welcome = driver.findElement(By.xpath(
    "//span[contains(normalize-space(.), 'Welcome User')]"));
WebElement welcome = driver.findElement(By.xpath(
    "//span[contains(normalize-space(.), 'Welcome User')]"));

In Python:

welcome = driver.find_element(
    By.XPATH, "//span[contains(normalize-space(.), 'Welcome User')]")
welcome = driver.find_element(
    By.XPATH, "//span[contains(normalize-space(.), 'Welcome User')]")
welcome = driver.find_element(
    By.XPATH, "//span[contains(normalize-space(.), 'Welcome User')]")
welcome = driver.find_element(
    By.XPATH, "//span[contains(normalize-space(.), 'Welcome User')]")

Pair normalize-space() with . rather than text(), so it works on the element’s full text. On real applications, this combination is a sensible default for any text-based locator.

Case-Insensitive Matching with translate()

Browsers evaluate XPath 1.0, which has no case-insensitive comparison. The usual workaround uses translate() to lower-case the text before comparing it:

This lower-cases the button’s text and checks for submit, so it matches Submit, SUBMIT and submit.

In Java, a small helper keeps the expression readable:

private static final String UPPER = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
private static final String LOWER = "abcdefghijklmnopqrstuvwxyz";

public static By buttonWithTextIgnoreCase(String text) {
    String xpath = String.format(
        "//button[contains(translate(normalize-space(.), '%s', '%s'), '%s')]",
        UPPER, LOWER, text.toLowerCase());
    return By.xpath(xpath);
}

driver.findElement(buttonWithTextIgnoreCase("Submit")).click();
private static final String UPPER = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
private static final String LOWER = "abcdefghijklmnopqrstuvwxyz";

public static By buttonWithTextIgnoreCase(String text) {
    String xpath = String.format(
        "//button[contains(translate(normalize-space(.), '%s', '%s'), '%s')]",
        UPPER, LOWER, text.toLowerCase());
    return By.xpath(xpath);
}

driver.findElement(buttonWithTextIgnoreCase("Submit")).click();
private static final String UPPER = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
private static final String LOWER = "abcdefghijklmnopqrstuvwxyz";

public static By buttonWithTextIgnoreCase(String text) {
    String xpath = String.format(
        "//button[contains(translate(normalize-space(.), '%s', '%s'), '%s')]",
        UPPER, LOWER, text.toLowerCase());
    return By.xpath(xpath);
}

driver.findElement(buttonWithTextIgnoreCase("Submit")).click();
private static final String UPPER = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
private static final String LOWER = "abcdefghijklmnopqrstuvwxyz";

public static By buttonWithTextIgnoreCase(String text) {
    String xpath = String.format(
        "//button[contains(translate(normalize-space(.), '%s', '%s'), '%s')]",
        UPPER, LOWER, text.toLowerCase());
    return By.xpath(xpath);
}

driver.findElement(buttonWithTextIgnoreCase("Submit")).click();

XPath 2.0 has a lower-case() function, but browsers don’t support XPath 2.0, so translate() remains the standard approach. It only covers the letters you list, so add accented characters if your application needs them.

Combining contains() with Axes

XPath axes, such as parent, ancestor, following-sibling, preceding-sibling, child and descendant, move between related elements. Combined with contains(), they find an element through something nearby that’s easier to identify.

To find an input by its label:

To find a form group that contains an error message:

In Java:

WebElement emailInput = driver.findElement(By.xpath(
    "//label[contains(normalize-space(.), 'Email')]/following-sibling::input"));
emailInput.sendKeys("jane@example.com");

WebElement errorGroup = driver.findElement(By.xpath(
    "//span[contains(normalize-space(.), 'Error')]/ancestor::div[contains(@class, 'form-group')]"));
WebElement emailInput = driver.findElement(By.xpath(
    "//label[contains(normalize-space(.), 'Email')]/following-sibling::input"));
emailInput.sendKeys("jane@example.com");

WebElement errorGroup = driver.findElement(By.xpath(
    "//span[contains(normalize-space(.), 'Error')]/ancestor::div[contains(@class, 'form-group')]"));
WebElement emailInput = driver.findElement(By.xpath(
    "//label[contains(normalize-space(.), 'Email')]/following-sibling::input"));
emailInput.sendKeys("jane@example.com");

WebElement errorGroup = driver.findElement(By.xpath(
    "//span[contains(normalize-space(.), 'Error')]/ancestor::div[contains(@class, 'form-group')]"));
WebElement emailInput = driver.findElement(By.xpath(
    "//label[contains(normalize-space(.), 'Email')]/following-sibling::input"));
emailInput.sendKeys("jane@example.com");

WebElement errorGroup = driver.findElement(By.xpath(
    "//span[contains(normalize-space(.), 'Error')]/ancestor::div[contains(@class, 'form-group')]"));

Labels tend to keep their wording even when the HTML around them is restructured, which makes label-based locators durable. Long chains of axes, though, become hard to read, slow to evaluate and hard to debug, so keep the relationships short and stable.

Locating by Position with Selenium 4 Relative Locators

Selenium 4 added relative locators, which find an element by its position relative to another. They can replace some axis-based XPath:

import static org.openqa.selenium.support.locators.RelativeLocator.with;

WebElement emailInput = driver.findElement(
    with(By.tagName("input"))
        .below(By.xpath("//label[contains(normalize-space(.), 'Email')]")));
import static org.openqa.selenium.support.locators.RelativeLocator.with;

WebElement emailInput = driver.findElement(
    with(By.tagName("input"))
        .below(By.xpath("//label[contains(normalize-space(.), 'Email')]")));
import static org.openqa.selenium.support.locators.RelativeLocator.with;

WebElement emailInput = driver.findElement(
    with(By.tagName("input"))
        .below(By.xpath("//label[contains(normalize-space(.), 'Email')]")));
import static org.openqa.selenium.support.locators.RelativeLocator.with;

WebElement emailInput = driver.findElement(
    with(By.tagName("input"))
        .below(By.xpath("//label[contains(normalize-space(.), 'Email')]")));

Relative locators such as above(), below() and near() work from where elements appear on screen, so they suit stable layouts better than responsive pages, where positions change with screen size.


Worked Examples on Real Markup

Finding a Button with Two Related Conditions

An online bookshop shows each book in its own block. You need the Add to Cart button for a fiction title by a particular author:

<div class="book">
  <h3>The Silent River</h3>
  <p class="author">Leom Ayodele</p>
  <ul class="tags"><li>Fiction</li><li>Paperback</li></ul>
  <button class="add-to-cart">Add to Cart</button>
</div>
<div class="book">
  <h3>Lessons from the Delta</h3>
  <p class="author">Leom Ayodele</p>
  <ul class="tags"><li>Non-fiction</li><li>Hardback</li></ul>
  <button class="add-to-cart">Add to Cart</button>
</div>
<div class="book">
  <h3>Paper Cities</h3>
  <p class="author">Mara Lindqvist</p>
  <ul class="tags"><li>Fiction</li><li>Paperback</li></ul>
  <button class="add-to-cart">Add to Cart</button>
</div>
<div class="book">
  <h3>The Silent River</h3>
  <p class="author">Leom Ayodele</p>
  <ul class="tags"><li>Fiction</li><li>Paperback</li></ul>
  <button class="add-to-cart">Add to Cart</button>
</div>
<div class="book">
  <h3>Lessons from the Delta</h3>
  <p class="author">Leom Ayodele</p>
  <ul class="tags"><li>Non-fiction</li><li>Hardback</li></ul>
  <button class="add-to-cart">Add to Cart</button>
</div>
<div class="book">
  <h3>Paper Cities</h3>
  <p class="author">Mara Lindqvist</p>
  <ul class="tags"><li>Fiction</li><li>Paperback</li></ul>
  <button class="add-to-cart">Add to Cart</button>
</div>
<div class="book">
  <h3>The Silent River</h3>
  <p class="author">Leom Ayodele</p>
  <ul class="tags"><li>Fiction</li><li>Paperback</li></ul>
  <button class="add-to-cart">Add to Cart</button>
</div>
<div class="book">
  <h3>Lessons from the Delta</h3>
  <p class="author">Leom Ayodele</p>
  <ul class="tags"><li>Non-fiction</li><li>Hardback</li></ul>
  <button class="add-to-cart">Add to Cart</button>
</div>
<div class="book">
  <h3>Paper Cities</h3>
  <p class="author">Mara Lindqvist</p>
  <ul class="tags"><li>Fiction</li><li>Paperback</li></ul>
  <button class="add-to-cart">Add to Cart</button>
</div>
<div class="book">
  <h3>The Silent River</h3>
  <p class="author">Leom Ayodele</p>
  <ul class="tags"><li>Fiction</li><li>Paperback</li></ul>
  <button class="add-to-cart">Add to Cart</button>
</div>
<div class="book">
  <h3>Lessons from the Delta</h3>
  <p class="author">Leom Ayodele</p>
  <ul class="tags"><li>Non-fiction</li><li>Hardback</li></ul>
  <button class="add-to-cart">Add to Cart</button>
</div>
<div class="book">
  <h3>Paper Cities</h3>
  <p class="author">Mara Lindqvist</p>
  <ul class="tags"><li>Fiction</li><li>Paperback</li></ul>
  <button class="add-to-cart">Add to Cart</button>
</div>

This locator narrows by author and genre before choosing the button:





It finds a div whose class includes book, that contains a p mentioning Leom Ayodele and an li that reads exactly Fiction, then selects that block’s button. Two books share the author, but only one is fiction, so the locator resolves to a single element.

The genre uses an exact match on purpose. contains(., 'Fiction') would also match Non-fiction if the capitalisation lined up, which is a good example of when exact matching is the safer choice.

Finding Replies to a Specific Comment

A comment thread nests replies inside each comment:

<div class="comment">
  <p class="author">John Doe</p>
  <p class="body">Great article, thanks.</p>
  <div class="reply"><p class="author">Priya Shah</p><p class="body">Agreed.</p></div>
  <div class="reply"><p class="author">Tom Reid</p><p class="body">Same here.</p></div>
</div>
<div class="comment">
  <p class="author">Ana Costa</p>
  <p class="body">One question about step 3.</p>
  <div class="reply"><p class="author">John Doe</p><p class="body">Happy to help.</p></div>
</div>
<div class="comment">
  <p class="author">John Doe</p>
  <p class="body">Great article, thanks.</p>
  <div class="reply"><p class="author">Priya Shah</p><p class="body">Agreed.</p></div>
  <div class="reply"><p class="author">Tom Reid</p><p class="body">Same here.</p></div>
</div>
<div class="comment">
  <p class="author">Ana Costa</p>
  <p class="body">One question about step 3.</p>
  <div class="reply"><p class="author">John Doe</p><p class="body">Happy to help.</p></div>
</div>
<div class="comment">
  <p class="author">John Doe</p>
  <p class="body">Great article, thanks.</p>
  <div class="reply"><p class="author">Priya Shah</p><p class="body">Agreed.</p></div>
  <div class="reply"><p class="author">Tom Reid</p><p class="body">Same here.</p></div>
</div>
<div class="comment">
  <p class="author">Ana Costa</p>
  <p class="body">One question about step 3.</p>
  <div class="reply"><p class="author">John Doe</p><p class="body">Happy to help.</p></div>
</div>
<div class="comment">
  <p class="author">John Doe</p>
  <p class="body">Great article, thanks.</p>
  <div class="reply"><p class="author">Priya Shah</p><p class="body">Agreed.</p></div>
  <div class="reply"><p class="author">Tom Reid</p><p class="body">Same here.</p></div>
</div>
<div class="comment">
  <p class="author">Ana Costa</p>
  <p class="body">One question about step 3.</p>
  <div class="reply"><p class="author">John Doe</p><p class="body">Happy to help.</p></div>
</div>

To get every reply to John Doe’s comment, but not John’s own reply elsewhere:





The condition uses the child step p, not descendant::p, so it only checks the comment’s own author line. Using descendant would also match Ana’s comment, because John’s reply sits inside it.

In Python:

replies = driver.find_elements(
    By.XPATH,
    "//div[contains(@class, 'comment') and p[contains(@class, 'author') "
    "and normalize-space(.)='John Doe']]/div[contains(@class, 'reply')]",
)
print(len(replies))  # 2
replies = driver.find_elements(
    By.XPATH,
    "//div[contains(@class, 'comment') and p[contains(@class, 'author') "
    "and normalize-space(.)='John Doe']]/div[contains(@class, 'reply')]",
)
print(len(replies))  # 2
replies = driver.find_elements(
    By.XPATH,
    "//div[contains(@class, 'comment') and p[contains(@class, 'author') "
    "and normalize-space(.)='John Doe']]/div[contains(@class, 'reply')]",
)
print(len(replies))  # 2
replies = driver.find_elements(
    By.XPATH,
    "//div[contains(@class, 'comment') and p[contains(@class, 'author') "
    "and normalize-space(.)='John Doe']]/div[contains(@class, 'reply')]",
)
print(len(replies))  # 2



contains() Compared with Other Ways to Match

Approach

What It Matches

Best Used When

contains()

The fragment anywhere in the value

The stable part sits in the middle or end of a value

starts-with()

Values that begin with the fragment

The stable part is always at the start

substring() workaround

Values that end with the fragment

You need to match a suffix, such as a file extension

CSS [attr*="..."]

The fragment anywhere in an attribute

Simple attribute matches, where CSS is shorter and easier to read

Exact match, such as @id='...' or text()='...'

The whole value

The value never changes, or partial matches would be too broad

contains() and starts-with()

starts-with() matches only when the value begins with the fragment. When the stable part is always at the front, it’s more specific than contains():





The two work well together:

Matching the End of a Value

XPath 1.0 has no ends-with(), so a suffix check uses substring():

It works, but it’s awkward. Often, a contains() on a more distinctive fragment does the same job more readably.

contains() and CSS Partial Matching

CSS selectors can match part of an attribute too. [class*="btn-primary"] behaves like contains(@class, 'btn-primary'):





CSS is usually shorter and easier to read for attribute matching. XPath is still needed to match visible text, which CSS can’t do. Modern browsers support the CSS :has() selector, which can select a parent based on its children, but for text matching and complex relationships, XPath is still the more capable option.

A practical rule: use CSS for simple attribute lookups, and XPath when you need text matching or relationships between elements.


Matching Too Much

//div[contains(@class, 'card')] matches card, card-header, card-body and cardboard-container. To match only the whole class name, pad the class value with spaces:

concat() adds a space before and after the class list, and the search looks for card with spaces around it. It only matches the whole word, at the cost of a longer expression.

Slow Expressions

Long chains of contains() and axes run slowly on large pages, because the engine checks every candidate node. Anchor expressions to a specific tag and avoid // in the middle of a path wherever you can.

text() on Nested Text

As covered above, text() only sees direct text nodes, and contains() uses only the first. Switch to . when text might be split across child elements.

Case Sensitivity

contains(@class, 'BTN') doesn’t match class="btn". Use translate() on the value when its case can change.

Assuming the Fragment Will Stay

A contains() locator relies on its fragment staying in the HTML. Class names get renamed, test IDs change and framework upgrades alter generated prefixes, so any contains() locator can break after a single DOM change.



Keeping contains() Locators Fast

  • Anchor to a tag. //*[contains(@class, 'btn')] checks every element on the page, while //button[contains(@class, 'btn')] checks only buttons.

  • Avoid repeated // steps. //div//span[contains(., 'Welcome')] has the engine search every span under every div, which can mean checking the same nodes many times.

  • Prefer a positive match over not(contains(...)) when another attribute can identify the element.

  • Use By.id or By.cssSelector when an exact ID or simple attribute is available, because browsers resolve those directly and quickly.

  • Reuse an element you’ve already found, and search inside it, instead of running the same full XPath again.

For example, find a panel once and search inside it:

WebElement panel = wait.until(ExpectedConditions.visibilityOfElementLocated(
    By.xpath("//div[contains(@class, 'settings-panel')]")));

WebElement save = panel.findElement(
    By.xpath(".//button[contains(normalize-space(.), 'Save')]"));
WebElement cancel = panel.findElement(
    By.xpath(".//button[contains(normalize-space(.), 'Cancel')]"));
WebElement panel = wait.until(ExpectedConditions.visibilityOfElementLocated(
    By.xpath("//div[contains(@class, 'settings-panel')]")));

WebElement save = panel.findElement(
    By.xpath(".//button[contains(normalize-space(.), 'Save')]"));
WebElement cancel = panel.findElement(
    By.xpath(".//button[contains(normalize-space(.), 'Cancel')]"));
WebElement panel = wait.until(ExpectedConditions.visibilityOfElementLocated(
    By.xpath("//div[contains(@class, 'settings-panel')]")));

WebElement save = panel.findElement(
    By.xpath(".//button[contains(normalize-space(.), 'Save')]"));
WebElement cancel = panel.findElement(
    By.xpath(".//button[contains(normalize-space(.), 'Cancel')]"));
WebElement panel = wait.until(ExpectedConditions.visibilityOfElementLocated(
    By.xpath("//div[contains(@class, 'settings-panel')]")));

WebElement save = panel.findElement(
    By.xpath(".//button[contains(normalize-space(.), 'Save')]"));
WebElement cancel = panel.findElement(
    By.xpath(".//button[contains(normalize-space(.), 'Cancel')]"));

The . at the start of the inner XPath limits the search to the panel. Without it, //button searches the whole document again, even when called on the panel element.


Debugging contains() Expressions

You can test an XPath in the browser console before putting it in a test. In Chrome, Edge or Firefox, open DevTools and run $x():

$x("//button[contains(@class, 'btn-primary')]")
$x("//button[contains(@class, 'btn-primary')]")
$x("//button[contains(@class, 'btn-primary')]")
$x("//button[contains(@class, 'btn-primary')]")

It returns an array of matches. An empty array means nothing matched, a long array means the locator isn’t specific enough, and one element means it’s right.

When a locator doesn’t behave as expected, check these causes:

  • Extra whitespace in the text, which normalize-space() fixes

  • A difference in letter case, which translate() fixes

  • Text split across child elements, which . fixes

  • An element that hadn’t rendered yet when the locator ran, which an explicit wait such as ExpectedConditions.presenceOfElementLocated fixes

  • An element inside an iframe, which switching context with driver.switchTo().frame(...) fixes

In Python, the wait looks like this:

from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC

button = WebDriverWait(driver, 10).until(
    EC.presence_of_element_located(
        (By.XPATH, "//button[contains(normalize-space(.), 'Submit')]")))
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC

button = WebDriverWait(driver, 10).until(
    EC.presence_of_element_located(
        (By.XPATH, "//button[contains(normalize-space(.), 'Submit')]")))
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC

button = WebDriverWait(driver, 10).until(
    EC.presence_of_element_located(
        (By.XPATH, "//button[contains(normalize-space(.), 'Submit')]")))
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC

button = WebDriverWait(driver, 10).until(
    EC.presence_of_element_located(
        (By.XPATH, "//button[contains(normalize-space(.), 'Submit')]")))



What contains() Costs at Scale

Used well, contains() produces locators that last much longer than exact matches. Each locator still depends on one way of identifying one element, though. When the fragment changes, the structure moves or a class is renamed, that locator breaks. Across a large suite, maintenance grows with the number of locators and how often the application changes, and eventually fixing existing tests can take longer than writing new ones. contains() pushes that point further away, but it doesn’t remove it.

Libraries that add self-healing on top of Selenium, such as Healenium, try to fix broken locators automatically while keeping tests in code. AI-native platforms take a different approach and don’t rely on a single locator at all. They identify each element from several signals at once, including its visible text, role and ARIA attributes, the surrounding DOM and its position on the page. If one signal changes, the others still identify the element.

The trade-off is real. Code-first XPath gives engineers precise control. An AI-native platform gives up some of that control in return for much less locator maintenance. Which fits better depends on your team, your application and how much maintenance you can absorb.


Writing the Same Step in Virtuoso QA

In Virtuoso QA, a tester describes the element in plain English, and the platform works out which element is meant. A Selenium step like this:

driver.findElement(By.xpath(
    "//button[contains(normalize-space(.), 'Submit') and contains(@class, 'btn-primary')]"))
    .click();
driver.findElement(By.xpath(
    "//button[contains(normalize-space(.), 'Submit') and contains(@class, 'btn-primary')]"))
    .click();
driver.findElement(By.xpath(
    "//button[contains(normalize-space(.), 'Submit') and contains(@class, 'btn-primary')]"))
    .click();
driver.findElement(By.xpath(
    "//button[contains(normalize-space(.), 'Submit') and contains(@class, 'btn-primary')]"))
    .click();

becomes:

The platform identifies the button from its text, role, surrounding structure and position, so there’s no XPath to write or maintain

  • When a release renames a class or wraps the button in a new container, Virtuoso QA proposes an updated match, and your team approves or rejects it, so every change to the test is recorded

  • If a step does fail, AI root cause analysis shows the failing step next to the last passing run, with the DOM, network activity and likely cause

  • Every run records which requirement each test covers and who approved it, giving teams evidence of what was tested before each release



Where Element Identification Is Heading

Teams that control both the application and the tests increasingly add attributes such as data-testid, purely for testing. These rarely change between releases, so they remove the need for contains() workarounds:

<button data-testid="submit-order" class="btn btn-primary-x92ka">Place order</button>
<button data-testid="submit-order" class="btn btn-primary-x92ka">Place order</button>
<button data-testid="submit-order" class="btn btn-primary-x92ka">Place order</button>
<button data-testid="submit-order" class="btn btn-primary-x92ka">Place order</button>
driver.findElement(By.cssSelector("[data-testid='submit-order']")).click();
// or
driver.findElement(By.xpath("//button[@data-testid='submit-order']")).click();
driver.findElement(By.cssSelector("[data-testid='submit-order']")).click();
// or
driver.findElement(By.xpath("//button[@data-testid='submit-order']")).click();
driver.findElement(By.cssSelector("[data-testid='submit-order']")).click();
// or
driver.findElement(By.xpath("//button[@data-testid='submit-order']")).click();
driver.findElement(By.cssSelector("[data-testid='submit-order']")).click();
// or
driver.findElement(By.xpath("//button[@data-testid='submit-order']")).click();

These locators are exact, fast and stable, as long as the development team keeps the attributes in place.

Self-healing libraries are also being added to code-first suites, combining XPath with extra signals to reduce brittleness while keeping tests in code.

New automation projects are increasingly starting on natural language platforms, with code-first frameworks kept for cases that need low-level control. contains() remains the workhorse of existing Selenium suites, but it’s less often the starting point for new ones.



Using contains() Well

The XPath contains() function is what makes Selenium workable on dynamic web applications. Use . with normalize-space() for text, translate() when case varies, whole-word matching for class names, and tightly scoped expressions for speed, and your locators will last much longer. When locator maintenance starts to slow your team down, adding stable data-testid attributes or moving to multi-signal identification are the next steps to consider.

Selenium Related Reads

Frequently Asked Questions

What does contains() do in XPath?

What is the alternative to XPath contains() for class matching?

Why does a contains() XPath work in DevTools but fail in Selenium?

Is XPath contains() slower than other locators?

How does Virtuoso QA handle element identification without XPath?

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