Contains in XPath: A Guide to Robust Web Test Selectors

Learn how to use contains() in XPath to create flexible, resilient locators for web automation. This guide covers syntax, examples, and best practices.

contains in xpathxpath selectorsweb automationseleniumplaywright
monito

Contains in XPath: A Guide to Robust Web Test Selectors

contains in xpathxpath selectorsweb automationselenium
May 1, 2026

Your test passed yesterday. Today it fails because the button text changed from “Submit” to “Submit Your Order,” or because a frontend refactor turned id="login-btn" into id="login-btn-7f3c". Nothing meaningful broke for the user, but your automation still went red.

That’s why people care about contains in xpath. Not because XPath is elegant. Not because partial matching is clever. Because brittle selectors waste time, hide real regressions, and train teams to ignore failing tests.

A good selector should survive normal UI churn. It should be specific enough to find the right element, but flexible enough to tolerate the harmless changes that happen every sprint.

Why Your Web Tests Break and How XPath Can Help

Most broken UI tests aren't exposing product bugs. They're exposing selector bugs.

A checkout flow can fail because marketing changed a button label. A login test can fail because a component library regenerated class names. A toast assertion can fail because the app now says “Saved successfully” instead of “Save successful”. When that keeps happening, the team stops trusting the suite.

XPath helps because it gives you more ways to describe an element than CSS usually does. You can target attributes, text, hierarchy, and combinations of all three. That flexibility is why over 65% of web automation projects utilize XPath selectors, with contains() among the most frequently used functions, according to Rebrowser’s XPath contains guide.

A common failure pattern

Say your test uses this:

//button[text()='Submit']

That works until the product team changes the label to:

<button>Submit Order</button>

Now your exact match is dead, even though the user flow still works.

This is where contains() earns its keep:

//button[contains(text(), 'Submit')]

That selector is less fragile because it anchors on the stable part of the label, not the full string.

Practical rule: If the app changes small parts of labels, IDs, or classes regularly, exact-match selectors usually cost more to maintain than they're worth.

XPath isn't magic. You can still write terrible XPath. Absolute paths like /html/body/div[3]/div[2]/button are often just time bombs with nicer syntax. But when you use relative XPath and partial matching well, you get selectors that bend instead of snap.

Teams that want fewer flaky failures usually end up adopting the same habit. They stop asking, “Can I locate this element?” and start asking, “Will this locator still work after the next deploy?” That mindset lines up with the same maintenance-first thinking behind automated testing best practices for smaller teams.

Mastering the Basics of XPath contains()

The contains() function is straightforward. Its purpose is to determine if one string is present within another.

Imagine searching a document for a keyword instead of matching a full sentence. If you're looking for “checkout,” you don't care whether the full text is “Proceed to checkout” or “Checkout now.” You care that the stable word is present.

The basic syntax

The shape is always the same:

contains(arg1, arg2)
  • arg1 is what XPath inspects. Usually an attribute like @id, @class, or text.
  • arg2 is the substring you want to find.

Inside a selector, it usually looks like this:

//tag[contains(@attribute, 'value')]

or

//tag[contains(text(), 'value')]

Using contains with attributes

Attribute matching is the most common use.

If your app generates IDs like this:

<input id="email-field-93ad2" type="email">

An exact match is fragile. A partial match is better:

//input[contains(@id, 'email-field')]

Same idea for classes:

<button class="btn btn-primary checkout-action">Pay Now</button>

You can match the stable part:

//button[contains(@class, 'checkout-action')]

A few practical attribute examples:

//a[contains(@href, '/pricing')]
//div[contains(@data-testid, 'toast')]
//input[contains(@name, 'search')]

Using contains with text

Text matching is useful when the visible label is your best anchor.

<button>Start Free Trial</button>

XPath:

//button[contains(text(), 'Start')]

Another example:

<div class="alert">Payment failed. Try another card.</div>

XPath:

//div[contains(text(), 'Payment failed')]

contains() is case-sensitive. Submit and submit are different matches.

The three forms you'll use most

Use case XPath
Partial ID //input[contains(@id, 'email')]
Partial class //button[contains(@class, 'primary')]
Partial text //button[contains(text(), 'Checkout')]

One important gotcha

text() only looks at direct text inside the element. If the text is split across nested tags, contains(text(), ...) can fail.

For clean HTML, it's fine:

<button>Checkout</button>

For nested HTML, be careful:

<button><span>Check</span>out</button>

In that case, you'll often need a different approach, which is where . and normalize-space() become useful.

Practical Examples for Common Scenarios

Most real selector work isn't about syntax. It's about dealing with messy frontend output without making your tests loose and dangerous.

These are the patterns that come up constantly.

Dynamic IDs that change every build

Frameworks and component libraries often append random-looking suffixes.

<input id="session-user-a4b8c" type="text">

Bad selector:

//input[@id='session-user-a4b8c']

Better selector:

//input[contains(@id, 'session-user')]

This works when the stable prefix matters more than the generated tail.

Elements with multiple classes

Class attributes are often long strings, and some class names change while others stay stable.

<button class="btn btn-large btn-primary add-to-cart active">Add to Cart</button>

A practical locator:

//button[contains(@class, 'btn-primary') and contains(@class, 'add-to-cart')]

That gives you flexibility without matching every primary button on the page.

Broad class matching is where people get sloppy. contains(@class, 'btn') usually matches far more elements than you think.

Text with variable details

A lot of UI text contains counters, usernames, or state-specific words.

<div class="notification">You have 3 new messages</div>

You don't want your test tied to the number unless the number itself matters.

Use this:

//div[contains(text(), 'new messages')]

Or, if the beginning is more stable:

//div[contains(text(), 'You have')]

The selector should reflect what the test is proving. If you're only asserting that the notification appears, don't hard-code dynamic content into the locator.

Combining text and attributes

The contains() function in XPath proves particularly useful. You can mix partial signals to make a locator both stable and narrow.

<div class="alert alert-success">Settings saved successfully</div>

XPath:

//div[contains(@class, 'alert') and contains(text(), 'saved')]

That’s much safer than matching only the class or only the word “saved”.

A small pattern library

Here are a few selectors worth keeping around:

  • Dynamic link path

    //a[contains(@href, '/checkout')]
    
  • Modal title with changing suffix

    //h2[contains(text(), 'Order Summary')]
    
  • Toast with stable meaning

    //div[contains(@class, 'toast') and contains(text(), 'success')]
    
  • Button in a specific area

    //section[contains(@class, 'billing')]//button[contains(text(), 'Update')]
    

The pattern is consistent. Find the stable fragment, then add just enough context to avoid accidental matches.

Comparing contains() with starts-with() and normalize-space()

contains() is useful, but it isn't the only string tool worth knowing. If you use it everywhere, you'll eventually write selectors that are broader or noisier than they need to be.

The better approach is to choose the function that matches the shape of the problem.

When contains() is the right fit

Use contains() when the stable part may appear anywhere in the string.

//button[contains(text(), 'Checkout')]

This is good for labels like:

  • “Checkout”
  • “Proceed to Checkout”
  • “Express Checkout”

The trade-off is obvious. More flexibility can also mean more accidental matches.

When starts-with() is cleaner

Use starts-with() when the beginning of the string is stable and meaningful.

//button[starts-with(text(), 'Save')]

That works well if your app has labels like:

  • “Save”
  • “Save Changes”
  • “Save Draft”

But it won't match “Quick Save” or “Auto Save”.

starts-with() is often better than contains() when prefixes are deliberate and consistent. It narrows the selector without forcing a full exact match.

When normalize-space() saves you

Whitespace bugs are common in rendered HTML. Text may include line breaks, double spaces, or padding that you don't notice in the browser.

Example HTML:

<button>
  Save Changes
</button>

This may not behave well with exact text matching. normalize-space() cleans the text before comparing it.

//button[normalize-space(text())='Save Changes']

Or combined with partial matching:

//button[contains(normalize-space(.), 'Save Changes')]

That second form is especially useful when nested tags are involved.

If a selector looks correct but returns nothing, check whitespace and nested text nodes before assuming the app changed.

Side by side comparison

Function Best for Risk
contains() Partial match anywhere Too broad if substring is weak
starts-with() Stable prefixes Misses valid matches with changed prefixes
normalize-space() Messy text and spacing Doesn't help if you're matching the wrong node

A simple decision rule

Use this mental shortcut:

  1. If the text must match exactly and spacing is messy, use normalize-space().
  2. If the label starts predictably but may have extra words, use starts-with().
  3. If the stable fragment could appear anywhere, use contains().

And if you're targeting user-visible text inside nested markup, prefer . over text():

//button[contains(normalize-space(.), 'Buy now')]

That works more reliably than contains(text(), 'Buy now') when the content is split across child elements.

Best Practices for Robust and Maintainable Selectors

A selector isn't good because it works today. It's good if it still works after the next refactor.

That's the standard that matters in test automation. A locator that passes in a pristine demo and flakes in production isn't helping anyone.

Prefer narrow and meaningful matches

This is bad:

//a[contains(@href, '.com')]

It will probably match a pile of unrelated links.

This is better:

//nav//a[contains(@href, '/pricing')]

Same function. Very different selector quality.

Good contains() usage depends on meaningful substrings. Match the part of the value that reflects intent, not a generic fragment that appears everywhere.

Don't use contains() when a stronger hook exists

If the page gives you a stable unique ID, a purpose-built data-testid, or a reliable ARIA attribute, use that first.

Examples of stronger hooks:

  • Stable test attribute

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

    //*[@id='billing-email']
    
  • Accessible label

    //*[@aria-label='Close dialog']
    

Use contains() when exact matching is too brittle, not as a reflex.

Be careful on large pages

There’s a practical performance angle here that most tutorials skip. According to IPRoyal’s guide on XPath contains, there is virtually no coverage of performance implications when using contains() against massive DOM trees, and understanding when it becomes a bottleneck could reduce test execution time by 20-40%.

That doesn't mean contains() is slow by default. It means broad XPath queries on big pages deserve scrutiny.

A few ways to keep it under control:

  • Scope the search Use //main//button[...] instead of //*[...].

  • Start with a tag //button[contains(...)] is tighter than //*[contains(...)].

  • Anchor to a container Search inside a modal, form, or section when possible.

  • Avoid stacking vague conditions A long XPath made of weak partial matches is harder to debug and often less reliable.

Think in maintenance cost

A flaky locator doesn't just fail one test. It creates review noise, rerun fatigue, and hesitation around deploys. That's why selector quality is part of the same conversation as regression testing best practices for growing products.

The cheapest selector is the one you don't have to revisit every other sprint.

If you want reliable tests, write locators like you'll inherit them from someone else in six months. Because you probably will.

Using contains() in Your Tools and Beyond

The nice thing about XPath is that the expression itself usually transfers cleanly between tools. The annoying part is that behavior around text matching, timing, rendering, and platform quirks doesn't always transfer as cleanly.

Real-world testing agents have to deal with that inconsistency. As noted in DataHen’s write-up on XPath contains text issues, contains() can behave inconsistently across browsers and automation platforms, and the lack of a troubleshooting flow forces many users into trial-and-error debugging.

Selenium examples

Python:

from selenium.webdriver.common.by import By

button = driver.find_element(By.XPATH, "//button[contains(text(), 'Checkout')]")

Java:

WebElement button = driver.findElement(
    By.xpath("//button[contains(text(), 'Checkout')]")
);

Playwright example

JavaScript or TypeScript:

const button = page.locator("//button[contains(text(), 'Checkout')]");
await button.click();

Where manual XPath work starts to hurt

The hard part isn't writing one selector. It's maintaining a whole suite of them across changing UIs, multiple environments, and edge cases like split text nodes, unstable classes, and framework-specific rendering behavior.

When debugging gets messy, browser tooling helps. If you're inspecting failing locators in the DOM, debugging in Chrome for web apps is still one of the most practical skills to build.

For teams that don't want to handcraft and maintain locators at all, the next step is letting an AI testing agent generate, validate, and fall back between strategies automatically. That's the direction the tooling is moving because the problem wasn't XPath syntax itself. It was maintenance.


If you're tired of brittle selectors and constant test upkeep, Monito is the practical shortcut. You describe what to test in plain English, and the AI agent runs the flow in a real browser, handles locator strategy for you, and returns structured bug reports with logs, screenshots, and replay data. It's built for small teams that need dependable web testing without writing and maintaining a pile of Playwright or Selenium code.

All Posts