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:
In Python:
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:
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:
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:
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:
In Python:
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:
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:
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:
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:
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:
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:
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 |
| Values that end with the fragment | You need to match a suffix, such as a file extension |
CSS | The fragment anywhere in an attribute | Simple attribute matches, where CSS is shorter and easier to read |
Exact match, such as | 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 everyspanunder everydiv, 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.idorBy.cssSelectorwhen 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:
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():
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()fixesA difference in letter case, which
translate()fixesText split across child elements, which
.fixesAn element that hadn’t rendered yet when the locator ran, which an explicit wait such as
ExpectedConditions.presenceOfElementLocatedfixesAn element inside an
iframe, which switching context withdriver.switchTo().frame(...)fixes
In Python, the wait looks like this:
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:
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:
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?










