TestingAPIs is still being built. If something breaks, email support@testingapis.com.

XPath for testing XML responses

Paths, predicates, functions and attributes, with and without namespaces.

XPath is a small language for pointing at parts of an XML document. You write a path, much like a file path, and get back the elements, attributes or text it describes, or a number or yes-or-no answer computed from them. Nearly every XML tool understands it, from Java and .NET to browsers and SOAP testing tools, so it's the one XML skill that travels everywhere. This lesson teaches the parts you need for testing, using XPath 1.0, the version built into Java.

The two documents

The examples use two real responses. One is ListCountries from the countries SOAP service, here with each country on one line to save space:

ListCountries response
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
  <soap:Body>
    <ListCountriesResponse xmlns="http://example.com/countries">
      <Countries>
        <Country><Code>US</Code><Name>United States</Name></Country>
        <Country><Code>GB</Code><Name>United Kingdom</Name></Country>
        <Country><Code>IN</Code><Name>India</Name></Country>
        <Country><Code>DE</Code><Name>Germany</Name></Country>
        <Country><Code>FR</Code><Name>France</Name></Country>
        <Country><Code>JP</Code><Name>Japan</Name></Country>
        <Country><Code>BR</Code><Name>Brazil</Name></Country>
        <Country><Code>CA</Code><Name>Canada</Name></Country>
        <Country><Code>AU</Code><Name>Australia</Name></Country>
        <Country><Code>ZA</Code><Name>South Africa</Name></Country>
      </Countries>
    </ListCountriesResponse>
  </soap:Body>
</soap:Envelope>

The other is /api/formats/xmlsign in to see from lesson 1: two users with an id attribute, a name, an email, a list of roles and an active flag, and no namespaces.

Paths

An XPath expression is read left to right, one step at a time:

Reading an expression
/users/user[@id='2']/name

/            start at the top of the document
users        go to the child element called users (the root)
/user        then to its children called user
[@id='2']    keep only the ones whose id attribute is '2'
/name        then go to their name child
SyntaxMeans
/At the start: the document itself. Between steps: go to the children
//Go to descendants at any depth. //role finds every role element, wherever it is
nameElements with this name
*Elements with any name
@nameThe attribute with this name
text()The text inside an element
.The current element
..Its parent

For the countries document, the elements are in the http://example.com/countries namespace, so every step needs a prefix bound to it, as lesson 2 explained. The tests bind c:

XPathTest.java
@Test
@DisplayName("paths: step from element to element")
void paths() {
    assertEquals("US", c.string(countries, "//c:Countries/c:Country/c:Code")); // string() gives the first match
    assertEquals(10, c.strings(countries, "//c:Country/c:Code").size());
}

//c:Countries/c:Country/c:Code selects all ten Code elements. Asked for a string, XPath returns the text of the first one, US, without complaint. That's a classic way for a test to pass by accident: if you mean a particular element, say which one.

Predicates: choosing which ones

A condition in square brackets after a step, called a predicate, keeps only the nodes for which it's true:

XPathTest.java
@Test
@DisplayName("predicates: filter with conditions in square brackets")
void predicates() {
    assertEquals("Japan", c.string(countries, "//c:Country[c:Code='JP']/c:Name"));
    assertEquals("South Africa", c.string(countries, "//c:Country[last()]/c:Name"));
    assertEquals("India", c.string(countries, "//c:Country[3]/c:Name"));
    assertEquals(List.of("US", "GB"), c.strings(countries, "//c:Country[starts-with(c:Name, 'United')]/c:Code"));
}
  • [c:Code='JP'] keeps the Country whose Code child has the text JP. The condition is evaluated from the Country, so c:Code means its child.
  • [3] is short for "position 3". Positions start at 1, not 0, and [last()] is the last one.
  • starts-with(c:Name, 'United') calls a function. Several countries pass, so strings returns both codes, in document order.

Functions and the four result types

An XPath expression returns one of four types, and the project's XmlQuery has a method for each:

TypeExampleXmlQuery method
String//c:Country[c:Code='JP']/c:Namestring(): the text, or the first node's text
Numbercount(//c:Country)number(): a double, so 10 is 10.0
Boolean//c:Country/c:Code = 'BR'bool()
Node set//c:Country/c:Codestrings(): the text of every node, as a list
XPathTest.java
@Test
@DisplayName("functions: count, sum, string length and more")
void functions() {
    assertEquals(10, c.number(countries, "count(//c:Country)"));
    assertEquals(3, plain.number(users, "count(//role)"));
    assertTrue(c.bool(countries, "//c:Country/c:Code = 'BR'")); // true if any Code equals BR
    assertEquals(2, c.number(countries, "string-length(//c:Country[c:Name='Canada']/c:Code)"));
}

The third assertion shows a rule that surprises people: comparing a node set with a value is true if any node matches. //c:Country/c:Code = 'BR' is true because one of the ten codes is BR. The common functions:

FunctionReturns
count(nodes)How many nodes
sum(nodes)The total of their values as numbers
contains(text, part)Whether text contains part
starts-with(text, start)Whether text starts with start
string-length(text)The number of characters
normalize-space(text)Text with spaces trimmed and runs of spaces squeezed to one
not(condition)The opposite of a condition
number(text)Text converted to a number, or NaN
local-name(node)An element's name without its namespace (lesson 2)

Attributes and conditions on children

The users document has no namespace, so its queries need no prefixes:

XPathTest.java
@Test
@DisplayName("attributes are selected with @")
void attributes() {
    assertEquals("Bob Smith", plain.string(users, "/users/user[@id='2']/name"));
    assertEquals(List.of("1", "2"), plain.strings(users, "//user/@id"));
}
XPathTest.java
@Test
@DisplayName("conditions on children: users who have the admin role")
void childConditions() {
    assertEquals(List.of("Alice Johnson"), plain.strings(users, "//user[roles/role='admin']/name"));
    assertEquals(List.of("Bob Smith"), plain.strings(users, "//user[active='false']/name"));
}

//user[roles/role='admin'] reads: users who have a role element, under roles, equal to admin. The any-match rule from above is what makes this work for Alice, who has two roles. And [active='false'] compares text with text, because XML has no booleans.

The helper

Java's XPath API takes a few lines per query, and namespaces need a NamespaceContext object. The project hides that in XmlQuery:

XmlQuery.java
package com.testingapis.soap;

import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import javax.xml.namespace.NamespaceContext;
import javax.xml.namespace.QName;
import javax.xml.xpath.XPath;
import javax.xml.xpath.XPathConstants;
import javax.xml.xpath.XPathExpressionException;
import javax.xml.xpath.XPathFactory;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;

/**
 * XPath with the prefixes you choose. The prefixes in your queries don't have to match the ones in the
 * document: what matters is the namespace URI each prefix stands for (lesson 2).
 *
 *   XmlQuery q = new XmlQuery(Map.of("c", "http://example.com/countries"));
 *   q.string(doc, "//c:Country[c:Code='IN']/c:Capital")   // "New Delhi"
 */
public final class XmlQuery {

    private final XPath xpath = XPathFactory.newInstance().newXPath();

    public XmlQuery(Map<String, String> prefixes) {
        xpath.setNamespaceContext(new NamespaceContext() {
            @Override
            public String getNamespaceURI(String prefix) {
                return prefixes.getOrDefault(prefix, "");
            }

            @Override
            public String getPrefix(String uri) {
                return null;
            }

            @Override
            public Iterator<String> getPrefixes(String uri) {
                return null;
            }
        });
    }

    /** No prefixes: for documents without namespaces. */
    public XmlQuery() {
        this(Map.of());
    }

    public String string(Node node, String expression) {
        return (String) evaluate(node, expression, XPathConstants.STRING);
    }

    public double number(Node node, String expression) {
        return (Double) evaluate(node, expression, XPathConstants.NUMBER);
    }

    public boolean bool(Node node, String expression) {
        return (Boolean) evaluate(node, expression, XPathConstants.BOOLEAN);
    }

    /** The text of every node the expression selects, in document order. */
    public List<String> strings(Node node, String expression) {
        NodeList nodes = (NodeList) evaluate(node, expression, XPathConstants.NODESET);
        List<String> values = new ArrayList<>();
        for (int i = 0; i < nodes.getLength(); i++) values.add(nodes.item(i).getTextContent());
        return values;
    }

    private Object evaluate(Node node, String expression, QName type) {
        try {
            return xpath.evaluate(expression, node, type);
        } catch (XPathExpressionException e) {
            throw new IllegalArgumentException("Bad XPath '" + expression + "': " + e.getMessage(), e);
        }
    }
}

The constructor turns a map of prefixes into the NamespaceContext that Java asks for when it meets a prefix in a query. Each method evaluates the expression and asks for one of the four result types. Any library you use will have something similar; REST Assured, for example, can check XML responses with XPath directly.

Cheat sheet

To getWrite
An element's text/users/user[@id='2']/name
An attribute//user/@id
Every match, anywhere//c:Country/c:Code
A match by a child's value//c:Country[c:Code='JP']/c:Name
By position//c:Country[3], //c:Country[last()]
By a function//c:Country[starts-with(c:Name, 'United')]
A countcount(//c:Country)
Whether anything matches//c:Country/c:Code = 'BR'
Ignoring namespaces (use sparingly)//*[local-name()='AddResult']

Check your understanding

  1. 1.What's the difference between /users/user and //user?Show answer

    /users/user goes from the root users element to its user children only. //user finds user elements at any depth.

  2. 2.What does //c:Country[1] return here, and at what position does XPath start counting?Show answer

    The first Country, the United States. Positions start at 1.

  3. 3.//c:Country/c:Code has ten matches. What does string() on it return?Show answer

    The text of the first match, US. To check a specific one, add a predicate.

  4. 4.Why is //c:Country/c:Code = 'BR' true?Show answer

    Comparing a node set with a value is true if any node in the set matches, and one Code is BR.

  5. 5.How do you select an attribute called id?Show answer

    With @id, for example //user/@id or a predicate like user[@id='2'].

Exercise

Using GetCountryInfo for JP, write XPath checks for: the capital; whether the population is more than 100 million, using number() and >; and whether the phone code starts with a plus sign. Then, using the users document, write an expression for the email of every user who is not active, and one that counts users with more than one role.

Practise and go further