Unit Tests: Validieren mit Matchers, wenn Mocks nicht mehr reichen

Wenn Ausführungspfade für die Prüfung mit Mocks zu komplex werden, kommt man mit einem Matcher viel weiter. In diesem Artikel erläutern wir zunächst die Grundlagen von Unit Tests, um dann auf die Probleme mit Mocks einzugehen und Matchers als Alternative vorzustellen.

Unit Tests dienen dazu, die Korrektheit einer funktionalen Einheit zu überprüfen. Dafür wird diese als Testsubjekt in eine Test-Fixture eingebettet, bei der man möglichst alle Komponenten bis auf das Subjekt selbst unter Kontrolle hat. Die nötigen Hilfsmittel dafür bietet ein Test-Framework wie etwa JUnit.
Die zu testende Funktion erhält und/oder liefert ggf. Daten und ruft Methoden an Kollaboratoren auf. Die Korrektheit ergibt sich aus der Prüfung dieser Daten und des Aufrufverhaltens.

Zum Überprüfen der Daten, die vom Subjekt produziert bzw. kommuniziert werden, kommt die Funktion assert in ihren vielen Ausprägungen zum Einsatz. Besonders hervorzuheben ist dabei assertThat(value, matcher), mit der ein Wert mit einer Fülle von Matcher-Klassen aus der Hamcrest-Bibliothek validiert werden kann. Diese ist besonders darauf ausgelegt, eine Lösung für die komplexesten Datenstrukturen nach beliebig strikten oder flexiblen Kriterien zu bieten, indem man vorhandene Matcher kombiniert oder komfortabel eigene Implementierungen einsetzt.

Testen mit Mocks

Das Verhalten des Subjekts definiert sich in erster Linie durch die Methodenaufrufe an seine Kollaboratoren und darüber hinaus an den Daten, die es dabei übergibt. Mocking Frameworks wie Mockito (oder EasyMock und andere) bieten die Möglichkeit, beim Test-Setup die Kollaboratoren eines ausreichend testbaren Subjekts durch Mocks (generierte Doubles) zu ersetzen. Nach dem Testaufruf wird mit der Funktion verify geprüft, ob die tatsächlich erfolgten Methodenaufrufe an die Mocks (inklusive einer eventuellen Prüfung der enthaltenen Daten) den Erwartungen entsprechen. Auch dies kann mehr oder weniger strikt geschehen. Dabei können Aspekte wie die Reihenfolge von Aufrufen (auch über mehrere Mocks hinweg), die zulässige Anzahl einzelner Aufrufe und die Zulässigkeit von zusätzlichen Aufrufen mit einbezogen werden.

Es ist davon auszugehen, dass die Vielfalt von verschiedenen Strukturen und Mustern bei den Daten wesentlich höher ist als beim Aufrufverhalten eines Subjekts an die Kollaboratoren. Entsprechend sind die Matchers so angelegt, dass man sich auf die meisten Situationen relativ leicht einstellen und mit etwas Mehraufwand eigentlich jede zu testende Datenstruktur verarbeiten kann.

Die Optionen der verify Funktionen sind allerdings meist fest in den Mocking-Frameworks verdrahtet, liegen in internen Bereichen und lassen nur ein sehr geringes Maß an Erweiterung zu. Gerade was die Striktheit angeht, hat man häufig nur die Wahl zwischen ganz oder gar nicht.

Ein ganz simpler Prozess.

Nehmen wir zum Beispiel eine Funktion, die einen Prozess durchführen soll, der aus mehreren Schritten besteht.

Beim Zubereiten eines Pfannkuchens muss man
Mehl zugeben, Milch zugeben, Eier zugeben
dann Teig rühren
dann Teig in der Pfanne backen.

Die Reihenfolge der ersten drei Schritte untereinander ist für das korrekte Ergebnis unerheblich, die beiden letzten Schritte müssen aber danach und in dieser Reihenfolge ausgeführt werden. Wenn man nur prüft, ob alle fünf Schritte einmal ausgeführt wurden, würde der Testcase nicht aufzeigen, falls in der Implementierung der ungerührte Teig gebacken würde. Dadurch wird der Test nutzlos.

Wenn man die Implementierung der Funktion kennt und weiß, dass Mehl vor Milch vor Eiern kommt, kann man diese eine Sequenz strikt mit verify prüfen. Aber eine Änderung der Implementierung, die die Korrektheit des Ergebnisses nicht beeinträchtigt, würde diesen Testcase fehlschlagen lassen. Und das sollte nicht passieren.

Außerdem könnte es sein, dass mehrere Köche daran arbeiten und voneinander unabhängige Schritte parallel ausführen (ForkJoinPool mit parallelen Subtasks). Dann würde der Testcase bei jedem Durchlauf potentiell mit einer anderen validen Ausführungsfolge konfrontiert, die er verlässlich erkennen muss. Die verfügbaren Lösungen für die Prüfung der Reihenfolge von Aufrufen (InOrder bei Mockito, StrictMockControl bei Easymock) geben es aber leider nicht her, eine teilweise strikte und teilweise unabhängige Ausführungsstruktur zu definieren – man muss also einen anderen Weg finden.

Ein etwas komplexerer Prozess.

Die Menge aller gültigen Ausführungspfade lässt sich als ein unvollständiger, gerichteter Graph darstellen, dessen Knoten die Arbeitsschritte und dessen Kanten die notwendigen Reihenfolgebeziehungen sind.

Ein naiver Ansatz wäre es, alle im Graphen enthaltenen gerichteten Pfade als strikt geordnete Aufrufabfolge zu prüfen. Das wären in diesem Falle also „Mehl vor rühren vor backen“, „Milch vor rühren vor backen“ und „Eier vor rühren vor backen“ wofür drei InOrder-Kontexte benötig werden. Genauso gut kann man für jede Kante des Graphen die Reihenfolge von genau zwei Aufrufen mit einem InOrder-Kontext abhandeln und würde damit die Beziehung zwischen „rühren“ und „backen“ nicht mehrmals prüfen.

Aber schon eine kleine Erweiterung des Prozesses führt zu einer unverhältnismäßig schnell wachsenden Menge an Knoten und Kanten. Dabei müsste die Abrufabfolge für jeden einzelnen Prozess komplett manuell ausgearbeitet werden.

Eine Lösung: Das Verhalten des Subjekts als Datenstruktur modellieren

Die Darstellung aller validen Abläufe ist eine Menge von Reihenfolgebeziehungen („A vor B“) von einzelnen Aktionen (Set<Order<Step>>). Dies kann wie oben erwähnt leicht als gerichteter Graph modelliert werden, aus dem wiederum diese Menge berechnet werden kann. Mit dem Builder-Pattern lässt sich eine solche Struktur recht komfortabel und übersichtlich aufbauen.

Abschließend muss man noch die Kollaboratoren anstatt durch Mocks durch ein anderes Double ersetzen, das bei einem Methodenaufruf einer zentralen Liste von tatsächlich erfolgten Aktionen einen Eintrag hinzufügt. Am Ende erhält man hieraus eine List<Step>.

Die Validierung reduziert sich auf die Prüfung, ob die Liste von Aktionen allen Bedingungen der geforderten Reihenfolgen erfüllt. Dafür lässt sich leicht ein Matcher implementieren, der mit einem Set<Order<Step>> initialisiert wird, um ihn dann mit assertThat auf die List<Step> anzuwenden.

Ist dies einmal implementiert, muss für jeden Prozess im Test nur der Ablaufgraph befüllt werden und der Rest funktioniert von selbst.

Kontakt

Testing gehört zum Alltag in vielen unserer Leistungsbereiche. Auf unserer Website gibt es mehr Informationen dazu, der direkte Kontakt zu uns ist telefonisch unter +49 721 619 021-0 oder jederzeit per E-Mail an list-blog@inovex.de möglich.

comments powered by Disqus