Das Prüfen und Testen von Exceptions, also sogenannten erwarteten oder unerwarteten Ausnahmen, ist sicherlich ein weiterer großer Vorteil der testgetriebenen Entwicklung. Sehr häufig fallen gerade diese Art von Tests hinten runter, wenn Tests nachrträglich geschrieben werden.
Dehalb möchte ich an dieser Stelle einmal mehrere mögliche Umsetzungen mit all ihren Vor- und Nachteilen hier aufzählen und dokumentieren. Sollte jemand eine neue oder andere Art wissen, ist es ihm/ihr freigstellt, dies hier zu kommentieren.
- Wie fange ich denn eigentlich Exceptions in einem Test ab?
- Und wie prüfe ich sie auf Herz und Nieren?
Was sind denn Herz und Nieren bei Exceptions? Naja, im Wesentlichen der Typ und die Message und gegebenenfalls, ob eine in tieferen Schichten aufgetretene Exception gekapselt wurde.
try…catch…
Natürlich gibt es bereits einen standardisiserten Weg, Ausnahmen in Programmen zu behandeln: den try-catch-Mechanismus.
Dieser Weg kann nun auch beim Testen einer Methode verwendet werden.
package de.mike.kata.sudoku; import static org.hamcrest.Matchers.is; import static org.junit.Assert.assertThat; import static org.junit.Assert.fail; import org.junit.Before; import org.junit.Test; public class SudokuTest { private Sudoku game; @Before public void setUp() { game = new Sudoku(); } @Test public void testBeginningStatus() { ... } @Test public void testFirstDraw() throws Exception { game.enterDigit(1, 2, 3); ... } @Test public void testNoNegativeDigits() throws Exception { try { game.enterDigit(-1, 5, 5); fail("IllegalArgumentException should have been thrown."); } catch (IllegalArgumentException ex) { assertThat(ex.getMessage(), is("Only digits [1..9] are allowed.")); } } }
Die zu testende Methode wird innerhalb eines try-catch-Blocks aufgerufen.
Nach dem Aufruf wird nun am fail deutlich, dass der Test schiefgehen würde, falls keine IllegalArgumentException geworfen worden wäre.
Vorteil dieser Methode ist, dass der Test im besten Sinne das Exception-Handling so testet, wie der Aufrufer der Methode dies auch tun sollte. Der Test ist also gleichzeitig eine gute Dokumentation für den Aufrufer.
Der Nachteil wird deutlich, wenn man die Test Coverage dieses Tests durch EclEmma überprüft:
Die Code Coverage liegt weiterhin bei 100%, aber der Test zeigt nur eigeschränkte Coverage an. Insbesondere, wenn mehr Code innerhalb des try-catch-Blocks aufgerufen wird, kann dies sehr unangenehm zu Buche schlagen.
@Test(expected = …)
Mit diesem Attribut hinter der Test-Annotation kann man prüfen, dass innerhalb des Tests eine Exception eines bestimmten Typs fliegen muss. Andernfalls ist der Test rot.
Ein weiterer kleiner Nachteil ist die nicht hilfreiche Ausgabe im Falle einer alternativen Exception. Dann würde der Test fehlschlagen und das Werfen der Exception als Grund angeben. Die Message aus dem fail – Aufruf käme nicht zum Tragen.
@Test(expected = IllegalArgumentException.class) public void testOnlyDigits() throws Exception { game.enterDigit(10, 5, 7); }
In diesem Fall steht im Log:
java.lang.AssertionError: Expected exception: java.lang.IllegalArgumentException at org.junit.internal.runners.statements.ExpectException.evaluate(ExpectException.java:32) at org.junit.internal.runners.statements.RunBefores.evaluate(RunBefores.java:26) at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:271) ...
Um diesen Test grün zu kriegen, könnte man beispielsweise folgendes implementieren:
public void enterDigit(int digit, int row, int column) { if (digit > 9) { throw new IllegalArgumentException(); } ... }
Damit würde man nun aber keine durch eine Message oder einen Cause – qualifizierte Ausnahme werfen können, denn die expected-Lösung kann nur auf die Tatsache reagieren, dass eine solche Exception flog, nicht aber das Exception-Objekt selbst untersuchen.
Damit ist zwar diese Lösungsstrategie sehr komfortabel der try-catch-Lösung gegenüber, aber reduziert die Prüfung auf den Exception-Typ, was in vielen Fällen nicht ausreicht.
Zudem ist die Testabdeckung des Tests ebenfalls rot bei dieser Lösung.
JUnit Rules
Aus meiner Sicht am elegantesten ist die Lösung, die mit JUnit Rules arbeitet.
Hier der Code für diese Lösung:
@Rule public ExpectedException expectedException = ExpectedException.none(); @Test public void testSameDigitInOneRow() throws Exception { expectedException.expect(IllegalArgumentException.class); expectedException.expectMessage("Same digit 5 in the same row 1."); game.enterDigit(5, 1, 1); game.enterDigit(5, 1, 6); }
Man deklariert sich ein public Attribut vom Typ org.junit.rules.ExpectedException und instanziiert es mit ExpectedException.none().
Sollte die Exception nicht geworfen werden, erscheint ein AssertionError:
java.lang.AssertionError: Expected test to throw (an instance of java.lang.IllegalArgumentException and exception with message a string containing "Same digit 5 in the same row 1.") at org.junit.Assert.fail(Assert.java:88) at ...
Wer sich nun an dem public Attribut stört oder einen entsprechenden Sonar Checker aktiviert hat, der public Attribute prinzipiell anmeckert, dem kann wie unten angegeben, geholfen werden:
private ExpectedException expectedException = ExpectedException.none(); @Rule public ExpectedException getExpectedException() { return expectedException; } @Test ...
Diese Variante ist sicherlich aufwendiger als das vorherige Verfahren, erlaubt nun eben aber das Untersuchen der geworfenen Exception, die man als Objekt in Händen hält.
Auch diese Methode hat den Schönheitsfehler der fehlenden Überdeckung des Tests.
catch exception Bibliothek
Mit dieser Bibliothek kann man zwei wesentliche Konzepte verfolgen:
- Exceptions zu fangen (catch-exception)
- Throwables zu fangen (catch-throwables)
Je nach Konzept bindet man beispielsweise über Maven die eine oder andere Bibliothek ein:
<dependency> <groupId>eu.codearte.catch-exception</groupId> <artifactId>catch-exception</artifactId> <version>1.4.4</version> <scope>test</scope> </dependency> <dependency> <groupId>eu.codearte.catch-exception</groupId> <artifactId>catch-throwable</artifactId> <version>1.4.4</version> <scope>test</scope> </dependency>
Angewandt auf die Sudoku-Kata ergibt sich damit beispielsweise folgender Test:
@Test public void testSameDigitInOneColumn() throws Exception { game.enterDigit(5, 1, 1); CatchException.catchException(game).enterDigit(5, 6, 1); assertThat(CatchException.caughtException(), is(instanceOf(IllegalArgumentException.class))); }
Wir wollen also prüfen, dass bei dem Versuch in Spalte 1 (und Zeile 6) wieder eine 5 einzutragen, das Programm eine Exception wirft,
die deutlich werden läßt, dass dies gegen die Spielregeln ist.
Der Test produziert nun folgenden Stacktrace:
java.lang.AssertionError: Expected: an instance of java.lang.IllegalArgumentException but: null at org.hamcrest.MatcherAssert.assertThat(MatcherAssert.java:20) ...
Der große Vorteil dieses Konzepts dem Rules-Konzept gegenüber ist die Unabhängigkeit des verwendeten Testrunners. Man könnte die Bibliothek auch im TestNG Umfeld verwenden.
Es könnten mit einem Test theoretisch auch mehrere Exceptions gefangen und geprüft werden.
Der für mich entscheidende Vorteil ist aber, dass die Abdeckung des Tests nun auch durch EclEmma mit 100% Coverage angezeigt wird:
In dieser Übersicht sieht man die Colorierung durch das Eclipse-Plugin EclEmma, das die Coverage nicht nur berechnet, sondern eben auch farblich hinterlegt.
Grüne Bereiche sind abgedeckt, rote Bereiche nicht. Bei Branches gibt es die gelbe Colorierung, falls nicht alle Zweige durchlaufen werden.
Mit der Bibliothek catch-exception fallen die Tests, in denen Exceptions oder Throwables getestet werden in der Berechnung der Coverage nun nicht mehr hinten runter.
JUnit Lambda
Wie spannend das obige Konzept ist, lässt sich erahnen, wenn man einen Blick auf das neue JUnit Konzept JUnit Lambda wirft.
Zusammenfassung
Wer sich nicht scheut, neue Bilbiotheken im eigenen Projekt einzuführen, verwendet sinnvollerweise catch-exception.
Das Handling ist einfach und intuitiv und läßt einem allen Spielraum, die gefangenen Exceptions zu untersuchen.
Wer auf Standards setzen möchte, bedient sich des JUnit Rules Konzepts und ignoriert die „fehlende“ Testcodeabdeckung. Der zu testende Code wird ja abgedeckt und das ist die qualitativ wichtigere Aussage.
Wer nicht mit JUnit ab Version 4.8 rumhantieren möchte oder eben ausschließlich auf JDK Boardmittel setzen will, der arbeitet mit try-catch. Vergesst allerdings nicht, den Test mit fail(…) zu beenden, falls keine Exception flog, denn sonst aht alles Testen keinen Sinn.