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:
<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:
/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| Syntax | Means |
|---|---|
| / | 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 |
| name | Elements with this name |
| * | Elements with any name |
| @name | The 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:
@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:
@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 textJP. The condition is evaluated from the Country, soc:Codemeans 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, sostringsreturns 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:
| Type | Example | XmlQuery method |
|---|---|---|
| String | //c:Country[c:Code='JP']/c:Name | string(): the text, or the first node's text |
| Number | count(//c:Country) | number(): a double, so 10 is 10.0 |
| Boolean | //c:Country/c:Code = 'BR' | bool() |
| Node set | //c:Country/c:Code | strings(): the text of every node, as a list |
@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:
| Function | Returns |
|---|---|
| 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:
@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"));
}@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:
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 get | Write |
|---|---|
| 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 count | count(//c:Country) |
| Whether anything matches | //c:Country/c:Code = 'BR' |
| Ignoring namespaces (use sparingly) | //*[local-name()='AddResult'] |
Check your understanding
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.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.//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.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.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.