XMLUnit is a Java library that compares two XML documents node by node and reports every difference, so tests can check XML output without fragile string comparison. Version 2.x has a core module with the DiffBuilder API, plus modules for Hamcrest matchers and AssertJ assertions.
We use XMLUnit to test code that writes XML, such as SOAP clients, export jobs, JAXB mappings and XSLT transformations. It tells us whether two documents are identical or only similar, and it can ignore whitespace, comments, element order or nodes that change on every run.
The following example compares a generated recipe with the expected one.
String expected = "<recipe><name>Pancakes</name><serves>4</serves></recipe>";
String actual = "<recipe>\n <name>Pancakes</name>\n <serves>4</serves>\n</recipe>";
Diff exact = DiffBuilder.compare(expected).withTest(actual).build();
boolean differs = exact.hasDifferences(); // true, whitespace text nodes
Diff relaxed = DiffBuilder.compare(expected).withTest(actual).ignoreWhitespace().build();
boolean relaxedDiffers = relaxed.hasDifferences(); // false
Notice that a pretty-printed document already differs from the compact one, and ignoreWhitespace() fixes that. In the next sections, we set up the library, compare documents in different ways, filter and evaluate differences, and test a real export.
1. XMLUnit Maven Dependency
The module xmlunit-core contains DiffBuilder, Input and the XPath and validation helpers, and it is all we need for plain JUnit assertions. The xmlunit-matchers module adds Hamcrest matchers on top of it. The examples use XMLUnit 2.14.0 with Hamcrest 3.0 on Java 25.
<dependency>
<groupId>org.xmlunit</groupId>
<artifactId>xmlunit-core</artifactId>
<version>2.14.0</version>
<scope>test</scope>
</dependency>
<!-- only for the Hamcrest matchers -->
<dependency>
<groupId>org.xmlunit</groupId>
<artifactId>xmlunit-matchers</artifactId>
<version>2.14.0</version>
<scope>test</scope>
</dependency>
For AssertJ, the module is xmlunit-assertj3 instead. The old XMLUnit 1.x API with XMLTestCase and XMLAssert lives on in xmlunit-legacy, but new tests use the 2.x API from this article.
2. Reading and Comparing XML Sources
XMLUnit reads both documents through its Input class, so the expected and the actual XML can come from different kinds of sources. The comparison itself does not care where the XML came from.
2.1. Input Types That XMLUnit Accepts
The methods DiffBuilder.compare() and withTest() accept an Object and convert it with Input.from(). That method accepts almost any type that can hold XML.
- A String with XML text, or a byte[].
- A File, a Path, an InputStream, a Reader or a URL.
- A DOM Document or Node, or a javax.xml.transform.Source.
- A JAXB object, when a JAXB implementation is on the classpath.
- An Input.Builder, which we use to transform a source first, for example with XSLT.
String xml = "<recipe><name>Pancakes</name></recipe>";
Path file = Files.writeString(Files.createTempFile("recipe", ".xml"), xml);
Document dom = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(file.toFile());
boolean fromFile = DiffBuilder.compare(xml).withTest(file.toFile()).build().hasDifferences(); // false
boolean fromPath = DiffBuilder.compare(xml).withTest(Input.fromPath(file)).build().hasDifferences(); // false
boolean fromDom = DiffBuilder.compare(xml).withTest(dom).build().hasDifferences(); // false
boolean fromBytes = DiffBuilder.compare(xml).withTest(xml.getBytes(StandardCharsets.UTF_8)).build().hasDifferences(); // false
In tests, the expected file sits in src/test/resources. We read it with Input.fromStream(getClass().getResourceAsStream(“/expected-recipe.xml”)), which works the same way when the tests run from a JAR. The DOM example uses the parser from reading XML with the DOM parser.
2.2. Comparing XML Sources
A comparison has two sides with fixed names. The control is the expected document that we pass to compare(), and the test is the actual document that we pass to withTest(). XMLUnit uses these names in every difference message.
We get the result in one of two ways. DiffBuilder builds a Diff object that we can query and print, whereas the Hamcrest matchers from CompareMatcher fit into an assertThat() call.
String control = "<recipe><name>Pancakes</name></recipe>";
String test = "<recipe><name>Waffles</name></recipe>";
Diff diff = DiffBuilder.compare(control).withTest(test).build();
boolean different = diff.hasDifferences(); // true
String first = diff.toString(); // Expected text value 'Pancakes' but was 'Waffles' - comparing ...
assertThat(test, not(isIdenticalTo(control))); // passes
assertThat(control, isIdenticalTo("<recipe><name>Pancakes</name></recipe>")); // passes
3. Comparison Examples Using XMLUnit
XMLUnit walks both documents at the same time and creates a comparison for each pair of nodes. Every comparison has an outcome, which is EQUAL, SIMILAR or DIFFERENT, and the outcomes decide whether the documents count as identical or similar.

3.1. Comparing Identical XMLs
Two documents are identical when every comparison is EQUAL, which means the same elements in the same order, same attributes and same text. By default, DiffBuilder checks for identity, and checkForIdentical() states the same thing explicitly.
String control = "<recipe><name>Pancakes</name><serves>4</serves></recipe>";
String same = "<recipe><name>Pancakes</name><serves>4</serves></recipe>";
boolean identical = !DiffBuilder.compare(control).withTest(same).checkForIdentical().build().hasDifferences(); // true
assertThat(same, isIdenticalTo(control)); // passes
3.2. Comparing Similar XMLs
Some differences do not change what the document means. XMLUnit rates them as SIMILAR, for example a CDATA section instead of plain text, a different namespace prefix for the same namespace URI, or an attribute that only the schema defaults. With checkForSimilar() or isSimilarTo(), these differences no longer fail the comparison.
String control = "<recipe><name>Pancakes</name></recipe>";
String cdata = "<recipe><name><![CDATA[Pancakes]]></name></recipe>";
boolean identicalCdata = !DiffBuilder.compare(control).withTest(cdata).build().hasDifferences(); // false
boolean similarCdata = !DiffBuilder.compare(control).withTest(cdata).checkForSimilar().build().hasDifferences(); // true
assertThat(cdata, isSimilarTo(control)); // passes
A different order of child elements is not similar by default, because the default node matcher pairs children by their position. The node matcher DefaultNodeMatcher(ElementSelectors.byName) pairs elements that have the same name, wherever they are among their siblings. The pairs that are out of order produce only a CHILD_NODELIST_SEQUENCE difference, which is rated similar.
String control = "<recipe><name>Pancakes</name><serves>4</serves></recipe>";
String reordered = "<recipe><serves>4</serves><name>Pancakes</name></recipe>";
NodeMatcher matchByName = new DefaultNodeMatcher(ElementSelectors.byName);
boolean similarByPosition = !DiffBuilder.compare(control).withTest(reordered).checkForSimilar().build().hasDifferences(); // false
boolean similarByName = !DiffBuilder.compare(control).withTest(reordered).checkForSimilar().withNodeMatcher(matchByName).build().hasDifferences(); // true
assertThat(reordered, isSimilarTo(control).withNodeMatcher(new DefaultNodeMatcher(ElementSelectors.byName))); // passes
For lists of elements with the same name, such as several ingredient elements, byName is not enough, because it pairs the first ingredient with the first one. In that case, ElementSelectors.byNameAndText pairs elements by name and text, and byNameAndAllAttributes pairs them by name and attribute values.
String control = "<ingredients><ingredient>milk</ingredient><ingredient>eggs</ingredient></ingredients>";
String shuffled = "<ingredients><ingredient>eggs</ingredient><ingredient>milk</ingredient></ingredients>";
NodeMatcher nameOnly = new DefaultNodeMatcher(ElementSelectors.byName);
NodeMatcher nameAndText = new DefaultNodeMatcher(ElementSelectors.byNameAndText);
boolean byName = !DiffBuilder.compare(control).withTest(shuffled).checkForSimilar().withNodeMatcher(nameOnly).build().hasDifferences(); // false
boolean byNameAndText = !DiffBuilder.compare(control).withTest(shuffled).checkForSimilar().withNodeMatcher(nameAndText).build().hasDifferences(); // true
3.3. Comparing or Ignoring Specific Tags
The method withNodeFilter() takes a predicate that XMLUnit calls for every node on both sides. Nodes for which it returns false are left out of the comparison, including their children. The method withAttributeFilter() does the same for attributes.
The filter must keep the root element and every parent of the nodes we want to compare. A filter that accepts only serves also rejects recipe, so XMLUnit compares nothing and every pair of documents passes. We exclude the unwanted nodes instead.
String control = "<recipe updated=\"09:00\"><name>Pancakes</name><note>draft</note></recipe>";
String test = "<recipe updated=\"10:15\"><name>Pancakes</name><note>final</note></recipe>";
Diff filtered = DiffBuilder.compare(control).withTest(test)
.withNodeFilter(node -> !"note".equals(node.getNodeName()))
.withAttributeFilter(attr -> !"updated".equals(attr.getName()))
.build();
boolean filteredDiffers = filtered.hasDifferences(); // false
3.4. Comparing Using XPaths
Comparing the whole document is too strict when a test cares about one or two values. The Hamcrest matchers HasXPathMatcher and EvaluateXPathMatcher check that a node exists or that an XPath expression evaluates to an expected value.
String recipe = "<recipe><name>Pancakes</name><serves>4</serves></recipe>";
assertThat(recipe, HasXPathMatcher.hasXPath("/recipe/serves")); // passes
assertThat(recipe, EvaluateXPathMatcher.hasXPath("/recipe/serves/text()", equalTo("4"))); // passes
assertThat(recipe, EvaluateXPathMatcher.hasXPath("count(/recipe/*)", equalTo("2"))); // passes
Without Hamcrest, the class JAXPXPathEngine from xmlunit-core evaluates an expression on any Input source.
String recipe = "<recipe><name>Pancakes</name><serves>4</serves></recipe>";
String serves = new JAXPXPathEngine().evaluate("/recipe/serves", Input.fromString(recipe).build()); // "4"
4. Normalizing XMLs
XML from real systems is rarely in the same format as the expected file. Indentation, comments and line breaks inside text are the usual noise, and XMLUnit removes it in two places, either on the source before the comparison or as an option of the comparison.
4.1. Using Comparison Options
The methods of DiffBuilder and CompareMatcher have the same names, so a rule learned for one works with the other.
| Method | Effect |
|---|---|
| ignoreWhitespace() | Removes whitespace-only text nodes and trims the remaining text nodes |
| normalizeWhitespace() | Also replaces each run of whitespace inside text with a single space |
| ignoreElementContentWhitespace() | Removes only whitespace-only text nodes between elements and keeps the text as it is |
| ignoreComments() | Removes comments from both documents |
String control = "<recipe><step>Mix flour and milk</step></recipe>";
String messy = """
<recipe>
<!-- from the editor -->
<step>Mix flour
and milk</step>
</recipe>
""";
boolean trimmedOnly = DiffBuilder.compare(control).withTest(messy).ignoreComments().ignoreWhitespace().build().hasDifferences(); // true
boolean normalized = DiffBuilder.compare(control).withTest(messy).ignoreComments().normalizeWhitespace().build().hasDifferences(); // false
assertThat(messy, isIdenticalTo(control).ignoreComments().normalizeWhitespace()); // passes
We can see that ignoreWhitespace() trims the step but keeps the inner line break, so only normalizeWhitespace() makes the step text equal.
4.2. Using Source Decorators
The options above are built on source decorators from the package org.xmlunit.input. We use these classes on their own when we want to clean a document once and reuse it, for example to write it back to a file or to run XPath on it.
- CommentLessSource removes all comments.
- WhitespaceStrippedSource removes whitespace-only text nodes and trims all other text nodes.
- WhitespaceNormalizedSource works like WhitespaceStrippedSource and also collapses inner whitespace to single spaces.
- ElementContentWhitespaceStrippedSource removes whitespace-only text nodes between elements.
- NormalizedSource merges adjacent text nodes and removes empty ones.
Source raw = Input.fromString("<recipe>\n <!-- draft -->\n <name> Pancakes </name>\n</recipe>").build();
Source clean = new WhitespaceStrippedSource(new CommentLessSource(raw));
boolean cleanDiffers = DiffBuilder.compare("<recipe><name>Pancakes</name></recipe>").withTest(clean).build().hasDifferences(); // false
5. Listing and Evaluating Differences
A failing test needs a message that shows all differences, not only the first one. The method getDifferences() returns every Difference with its type and the XPath of both nodes, so we can print them or put them into the assertion message.
String control = "<recipe><name>Pancakes</name><serves>4</serves></recipe>";
String test = "<recipe><name>Waffles</name><serves>2</serves></recipe>";
Diff diff = DiffBuilder.compare(control).withTest(test).build();
List<String> types = new ArrayList<>();
diff.getDifferences().forEach(d -> types.add(d.getComparison().getType() + " at " + d.getComparison().getTestDetails().getXPath()));
List<String> found = List.copyOf(types); // [TEXT_VALUE at /recipe[1]/name[1]/text()[1], TEXT_VALUE at /recipe[1]/serves[1]/text()[1]]
Diff firstOnly = DiffBuilder.compare(control).withTest(test).withComparisonController(ComparisonControllers.StopWhenDifferent).build();
long stoppedCount = StreamSupport.stream(firstOnly.getDifferences().spliterator(), false).count(); // 1
ComparisonControllers.StopWhenDifferent stops at the first real difference, which saves time for large documents when the test only needs a yes or no.
A DifferenceEvaluator goes one step further and changes the outcome of a comparison. The following evaluator treats any text inside createdAt as equal, while the default evaluator still rates all other comparisons.
static DifferenceEvaluator ignoreCreatedAt() {
return (comparison, outcome) -> {
Node target = comparison.getControlDetails().getTarget();
if (outcome == ComparisonResult.DIFFERENT && target != null
&& target.getParentNode() != null && "createdAt".equals(target.getParentNode().getNodeName())) {
return ComparisonResult.EQUAL;
}
return outcome;
};
}
String control = "<recipe><name>Pancakes</name><createdAt>2026-10-01T09:00</createdAt></recipe>";
String test = "<recipe><name>Pancakes</name><createdAt>2026-10-10T18:42</createdAt></recipe>";
Diff evaluated = DiffBuilder.compare(control).withTest(test)
.withDifferenceEvaluator(DifferenceEvaluators.chain(DifferenceEvaluators.Default, ignoreCreatedAt()))
.build();
boolean evaluatedDiffers = evaluated.hasDifferences(); // false
A node filter would remove createdAt completely, so a missing createdAt element would pass too. The evaluator keeps the check that the element exists and only ignores its value.
6. Comparing a Recipe Export in a Test
A recipe app exports each recipe as XML for a printing service. The export contains a creation timestamp and is pretty-printed, and the order of the tag elements depends on a HashSet. A test that compares strings fails on every run, although the export is correct.
The following test compares the export with the expected document and handles the three sources of noise, namely formatting, the timestamp and the tag order. A conditional element selector pairs tag elements by their text and all other elements by name, so the createdAt elements are still paired and reach our evaluator.
String expected = """
<recipe>
<name>Pancakes</name>
<createdAt>any</createdAt>
<tags><tag>breakfast</tag><tag>sweet</tag></tags>
</recipe>
""";
String exported = "<recipe><name>Pancakes</name><createdAt>2026-10-10T18:42</createdAt><tags><tag>sweet</tag><tag>breakfast</tag></tags></recipe>";
ElementSelector tagsByText = ElementSelectors.conditionalBuilder()
.whenElementIsNamed("tag").thenUse(ElementSelectors.byNameAndText)
.elseUse(ElementSelectors.byName)
.build();
Diff exportDiff = DiffBuilder.compare(expected).withTest(exported)
.ignoreWhitespace()
.checkForSimilar()
.withNodeMatcher(new DefaultNodeMatcher(tagsByText))
.withDifferenceEvaluator(DifferenceEvaluators.chain(DifferenceEvaluators.Default, ignoreCreatedAt()))
.build();
boolean exportDiffers = exportDiff.hasDifferences(); // false
In a JUnit test, we finish with assertFalse(exportDiff.hasDifferences(), exportDiff.toString()), so a failure message shows the first difference with its XPath. The same rules written with JUnit 5 assertions or with XmlAssert give the same result, because all of them use the same comparison engine.
7. XMLUnit vs Other Ways to Compare XML
Plain Java can compare XML too, but each shortcut breaks on a different kind of noise, and none of them reports where the documents differ.
| Approach | Whitespace and comments | Element order | Shows all differences |
|---|---|---|---|
| String.equals() | No | No | No |
| DOM Node.isEqualNode() after normalize() | No, text nodes must match | No | No, only true or false |
| XMLUnit DiffBuilder | Yes, with options | Yes, with node matchers | Yes, with XPath of each node |
| XMLUnit with XmlAssert or Hamcrest | Yes | Yes | Yes, in the assertion message |
8. XMLUnit FAQs
Questions about XMLUnit mostly come from comparisons that fail for reasons a human reader would ignore.
8.1. How do we compare two XML files in Java?
Pass both files to DiffBuilder, for example DiffBuilder.compare(new File(“expected.xml”)).withTest(new File(“actual.xml”)).ignoreWhitespace().build(), and check hasDifferences(). Add checkForSimilar() and a node matcher when the element order may differ.
8.2. How do we ignore the element order in XMLUnit?
Set a node matcher such as new DefaultNodeMatcher(ElementSelectors.byName) and call checkForSimilar(). For repeated elements with the same name, use byNameAndText, byNameAndAllAttributes or a conditional selector built with ElementSelectors.conditionalBuilder().
8.3. What is the difference between identical and similar in XMLUnit?
Identical documents have no difference at all. Similar documents differ only in ways that keep the meaning, such as CDATA instead of text, a different namespace prefix or a different order of matched children.
8.4. How do we ignore an attribute or an element?
Use withAttributeFilter() or withNodeFilter() with a predicate that returns false for the node to skip, as in section 3.3. To keep the node but ignore its value, use a DifferenceEvaluator.
8.5. Is XMLUnit 1.x still supported?
The 1.x API is available in the xmlunit-legacy module, which runs on top of XMLUnit 2. The project recommends the 2.x API for new code, and the 1.x classes such as XMLTestCase are tied to JUnit 3.
9. Conclusion
XMLUnit 2 compares XML node by node and rates every difference as equal, similar or different. We build comparisons with DiffBuilder, or with the Hamcrest and AssertJ wrappers, and decide how strict the test is with checkForSimilar(), node matchers and whitespace options.
Filters remove nodes from the comparison, whereas a DifferenceEvaluator keeps the node and changes how its difference counts. With those two tools, a test can compare a full export and still pass on every run.
10. References
- XMLUnit User Guide
- XMLUnit DifferenceEvaluator
- XMLUnit Selecting Nodes
- XMLUnit Release Notes
- XMLUnit Javadoc
Happy Learning !!