ScreenshotNeo

BlogHow-to

How to Find Elements by Text Using XPath contains()

Use XPath contains() to locate elements by partial text, handle nested markup and whitespace, and write reliable Selenium locators in Python and Java.

By the ScreenshotNeo team29 September 202611 min read

How to Find Elements by Text Using XPath contains()

Use contains() inside an XPath predicate to match an element whose text includes a substring. For a button labeled “Continue to checkout,” for example, this XPath matches the button even though the full label is longer:

//button[contains(., 'Continue')]

In Selenium, pass that expression to the XPath locator strategy. In Python: driver.find_element(By.XPATH, "//button[contains(., 'Continue')]"). The dot (.) evaluates the element’s combined string value, including descendant text. If the relevant text is a direct text node, contains(text(), 'Continue') can work too. For labels with inconsistent spacing, use normalize-space().

This guide explains what each expression matches, how to narrow it to the right element, and how to use it reliably in Selenium. XPath is a good fit when text is the stable signal you have; when a unique ID or data attribute exists, that is usually a simpler locator. Selenium documents XPath as a supported locator strategy and cautions that XPath can be harder to debug than simpler locators. (W3C XPath 1.0; Selenium locator strategies; Selenium locator guidance.)

1. What XPath contains() means

contains(a, b) is a Boolean string function: it is true when string a contains string b. A predicate in square brackets filters the nodes selected before it. Put together, //button[contains(., 'Continue')] means: find button elements anywhere in the document, then keep those whose string value includes the exact substring Continue.

The function performs a substring match, not a word-boundary match. It can match “Continue,” “Continue shopping,” or “Please Continue,” but it will not match “Continuing” if the target string includes a different sequence. It does not require an exact label.

How the XPath pieces fit

Piece Meaning
// Search descendants from the current XPath context.
button Restrict the search to button elements.
[...] Filter the candidate buttons by a condition.
contains(., 'Continue') Keep candidates whose string value contains the substring.

The predicate filters each candidate button in turn. This is why adding an element name is useful: //*[contains(., 'Continue')] can match a button and its containing form, section, or other ancestors, while //button[contains(., 'Continue')] asks only for buttons.

2. Choose text() or the dot

The most important choice is what text expression to pass to contains(). XPath text() is a node test for text nodes. The dot refers to the current candidate node; when XPath converts an element to a string, it uses the concatenated text of its descendant text nodes in document order.

The dot lets XPath inspect the combined descendant text when a label is split across nested markup.
The dot lets XPath inspect the combined descendant text when a label is split across nested markup.

Consider this markup:

<button>Continue <strong>to checkout</strong></button>

The label is split across a direct text node and a nested <strong> node. //button[contains(., 'Continue to checkout')] tests the button’s combined string value. //button[contains(text(), 'Continue to checkout')] tests the button’s direct text-node selection; it does not represent the full combined label in the same way and can fail when the phrase crosses a markup boundary.

Use contains(text(), ...) when you specifically mean direct text and know the page structure supports it. For ordinary element labels that may contain nested markup, contains(., ...) is usually the more robust starting point. The dot matches descendant text, whether or not that text is visually displayed; XPath is not a visibility filter.

Common patterns

Goal XPath
Button contains a phrase, including nested text //button[contains(., 'Continue')]
Direct text node contains a phrase //button[contains(text(), 'Continue')]
Link text contains a phrase //a[contains(., 'Documentation')]
Accessible label contains a phrase //button[contains(@aria-label, 'Continue')]
Element’s normalized label is exactly a phrase //button[normalize-space(.) = 'Continue']

3. Handle extra whitespace and exact matches

HTML source can contain line breaks, indentation, and repeated spaces. If you need a substring match that tolerates surrounding or repeated formatting whitespace, wrap the element string value in normalize-space():

//button[contains(normalize-space(.), 'Continue to checkout')]

normalize-space() removes leading and trailing whitespace and replaces runs of whitespace with a single space. It does not make the comparison case-insensitive, and it does not make an approximate or fuzzy match. For an exact label after whitespace normalization, use equality:

//button[normalize-space(.) = 'Continue to checkout']

Choose carefully between substring and equality. Substring matching is useful when surrounding text changes predictably, but a short fragment such as OK may match “OK,” “Book,” or other unintended labels. Exact normalized equality is more selective when the complete label is stable.

4. Write a reliable text locator

  1. Identify the intended element. Decide whether it is a button, link, heading, or another element. Avoid searching every node unless you have a reason.
  2. Choose the text scope. Use . for combined descendant text; use text() only when direct text-node matching is intended.
  3. Choose substring or exact matching. Use contains() for a stable fragment, or equality for a complete normalized label.
  4. Make the match specific. Add a stable attribute, a relevant parent, or a relationship when duplicate labels are possible.
  5. Check the result count and visibility. Confirm the locator identifies the intended element on the actual page before clicking it.

For example, if the button has a stable accessible label, combine the element and attribute rather than relying on broad page text:

//button[contains(@aria-label, 'Continue')]

If its visible label is the stable identifier, narrow by element type and consider exact normalized text:

//button[normalize-space(.) = 'Continue']

XPath string literals use single or double quotes. If your target phrase itself contains a quote, XPath 1.0 has no backslash escape for a quote inside a string literal. Use the other quote type if possible. If the text contains both quote types, construct the value with XPath’s concat() function. For example, the text He said "it's ready" can be represented by combining quoted pieces with concat(). When building an XPath from variable input, escape or construct the XPath literal correctly instead of concatenating raw user text into an expression.

5. Use XPath contains() in Selenium

Selenium accepts XPath using By.XPATH. The following Python example opens a page, waits for a matching button to become clickable, then clicks it. Install Selenium with python -m pip install selenium; use a browser and driver setup supported by your Selenium environment.

from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.support.ui import WebDriverWait

url = "https://example.com/checkout"
locator = (By.XPATH, "//button[contains(., 'Continue')]")

driver = webdriver.Chrome()
try:
    driver.get(url)
    button = WebDriverWait(driver, 10).until(
        EC.element_to_be_clickable(locator)
    )
    button.click()
finally:
    driver.quit()

Replace the example URL with the page under test. The explicit wait gives the page time to render the element; it does not repair a locator that matches the wrong node or no nodes at all. If multiple buttons contain the phrase, inspect all matches and make the expression more specific before using find_element, which returns one match.

Java example

import org.openqa.selenium.By;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.chrome.ChromeDriver;
import org.openqa.selenium.support.ui.ExpectedConditions;
import org.openqa.selenium.support.ui.WebDriverWait;
import java.time.Duration;

public class FindContinueButton {
    public static void main(String[] args) {
        WebDriver driver = new ChromeDriver();
        try {
            driver.get("https://example.com/checkout");
            By locator = By.xpath("//button[contains(., 'Continue')]");
            WebElement button = new WebDriverWait(driver, Duration.ofSeconds(10))
                .until(ExpectedConditions.elementToBeClickable(locator));
            button.click();
        } finally {
            driver.quit();
        }
    }
}

Check whether the locator is unique

During debugging, retrieve all matches rather than assuming the first result is correct. This Python snippet prints the count and each match’s text:

matches = driver.find_elements(By.XPATH, "//button[contains(., 'Continue')]")
print(f"matches: {len(matches)}")
for index, element in enumerate(matches):
    print(index, repr(element.text))

If the page has no match, check spelling, text timing, frame context, and whether the text is actually in the DOM. If it has several, add scope or an attribute. A locator that returns one element today may become ambiguous when the page changes, so uniqueness is worth checking in tests.

6. When XPath text matching is the right tool

Text-based XPath helps when the wording itself is the identifier and no stable ID or data attribute is available. It is also useful when you need a relationship, such as a button inside a particular dialog. But text can change with localization, content edits, A/B variants, or personalization. If the application exposes a unique ID or stable test attribute, that is generally easier to maintain.

Keep expressions compact and readable. XPath is flexible, but a long path tied to several ancestor levels can break when markup changes. Prefer a short locator based on a stable semantic element and one meaningful condition. Selenium’s locator guidance recommends compact readable locators, and notes XPath can be difficult to debug and may be slower than simpler strategies. For most test suites, reducing unnecessary DOM traversal and waiting only as long as the app needs helps keep the suite responsive.

Case sensitivity

Do not assume your XPath host treats text matching as case-insensitive. A literal substring is an exact string comparison in XPath 1.0; contains(., 'continue') does not match text with different casing such as Continue. If the UI case is not stable, normalize case explicitly using translate() for a known alphabet, or use an application-provided stable attribute. Verify the behavior in the browser and XPath environment you support rather than depending on an undocumented cross-browser assumption.

7. Troubleshooting

Symptom Likely cause Fix
NoSuchElementException The expression does not match current DOM text, the page has not rendered, or the target is in another frame. Inspect the live DOM, verify the exact phrase and context, wait for the element, and switch into the correct frame if applicable.
InvalidSelectorException Malformed XPath, mismatched brackets or quotes, or an unescaped quote in a string literal. Check the XPath syntax and construct a valid XPath string literal. Test a short expression first.
The locator matches a parent and child A broad expression such as //*[contains(., 'Continue')] matches every qualifying ancestor too. Specify the expected tag, stable attribute, or a relevant relationship.
It misses text split by markup text() is being used where the phrase spans direct and nested text nodes. Try contains(., 'phrase') on the intended element.
It misses a label with odd spacing The expression expects whitespace exactly as represented. Use normalize-space(.), then verify the normalized phrase.
The first match is not the target Several elements contain the same short fragment. Use find_elements to inspect matches, then make the locator more specific or use exact normalized equality.
It finds a hidden or offscreen element XPath evaluates DOM structure and text; it does not promise visibility or interactability. Wait for visibility or clickability with Selenium expected conditions and confirm the page has the intended visible control.
It works locally but fails in CI Timing, viewport, browser state, localization, or a different rendered page may change the DOM. Use explicit waits, a deterministic test state, stable attributes, and inspect the CI page state when failure occurs.
It cannot see content in a shadow root Ordinary document XPath does not search inside a separate shadow root. Locate the host, access its shadow root using the supported WebDriver API, and locate within that root with supported strategies.

8. Performance, reliability, and cost

A single text XPath lookup is rarely the dominant cost in a test. Page loads, network calls, waits, and browser startup often take more time. Still, an expression that scans every node and checks long descendant text can perform more work than a direct ID lookup. Scope the search to the smallest useful region, avoid repeated broad scans in loops, and prefer an ID or stable data attribute when the application provides one. Selenium notes that DOM traversal is expensive and XPath may be slower; treat this as a reason to keep locators simple, not as a universal timing estimate.

Reliability usually matters more than shaving a small amount off one lookup. Text locators are coupled to UI copy; localization or a product wording change can cause failures. Use waits for expected page state, avoid arbitrary sleeps, and assert that the intended element is unique and actionable. If the wording is the actual behavior under test, text matching is appropriate. If the test only needs to interact with a control, a stable test attribute may be less brittle.

There is no XPath usage fee in Selenium itself. The practical cost is engineering and CI time spent debugging brittle selectors, along with the runtime of browser sessions. Save time by using short selectors, actionable waits, and useful failure diagnostics such as match counts and element text. A screenshot of the rendered page can also help distinguish a locator bug from a page that failed to load or displayed a consent overlay.

9. Inspect the rendered page with ScreenshotNeo

If an XPath unexpectedly finds nothing, the problem may be the rendered page state rather than the expression: a consent banner, popup, failed navigation, or bot check can obscure the expected content. [ScreenshotNeo](https://screenshotneo.com) is a website screenshot API and MCP server. Its screenshot can help you inspect what the page actually rendered; it does not replace Selenium’s DOM locator or prove that an XPath matches.

A screenshot can reveal whether overlays or page state explain an unexpected locator result.
A screenshot can reveal whether overlays or page state explain an unexpected locator result.

10. FAQ

Can XPath contains() match part of an attribute?

Yes. Use an attribute expression such as //button[contains(@aria-label, 'Continue')]. Prefer stable attributes when they exist, and keep the tag or surrounding scope specific.

Can I match text that contains an apostrophe?

Yes. Use double quotes around an XPath literal containing an apostrophe. If the target contains both quote types, assemble the literal with XPath concat(); the host language’s string escaping and XPath’s quoting are separate layers.

Does XPath contains() match text rendered only by CSS?

No. XPath queries the document tree and its string values. Text generated only by CSS is not a text node in the DOM string value.

Or skip the browser setup

For a rendered screenshot without setting up a browser session, make one GET request. See the [ScreenshotNeo API documentation](https://screenshotneo.com/docs/) for the available parameters.

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
  • Cookie banners, popups, and chat widgets are removed before capture; each step can be turned off.
  • Bot checks, blank pages, and failed loads are never billed; response headers report the page verdict and billing status.
  • An MCP server lets AI agents, including Claude and Cursor, take screenshots.
  • The free plan includes 1,000 screenshots a month with no card; paid plans start at $5 for 3,000.

Sign up free for 1,000 screenshots a month, with no card required.