zur Übersicht
30. Nov. 2019

Tiefe Assertions

Gelegentlich hat man es beim Unit-Testen nicht mit einfachen Typen zu tun, sondern z.B. mit

  • Ergebnisse von Webservice-Schnittstellen (REST/SOAP)
  • ORM-gemappte Datenbank-Ergebnisse
  • generierte Parse-Bäumen
  • jegliche Art Graph oder Baum

Solche Typen sind oft tief (oder rekursiv) geschachtelt und deswegen nich ganz so einfach zu testen. Mit dem richtigen Werkzeug geht es aber - z.B. mit dem IsEquivalent-Matcher von XrayInterface


Ein Beispiel

Ich möchte beginnen mit einem vergleichsweise einfachen Objekt mit rekursivem Typ:

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;
  }
}

Nun erzeugen wir einen kleinen Baum:

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);

Die Aufgabe des Tests ist es jetzt diese Struktur vollständig zu verifizieren. Der naive Ansatz (in Hamcrest) wäre folgender:

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"));

Nicht ganz optimal:

  • Die Lesbarkeit leidet unter den Navigations-Methoden (getXXX().getXXX()...) die ständig den Kontext verändern
  • Der Code ist nicht sonderlich robust. Er geht nämlich implizit davon aus, dass die Reihenfolge bei den Kindknoten stabil bleibt.

Agilen Entwicklern wird vermutlich die Code-Duplikation negativ auffallen. Nächster Versuch:

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"));

Aus meiner Sicht sogar schlimmer. Wenn eine Assertion fehlschlägt, will man gerne wissen wo der Baum jetzt von den Erwartungen abweicht:

  • bei expliziten Navigationspfaden wie oben recht einfach (jede Methode steht für einen Knoten/eine Kante)
  • beim duplikationsbereinigten Navigationspfad deutlich schwieriger weil der Pfad zum fehlschlagenden Assert schrittweise zurückverfolgt werden muss.

Irgendwie muss das besser gehen …

Das Beispiel - etwas realistischer

Wenn wir uns das Beispiel oben anschauen, ist es ohnehin nicht sonderlich realistisch. Ein idealer Unit-Test würde nur die Methoden des Knotens testen und nicht die Struktur eines ganzen Baums.

Anders sieht das ganze aus, wenn wir eine Methode testen möchten, die einen TreeNode zurück gibt, z.B.

TreeNode createTreeFromXY() {
  ...
}

Dann würde der Test wie folgt aussehen:

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"));

Und natürlich gibt es dafür auch eine noch naivere Lösung:

TreeNode expected = new TreeNode("root", null);

... // build an equivalent tree
        
assertThat(root, equalTo(expected));

Man muss lediglich die equals-Methode implementieren - und das ist gar nicht so einfach:

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
}

Außerdem sollten wir immer bedenken, dass die equals-Methode eigentlich nicht eine Hilfsmethode für Unit-Tests ist, sondern oft im Produktivcode verwendet wird, manchmal aber bewusst anders als man es im Test gerne hätte.

D.h. auch im realistischeren Beispiel gibt es keine einfache Lösung …

Was muss ein Assert für rekursive Objekte erfüllen

Bevor wir den Lösungsansatz betrachten möchte ich noch einmal formulieren was die Lesbarkeit/Wartbarkeit von Asserts stört:

  • Duplikation von Code
  • Schrittweise Extraktion von Ergebnissen
  • Implizite Annahmen von Reihenfolgen
  • Abhängigkeit von Equals-Methoden

Robustere Asserts mit komplexen Hamcrest-Matchern

Schöner wäre aus meiner Sicht eine Lösung mit Hamcrest-Matchern:

assertThat(root, treeNodeMatchingId("root")
  .withChildrenContaining(
    treeNodeMatchingId("a")
      .withChildrenContaining(
        treeNodeMatchingId("b"),
        treeNodeMatchingId("c")
      ),
    treeNodeMatchingId("d")
  )
);

Den Hamcrest-Matcher hinter treeNodeMatchingId muss man jetzt aber selbst implementieren. Aus meiner Sicht ist das ein akzeptabler Kompromiss, aber es geht auch noch einfacher.

Robustere Asserts mit dynamischem IsEquivalent-Hamcrest-Matcher

Zunächst stellen wir das Code-Stück oben noch etwas generischer dar:

assertThat(root, treeNode()
  .withId("root")
  .withChildren(contains(
    treeNode()
      .withId("a")
      .withChildren(contains(
        treeNode()
          .withId("b"), 
        treeNode()
          .withId("c")
      )), 
    treeNode()
      .withId("d") 
  ))
);

Zugegeben - dieser Code ist nicht ganz so einfach zu schreiben und auch etwas holpriger zu lesen. Aber wir können ihn mit XRayInterface abbilden. Dazu definieren wir lediglich das Interface:

interface TreeNodeMatcher extends Matcher<TreeNode> {
  public TreeNodeMatcher withId(String id);
  public TreeNodeMatcher withChildren(Matcher<Iterable<? extends TreeNode>> c);
}

und die Factory-Methode treeNode:

TreeNodeMatcher treeNode() {
  return IsEquivalent.equivalentTo(TreeNodeMatcher.class);
}

Solche IsEquivalent-Matcher können nicht nur Gleichheit prüfen (wie in withId), sondern auch mit beliebigen Matchern (wie in withChildren) umgehen. Im letzteren Falle liefert der Matcher auch sprechende Fehlermeldungen. Z.B. würde eine Prüfung mit folgendem Assert:

assertThat(root, treeNode()
  .withId("root")
  .withChildren(contains(
    treeNode()
      .withId("a")
      .withChildren(contains(
        treeNode()
          .withId("notb"), 
        treeNode()
          .withId("c")
      )), 
    treeNode()
      .withId("d") 
  ))
);

einen Testfehler ergeben:

**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>>>`

IsEquivalent-Hamcrest-Matcher dekorieren

Wenn uns das Interface für die Matcher jetzt noch zu feingliedrig erscheint, dann können wir alle fehlenden Methoden in Form von statischen Methoden oder default-Methoden in die Matcher-Interfaces einfügen, z.B.

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);
  }
}

Damit können wir unseren Test-Code auch wieder ganz kompakt verfassen:

assertThat(root, treeNodeMatchingId("root")
  .withChildrenContaining(
    treeNodeMatchingId("a")
      .withChildrenContaining(
        treeNodeMatchingId("b"),
        treeNodeMatchingId("c")
      ),
    treeNodeMatchingId("d")
  )
);

Zusammenfassung

Mit XRayInterface-IsEquivalent-Matchern können wir in kurzer Zeit mit wenig Code mächtige und flexible Hamcrest-Matcher erzeugen, die

  • keine Duplikation von Extraktions/Navigationspfaden benötigen
  • beliebig kombinierbar mit Standard-Hamcrest-Matchern sind
  • und keine Abhängigkeit zu Equals-Methoden von unterliegenden Objekten haben