Deep Assertions
Occasionally unit testing is more complex than with simple types, e.g with
- results from webservice interfaces (REST/SOAP)
- orm-mapped database results
- parse trees
- graphs or trees in general
Such types are deeply (or recusively) nested and thus not easily validated. It is easier when using the right tool - e.g. with the IsEquivalent-Matcher of XrayInterface …
An example
I want to start with a simple object with recursive type:
class TreeNode {
private String id;
private TreeNode parent;
private List<TreeNode> children;
public TreeNode(String id, TreeNode parent) {
this.id = id;
this.parent = parent;
this.children = new ArrayList<>();
}
public void addChild(TreeNode child) {
children.add(child);
}
public String getId() {
return id;
}
public TreeNode getParent() {
return parent;
}
public List<TreeNode> getChildren() {
return children;
}
}
We create a small tree:
TreeNode root = new TreeNode("root", null);
TreeNode a = new TreeNode("a", root);
root.addChild(a);
TreeNode b = new TreeNode("b", a);
a.addChild(b);
TreeNode c = new TreeNode("c", a);
a.addChild(c);
TreeNode d = new TreeNode("d", root);
root.addChild(d);
The task of the test is to verify the structure of the given tree. A naive approach with Hamcrest could be the following:
assertThat(root.getId(), equalTo("root"));
assertThat(root.getChildren()
.get(0).getId(), equalTo("a"));
assertThat(root.getChildren()
.get(0).getChildren()
.get(0).getId(), equalTo("b"));
assertThat(root.getChildren()
.get(0).getChildren()
.get(1).getId(), equalTo("c"));
assertThat(root.getChildren()
.get(1).getId(), equalTo("d"));
Not quite optimal:
- the readability is negatively influenced by the navigating methods (
getXXX().getXXX()...
) that continuously change the context - this code is not resilient at all. It depends implicitly on a stable sequence of the child nodes, which should be an implementation detail.
Agile developers might notice that there is a lot of duplicated code. Next try:
assertThat(root.getId(), equalTo("root"));
List<TreeNode> rootChildren = root.getChildren();
TreeNode nodea = rootChildren.get(0);
assertThat(nodea.getId(), equalTo("a"));
List<TreeNode> aChildren = nodea.getChildren();
TreeNode nodeB = aChildren
.get(0);
assertThat(nodeB.getId(), equalTo("b"));
TreeNode nodeC = aChildren
.get(1);
assertThat(nodeC.getId(), equalTo("c"));
TreeNode nodeD = rootChildren
.get(1);
assertThat(nodeD.getId(), equalTo("d"));
Not better - from my point of view. If an assertions fails in this code, we would want to know the location of the failing node:
- easy to resolve by parsing the method navigation path (each method represents a node or edge)
- not just as easy to track variables over the lines up to the root node
How can we do better?
An example - more realistic
Regarding the example above might point the reader to the fact that nobody should test a full tree that was created in a test method. Ideal unit tests would test the tree node in an isolated manner.
Yet trees can be the result of methods that contain business logic and return a TreeNode
, e.g.
TreeNode createTreeFromXY() {
...
}
The test would look like:
TreeNode root = createTreeFromXY();
assertThat(root.getId(), equalTo("root"));
assertThat(root.getChildren()
.get(0).getId(), equalTo("a"));
assertThat(root.getChildren()
.get(0).getChildren()
.get(0).getId(), equalTo("b"));
assertThat(root.getChildren()
.get(0).getChildren()
.get(1).getId(), equalTo("c"));
assertThat(root.getChildren()
.get(1).getId(), equalTo("d"));
And of course there is an even more naive solution:
TreeNode expected = new TreeNode("root", null);
... // build an equivalent tree
assertThat(root, equalTo(expected));
For this test will need to implement the equals
method - sounds simple but is not so easy at all:
public boolean equals(Object obj) {
TreeNode that = (TreeNode) obj;
return this.id.equals(that.id)
&& this.parent.equals(that.parent) //fails with NullPointerException
&& this.children.equals(that.children);
}
public boolean equals(Object obj) {
TreeNode that = (TreeNode) obj;
return this.id.equals(that.id)
&& Object.equals(this.parent, that.parent) //fails with StackOverflowException
&& this.children.equals(that.children);
}
public boolean equals(Object obj) {
TreeNode that = (TreeNode) obj;
return this.id.equals(that.id)
&& this.children.equals(that.children); //skips equality of parent
}
Besides we should realize that the equals
method is usually not an auxiliary method for unit testing, but a part of the production code, sometimes implemented in another way as we need it for the test.
This means - the more realistic example is not easily solved …
What are the requirements of an Assert on recursive Objects
Before discussing the solution approach, I want to clarify which properties of the upper examples are negatively influencing readability and maintainability:
- code duplication
- stepwise extraction of results
- implicit assumptions about sequence of collections
- dependencies to equals methods
More resilient Asserts with complex Hamcrest matchers
Cleaner would be a solution with Hamcrest matchers:
assertThat(root, treeNodeMatchingId("root")
.withChildrenContaining(
treeNodeMatchingId("a")
.withChildrenContaining(
treeNodeMatchingId("b"),
treeNodeMatchingId("c")
),
treeNodeMatchingId("d")
)
);
The Hamcrest matcher beneath treeNodeMatchingId
must be implemented on ones own. From my point of view this work is a valid tradeoff, yet there is an even simpler way.
Resilient Asserts with dynamic IsEquivalent HamcrestMatcher
First we generify the code above as follows:
assertThat(root, treeNode()
.withId("root")
.withChildren(contains(
treeNode()
.withId("a")
.withChildren(contains(
treeNode()
.withId("b"),
treeNode()
.withId("c")
)),
treeNode()
.withId("d")
))
);
Frankly - this code is not as easy to write and a bit harder to read. But it is easy to make this code run via XRayInterface. For this we define an interface:
interface TreeNodeMatcher extends Matcher<TreeNode> {
public TreeNodeMatcher withId(String id);
public TreeNodeMatcher withChildren(Matcher<Iterable<? extends TreeNode>> c);
}
and a factory method treeNode
:
TreeNodeMatcher treeNode() {
return IsEquivalent.equivalentTo(TreeNodeMatcher.class);
}
Such IsEquivalent
matchers are not only able to validate equality of values (as in withId
), but also can be combined with arbitrary matchers (as in withChildren
). In the latter case the matcher will even return a comprehensible error message. E.g. a check with following assert:
assertThat(root, treeNode()
.withId("root")
.withChildren(contains(
treeNode()
.withId("a")
.withChildren(contains(
treeNode()
.withId("notb"),
treeNode()
.withId("c")
)),
treeNode()
.withId("d")
))
);
would emit following error message:
**Expected:** with properties <Id=root>, <Children=iterable containing [with properties <Id=a>, <Children=iterable containing [with properties <Id=notb>, with properties <Id=c>]>, with properties <Id=d>]> **but:** with properties <Id=root>, <Children=item 0: with properties <Id=a>, <Children=item 0: with properties <Id=b>>>`
Decorating IsEquivalent Hamcrest matchers
The current example is yet overly generic and verbose, so it might be suitable to write some missing methods as static factory methods and default builder methods, e.g.
interface TreeNodeMatcher extends org.hamcrest.Matcher<TreeNode> {
public TreeNodeMatcher withId(String id);
public TreeNodeMatcher withChildren(Matcher<Iterable<? extends TreeNode>> c);
public default TreeNodeMatcher withChildrenContaining(TreeNodeMatcher... c) {
return withChildren(contains(c));
}
public static TreeNodeMatcher treeNodeWithId(String id) {
return IsEquivalent.equivalentTo(TreeNodeMatcher.class).withId(id);
}
}
This given, we can write our test again in the very compact way known from above:
assertThat(root, treeNodeMatchingId("root")
.withChildrenContaining(
treeNodeMatchingId("a")
.withChildrenContaining(
treeNodeMatchingId("b"),
treeNodeMatchingId("c")
),
treeNodeMatchingId("d")
)
);
Summary
With XRayInterface IsEquivalent matchers we can create powerful and flexible matchers with small coding effort, having following properties
- we need no duplication of navigation paths
- we can combine them with arbitrary Hamcrest matchers
- we do not depend on correctly implemented equals methods