Node Types and Wildcards
Understand the seven node types XPath can see, what * and node() really match, and why //DIV finds nothing in an HTML page.
Node Types and Wildcards
XPath does not see HTML source. It sees a tree of nodes built by the browser’s parser. Knowing the node types explains a lot of “why doesn’t this match?” moments.
The seven node types
| Node type | Example | How to select |
|---|---|---|
| Root | the document itself | / |
| Element | <button> | button, * |
| Attribute | type="submit" | @type, @* |
| Text | Sign In | text() |
| Comment | <!-- todo --> | comment() |
| Processing instruction | <?xml-stylesheet ?> | processing-instruction() |
| Namespace | xmlns:svg | namespace::* (rare) |
In test automation you will use elements, attributes and text nodes almost exclusively.
Wildcards
//* every element in the document
//form/* every element that is a direct child of a form
//*[@data-testid] any element with a data-testid attribute
//@* every attribute node
//button/@* every attribute on every button
//div/node() every child node of every div, including text and comments
* matches elements only. node() matches any node type. The difference matters when you count children or walk siblings, because whitespace between tags becomes text nodes.
Element names are case-sensitive
In an HTML document the parser lowercases element names, so //DIV, //Div and //div are three different names to XPath and only the lowercase one matches:
//DIV 0 matches in an HTML page
//div matches
Attribute names are lowercased by the HTML parser too. Attribute values are left as written, so @type='Submit' will not match type="submit".
XML documents (and XHTML served as XML) are different: element names keep their case exactly as written.
text() is a node, not a string
text() selects the text node children of the context element. That has two consequences:
- It does not include text inside child elements.
- An element can have several text nodes when child elements are mixed in.
<button>Save <span>changes</span> now</button>
//button/text() two text nodes: "Save " and " now"
//button[text()='Save'] no match; the first text node is "Save " with a trailing space
//button[contains(text(), 'Save')] match; only the first text node is tested
//button[normalize-space()='Save changes now'] match; whole string value, trimmed
The Text Matching Functions lesson covers the practical patterns. The point here is that text() and “the text you see on screen” are not the same thing.
Attributes are not children
Attributes hang off an element but are not children of it. //input/* does not return attributes, and you cannot walk from an attribute to a sibling attribute. You always reach them with @ from their owner element:
//input[@name='email']/@placeholder the placeholder attribute node
string(//input[@name='email']/@placeholder) its value as a string
The root and the document element
/ is the root node, which sits above <html>. /html is the document element. That is why absolute paths start /html/body/... and why /body matches nothing.
/ root node
/html document element
/* same thing: the single element child of the root
//html also works, since // starts from the root
Comments and hidden content
comment() rarely matters for tests, but it is handy when scraping pages that hide data in comments. More relevant: XPath has no idea about CSS visibility. //button matches hidden buttons too. Your test framework decides what “visible” means, not XPath.
Try It Yourself
Open in Playground →
Compare the results of //div/node() and //div/* on the same sample and look at the node types in the results panel.
Next Steps
- Operators and Comparisons -
=,!=,|, arithmetic and what they do with node-sets - Text Matching Functions - Turning text nodes into reliable matches
- SVG, Namespaces and local-name() - Why
//svgfails and how to fix it