Hinweis:
Dieser Blogartikel ist älter als 5 Jahre – die genannten Inhalte sind eventuell überholt.
Im Laufe der Zeit hat das Testing Framework JUnit 4 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.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
@Test public void functionReturnsCorrectResult() { // setup prepareUnitState(); // initialize unitUnderTest with state and colaborators prepareColaboratorBehaviour(); Object input = prepareInput(); // call function Object result = unitUnderTest.executeFunction(input); // check result and behaviour assertThat(result, is(EXPECTED_RESULT)); verifyColaboratorInteractions(); } |
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 mit JUnit 4
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.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
@Test public void functionReturnsCorrectResult() { // setup prepareUnitState(); // initialize unitUnderTest with state and colaborators prepareColaboratorBehaviour(); Object input = prepareInput(); try { // call function unitUnderTest.executeFunction(input); fail(); // ensure that this point is never reached } catch {Exception e) { assertThat(e, is(EXPECTED_EXCEPTION)); } } |
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.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
@Test(expected=Exception.class) public void functionReturnsCorrectResult() { // setup prepareUnitState(); // initialize unitUnderTest with state and colaborators prepareColaboratorBehaviour(); Object input = prepareInput(); // call function unitUnderTest.executeFunction(input); } |
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.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
@Rule public ExpectedException thrown = ExpectedException.none(); // initially, expect no exception @Test public void functionReturnsCorrectResult() { // setup prepareUnitState(); // initialize unitUnderTest with state and colaborators prepareColaboratorBehaviour(); Object input = prepareInput(); // after this point, a matching exception is expected thrown = ExpectedException.expect(is(EXPECTED_EXCEPTION)); // call function unitUnderTest.executeFunction(input); } |
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.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
@Test public void functionReturnsCorrectResult() { // setup prepareUnitState(); // initialize unitUnderTest with state and colaborators prepareColaboratorBehaviour(); Object input = prepareInput(); // fails if any exception is thrown before this point try { // call function unitUnderTest.executeFunction(input); fail(); // fails, if no exception is thrown } catch {ExpectedExceptionType e) { // fails if type not correct assertSpecificExceptionAttributes(e); // fails if attributes not correct verifySpecificColaboratorInteractions(); // fails if interactions not correct } // teardown specificCleanup(); } |
Das zweite Idiom mit Spezifikation der erwarteten Exception-Klasse bietet leider tatsächlich kaum mehr als genau das.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
@Test(expected=ExpectedExceptionType.class) // fails if no Exception or wrong type is thrown public void functionReturnsCorrectResult() { // setup prepareUnitState(); // initialize unitUnderTest with state and colaborators prepareColaboratorBehaviour(); Object input = prepareInput(); // won't fail if ExpectedExceptionType is thrown before this point // call function unitUnderTest.executeFunction(input); // won't fail if any attribute differs from the specification // won't fail if any interaction differst from the specification // teardown specificCleanup(); // will never be executed } |
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.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 |
@Rule public ExpectedException thrown = ExpectedException.none(); // initially, expect no exception @Test public void functionReturnsCorrectResult() { // setup prepareUnitState(); // initialize unitUnderTest with state and colaborators prepareColaboratorBehaviour(); Object input = prepareInput(); // fails if any exception is thrown before this point thrown = ExpectedException .expect(ExpectedExceptionType.class) // fails if no Exception or wrong type is thrown .expect(is(EXPECTED_EXCEPTION)) // fails if matcher won't match the exception .expectMessage("specific error") // applies containsString Matcher .expectMessage(is("specific error")) // fails if matcher won't match the message .expectCause(is(EXPECTED_CAUSE)); // fails if matcher won't match the cause // call function unitUnderTest.executeFunction(input); // won't fail if any interaction differst from the specification // teardown specificCleanup(); // will never be executed } |
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. Und wenn man das Runnable
Interface dann noch in einer Ableitung mit ein bisschen Exception-Handling ergänzt, lassen sich auch Methodenaufrufe mit checked Exceptions in der Signatur problemlos in einer Lambda verwenden.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 |
@Rule public void ExpectedException thrown = ExpectedException.none(); // initially, expect no exception @Test public void functionReturnsCorrectResult() { // setup prepareUnitState(); // initialize unitUnderTest with state and colaborators prepareColaboratorBehaviour(); Object input = prepareInput(); // fails if any exception is thrown before this point thrown = ExpectedException .expect(is(EXPECTED_EXCEPTION)) // fails if matcher won't match the exception .expect(afterException(() -> { verifySpecificColaboratorInteractions(); // fails if interactions not correct, may throw checked Exception // teardown specificCleanup(); })); // call function unitUnderTest.executeFunction(input); } public static PerformAfterException afterException(Runnable runnable) { return new PerformAfterException(runnable); } public static class PerformAfterException extends TypeSafeMatcher<Throwable> { private final CheckedRunnable<?> runnable; public PerformAfterException(CheckedRunnable<?> runnable) { this.runnable = runnable; } @Override public void describeTo(Description description) { description.appendText("Runnable should be performed without errors"); } @Override protected boolean matchesSafely(Throwable item) { if (runnable != null) { runnable.run(); } return true; // the result could also be determined with a Callable<Boolean> } } @FunctionalInterface public interface CheckedRunnable<E extends Exception> extends Runnable { @Override default void run() throws RuntimeException { try { runChecked(); } catch (Exception ex) { throw new RuntimeException(ex); } } void runChecked() throws E; } |
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.
Ihr müsst das ‚void‘ hier entfernen:
@Rule
public void ExpectedException thrown = ExpectedException.none(); // initially, expect no exception
Vielen Dank für den Hinweis. wird umgehend korrigiert.