XML Fields
XML fields implement MatcherField<Node, R> and extract values from a W3C DOM Node via XPath. No external XML library is required — extraction uses the JDK's javax.xml.xpath API.
- Kotest
- Hamkrest
All XML field classes are open and available for subclassing. The only difference between the Kotest and Hamkrest variants is the package of the base MatcherField interface they import.
Two construction styles
Every XML field class accepts either a path string or a pre-compiled XPathExpression. Pick whichever fits the case:
| Path-string constructor | XPathExpression constructor | |
|---|---|---|
| Use when | Plain XPath, no namespaces | Namespaces, fluent XPath builders, dynamic / lazy compilation |
| Compilation | Internal, thread-safe, lazy on first extract | Caller-managed |
path property | Always non-null | Non-null only when wrapped in XPathExpressionWrapper |
Default name | The path string | Class simple name |
The path-string form is the simpler default. Reach for the XPathExpression form only when you need to inject a NamespaceContext or compose paths via a builder DSL.
Field classes
XmlField<T>
The base class. Evaluates an XPath expression against a Node and applies transform to the matched node.
// Path-string form (recommended for plain XPath):
val aProvider = XmlField<Provider>("/order/@provider") {
Provider.fromString(it.nodeValue)
}
// Compiled-expression form (use for namespaces / builders):
val aProvider = XmlField(
compile("/order/@provider"),
"Provider"
) { node -> Provider.fromString(node.nodeValue) }
Constructors:
| Signature | Notes |
|---|---|
XmlField(path: String, transform: (Node) -> T?) | Compiles internally; name defaults to path |
XmlField(path: String, name: String, transform: (Node) -> T?) | Compiles internally; explicit name |
XmlField(expression: XPathExpression, transform: (Node) -> T?) | Name derived from class simple name |
XmlField(expression: XPathExpression, name: String, transform: (Node) -> T?) | Explicit name |
XmlField(expressionProvider: () -> XPathExpression, transform: (Node) -> T?) | Lazy expression |
XmlField(expressionProvider: () -> XPathExpression, name: String?, transform: (Node) -> T?) | Lazy + explicit name |
The path property returns the XPath string when the expression is an XPathExpressionWrapper; null otherwise. Path-string constructors always wrap, so path is non-null in that case.
XmlTextField<T>
Extracts textContent from the matched node and applies transform. Prefer XmlStringField when the identity transform is sufficient.
val anAmount = XmlTextField(compile("/order/amount"), "Amount") { it.toBigDecimal() }
XmlStringField
Returns the matched node's textContent verbatim. Use this when you don't need a transform — it's the closest XML analogue to JSON's JsonTextField.
val status = XmlStringField("/FeasibilityResponse/Status")
val customerName = XmlStringField("/order/customer/name", "Customer Name")
Also accepts a pre-compiled XPathExpression or a lazy () -> XPathExpression for namespace / builder cases.
XmlNodeField
Returns the matched Node itself. Use when downstream code needs the raw DOM node.
XmlListField<T>
Extracts an ordered List<T> by evaluating the XPath as a node-set and mapping each node through transform.
val profileTypes = XmlListField(
compile("/FeasibilityResponse/Profiles/Profile/Type"),
"Profile Types"
) { it.textContent }
- Kotest
- Hamkrest
Use with withListOf for exact ordered assertions, or matching(containExactlyInAnyOrder(...)) for order-insensitive checks.
Use with withListOf for exact ordered assertions. withListOf uses equalTo(list) — elements must be in the same order.
XmlSetField<T>
Like XmlListField<T> but deduplicates results into a Set<T>. Use with withSetOf — set equality is order-insensitive.
XPathExpressionWrapper
class XPathExpressionWrapper(
private val xPathExpression: XPathExpression,
val path: String
) : XPathExpression
Wraps a compiled XPathExpression and retains the original path string. Wrapping ensures that:
- The
pathproperty onXmlFieldreturns the XPath string (useful for tooling and report rendering). - The path is readable when debugging test failures.
The path-string constructors apply this wrapper automatically. Use compileXPath directly when you want to compile once and reuse the result:
val orderId = compileXPath("/order/@id") // returns an XPathExpressionWrapper
val a = XmlField<String>(orderId) { it.nodeValue }
val b = XmlField<Int>(orderId, "Numeric Id") { it.nodeValue.toInt() }
For namespace-aware compilation, build your own XPathExpression with a NamespaceContext and pass it directly to the field's constructor (then wrap it yourself if you want the path property populated).
Node extension helpers
Defined in XmlExtensions.kt. Used internally by the field classes and available for direct use.
| Function | Description |
|---|---|
Node.getNodeOrNull(path: XPathExpression): Node? | Returns the single matched node, or null. |
Node.getNodes(path: XPathExpression): List<Node> | Returns all matched nodes in document order. |
Worked example
Declare fields in a Kotlin object:
object FibreVisionResponseFields {
val status = XmlStringField("/FeasibilityResponse/Status", "Status")
val firstProfileType = XmlStringField("/FeasibilityResponse/Profiles/Profile[1]/Type", "Profile Type")
val firstProfileDownloadSpeed = XmlField<Int>("/FeasibilityResponse/Profiles/Profile[1]/DownloadSpeed", "Download Speed") {
it.textContent.toInt()
}
val profileTypes = XmlListField<String>("/FeasibilityResponse/Profiles/Profile/Type", "Profile Types") {
it.textContent
}
}
Parse the response document and compose the matcher in the test:
- Kotest
- Hamkrest
import dev.kensa.kotest.testsupport.field.of
import dev.kensa.kotest.testsupport.field.withListOf
import io.kotest.matchers.containExactlyInAnyOrder
@Test
fun `FibreVision XML response — asserted via XmlField DSL`() {
given(bothSuppliersAreServiceable())
whenever(aFeasibilityCheckIsRequestedForTheServiceAddress())
then(theFibreVisionResponseDocument(),
(status of "SERVICEABLE")
.and(firstProfileType of "FTTC")
.and(firstProfileDownloadSpeed of fixtures[broadbandDownloadSpeed])
.and(profileTypes matching containExactlyInAnyOrder("FTTC"))
)
}
private fun theFibreVisionResponseDocument() = StateCollector<Document> {
val xml = fibreVisionStub.feasibilityResponseFor(fixtures[trackingId])
?: error("no response captured")
DocumentBuilderFactory.newInstance().newDocumentBuilder()
.parse(ByteArrayInputStream(xml.toByteArray()))
}
import com.natpryce.hamkrest.and
import com.natpryce.hamkrest.assertion.assertThat
import dev.kensa.hamkrest.testsupport.field.of
import dev.kensa.hamkrest.testsupport.field.withListOf
import dev.kensa.hamkrest.testsupport.field.xml.XmlField
import dev.kensa.hamkrest.testsupport.field.xml.XmlListField
import dev.kensa.hamkrest.testsupport.field.xml.XmlStringField
import dev.kensa.hamkrest.testsupport.field.xml.XPathExpressionWrapper
@Test
fun `FibreVision XML response — asserted via XmlField DSL`() {
given(bothSuppliersAreServiceable())
whenever(aFeasibilityCheckIsRequestedForTheServiceAddress())
then(theFibreVisionResponseDocument(),
(status of "SERVICEABLE")
and (firstProfileType of "FTTC")
and (firstProfileDownloadSpeed of 80)
and profileTypes.withListOf("FTTC")
)
}
For a complete working example see FibreVisionResponseFields.kt and FieldDslExamplesTest.kt in clearwave-example.