Zeitgemäßes Exception Testing mit JUnit 4

Im Laufe der Zeit hat das JUnit Test Framework immer raffiniertere Verfahren angeboten, um das Verhalten in Ausnahmefällen (Exceptions) zu testen. Um aber auch mit der aktuellen Methode alle relevanten Aspekte abdecken zu können, ist eine kleine Erweiterung notwendig.

Der Unit-Test an sich

Ein einzelner Unit-Testfall soll alle relevanten Aspekte des Verhaltens der UnitUnderTest bei der Ausführung einer konkreten Funktion prüfen und sicherstellen. Dabei ist die Funktion im Kontext ihres Zustandes, des Verhaltens ihrer Kollaborateure und eventueller Eingabedaten zu betrachten. Das zu prüfende Verhalten besteht vornehmlich aus einem eventuellen Ergebnis sowie aus der Interaktion mit den Kollaborateuren. Die Struktur eines solchen Tests entspricht gängigerweise dem folgenden Muster.

Das Verhalten im Ausnahmefall testen

Neben dem produzieren des erwünschten Ergebnisses im Normalfall soll eine Funktion häufig auch ein bestimmtes Verhalten im Ausnahmefall zeigen. Ausnahmen treten immer dann auf, wenn der aktuelle Kontext von Zustand und Verhalten der beteiligten Akteure der Unit die Ausführung der aufgerufenen Funktion unmöglich macht. Das kann eine ungültige Eingabe sein oder aber ein unerwartetes Verhalten eines Kolaborateurs. In diesem Fall löst die Funktion während ihrer Ausführung eine Exception aus, welche dann vom Aufrufer behandelt werden muss.

Der klassische Exception Test

Der einfachste Ansatz zum Testen dieses Verhaltens sieht dann so aus, dass man die Funktion aufruft und in einem catch-Block prüft, dass die Exception tatsächlich geworfen wurde.

Der Exception Test mit Annotation

Wie man im obigen Beispiel sehen kann, ist das mit einigem Boilerplate-Code verbunden, bei dem auch nicht selten Flüchtigkeitsfehler auftreten, welche leicht dazu führen, funktionale Fehler zu verdecken. Um diesem Umstand entgegenzuwirken wurde mit JUnit 4.0 nun ein Mechanismus eingeführt, der die Prüfung auf eine erwartete Exception in das Framework verlagert, welches den Test ausführt. Dafür muss diesem in der @Test-Annotation mitgeteilt werden, welche Exception-Klasse erwartet wird. Tritt solch eine Exception nicht auf, schlägt der Testfall fehl.

Der Exception Test mit Rule

Mithilfe der Annotation wird nun zwar die mühsame Kleinarbeit dem JUnit Framework überlassen, dafür verliert man aber auch einen Großteil an Flexibilität und Genauigkeit. Es kann damit nämlich nur getestet werden, dass an irgend einer Stelle in der Ausführung der Test-Methode eine Exception eines bestimmten Typs ausgelöst wird. Alle weiteren Details der konkreten Exception-Instanz bleiben ungeprüft.

Nach der Einführung des Konzepts der Rules als leicht zu erweiternde Kontrollelemente des Ausführungskontextes von Unit-Tests wurde mit der Version 4.7 die ExpectedException-Rule bereitgestellt, um sehr spezifisch die konkreten Eigenschaften von ausgelösten Exceptions zu testen.

Dieses neue Idiom wird nunmehr für sämtliche Exception-Tests empfohlen, da es weniger fehleranfällig ist als das erste und viel ausdrucksstärker als das zweite.

Aspekte des Verhaltens im Ausnahmefall

Insbesondere bei Tests des Verhaltens einer Unit im Fall einer Ausnahmesituation muss man sehr genau überlegen, welche Aspekte des gesamten Verhaltens wirklich relevant sind.

  • Kann es eine beliebige Exception sein oder muss es ein bestimmter Typ sein?
  • Muss die Instanz besondere Attribute haben, eine bestimmte Nachricht, einen Auslöser (cause), einen Fehlercode o.ä.?
  • Wie weit wurde die interne Funktion bereits abgearbeitet?
  • Wurden Daten erzeugt oder Seiteneffekte ausgelöst?
  • Wurden Ressourcen allokiert und auch wieder freigegeben?

Da eine Funktion beim Werfen einer Exception rein technisch keinen Rückgabewert mehr liefern kann, nimmt die Exception selbst nun den Platz des Ergebnisses ein. Aber auch die Interaktion mit den Kollaborateuren ist weiterhin ein Teil des Verhaltens.

Darüber hinaus mag es in einer einzelnen Test-Methode außer dem eigentlichen Testablauf auch noch hilfreich sein, eventuelle Aufräumarbeiten durchzuführen, die so spezifisch sind, dass sie in einer allgemeinen mit @After annotierten Methode nicht sinnvoll unterzubringen wären.

In der klassischen Variante kann man auf all diese Details sehr spezifisch eingehen.

Das zweite Idiom mit Spezifikation der erwarteten Exception-Klasse bietet leider tatsächlich kaum mehr als genau das.

Dagegen bietet das aktuell empfohlene Verfahren zwar eine komfortable und flexible Schnittstelle, die Details der geworfenen Exception zu verifizieren; sonstige Prüfungen und Nacharbeiten ermöglicht sie aber nicht.

Functional Interfaces to the rescue

Was also nun? Muss man wieder ganz zum Anfang zurück, wenn man wirklich richtig testen will, oder muss man mit den blinden Flecken leben, die das Framework nicht so recht durchdringt?

So schlimm ist es zum Glück nicht.

Mit einer sehr überschaubaren selbstgeschriebenen Erweiterung kann man auch die ExpectedException Rule dazu bringen, die gewünschten Aktivitäten nach dem Prüfen der geworfenen Exception auszuführen. Das Konzept der Functional Interfaces in Java 8 gibt uns dafür eine komfortable und übersichtliche Syntax an die Hand.

Der Trick beruht darauf, dass die Rule zum Überprüfen der Exception-Instanz beliebige Matcher entgegennimmt und diese aufruft. Wir bauen also einen leichtgewichtigen Adapter, der als Matcher auftritt und seinerseits ein Runnable aufnimmt, das er dann ausführt.

Die Adapter-Klasse und die statische Factory-Methode lassen sich leicht in einer Util-Klasse unterbringen, um für alle Tests verfügbar zu sein.

Zur Erhöhung der Wiederverwendbarkeit kann dem Runnable auch die Exception als Parameter übergeben werden, wenn man dafür ein eigenes Functional Interface definiert. Und letztlich ist auch die Verwendung eines Callable<Boolean> anstatt eines Runnable denkbar, um das Ergebnis des Adapters als Matcher zu bestimmen. Dann müsste kein AssertionError innerhalb ausgelöst werden.

Join us!

Wir sind auf der Suche nach talentierten Software-Entwicklern mit Schwerpunkt Java, .NET und JavaScript, die auch vor neueren Programmiersprachen wie Clojure und Go nicht zurückschrecken. Offene Stellen gibt es in Vollzeit, als Werkstudent*in oder Praktikant*in.
comments powered by Disqus