Bessere JUnit-5-Tests mit @Nested

Keine Kommentare

Mit JUnit 5 erhalten wir die aus anderen Test-Frameworks bekannte Möglichkeit, Tests zu verschachteln. Damit lassen sich Tests viel übersichtlicher gestalten und besser strukturieren. In diesem Blogpost zeige ich an einem Beispiel, wie das aussehen kann. Wer noch mit JUnit 4 unterwegs, dem sei an dieser Stelle mein Blogpost JUnit 4 & 5 in einem Projekt nutzen ans Herz gelegt.

Bestandsaufnahme

Als Beispiel für diesen Blogpost möchte ich gerne die Tests für die Klasse Validate von Apache Commons Lang nutzen. Im zugehörigen ValidateTest finden wir insgesamt 67 Tests mit Namen wie testIsTrue1, testIsTrue2 oder auch testNotBlankNotBlankStringWithNewlinesShouldNotThrow. Das muss auch besser gehen!

Ein Blick in die Implementierung von testIsTrue1 offenbart, dass auch die Implementierung der einzelnen Tests verbessert werden kann:

@Test
void testIsTrue1() {
    Validate.isTrue(true);
    try {
        Validate.isTrue(false);
        fail("Expecting IllegalArgumentException");
    } catch (final IllegalArgumentException ex) {
        assertEquals("The validated expression is false", ex.getMessage());
    }
}

Wie zu sehen ist, werden hier mehrere Dinge auf einmal getestet. Zunächst wird geprüft, dass keine Exception geworfen wird, wenn isTrue(true) aufgerufen wird. Danach folgt ein Block, der das korrekte Verhalten im Falle einer Exception prüft. Das Pattern, dabei einen try-catch-Block zu verwenden, stammt noch aus der Zeit von JUnit 3. In JUnit 5 können wir dafür die Methode assertThrows() nutzen.

Erste Gruppierung durch @Nested

Widmen wir uns also mal den Tests für die Methode isTrue() und ihre verschiedenen Überladungen. Die Methode gibt es in fünf Ausführungen. Alle werfen eine Exception, wenn der übergebene Boolsche Wert false ist. Darüber hinaus gibt es verschiedene Möglichkeiten die Message der Exception zu beeinflussen:

  • isTrue(boolean) – Wirft eine Exception mit Default Message.
  • isTrue(boolean, String) – Wirft eine Exception mit der angegebenen Message.
  • isTrue(boolean, String, long) – Wirft eine Exception, wobei der gegebene long-Wert in das gegebene String Template eingesetzt wird.
  • isTrue(boolean, String, double) – Wirft eine Exception, wobei der gegebene double-Wert in das gegebene String Template eingesetzt wird.
  • isTrue(boolean, String, Object...) – Wirft eine Exception, wobei die gegebenen Argumente in das gegebene String Template eingesetzt werden.

Bisher werden diese verschiedenen Überladungen durch die Tests testIsTrue1 bis testIsTrue5 getestet. Das führt dazu, dass ich bei einem Fehler erst in die Testimplementierung gucken muss, bevor ich weiß, worum es geht. Deshalb möchte ich für jede Überladung einen @Nested-Block schreiben. Für isTrue(boolean, String, long) könnte das so aussehen:

@Nested
class IsTrueWithLongTemplate {
 
    @Test
    void shouldNotThrowForTrueExpression() {
        Validate.isTrue(true, "MSG", 6);
    }
 
    @Test
    void shouldThrowExceptionWithLongInsertedIntoTemplateMessageForFalseExpression() {
        final IllegalArgumentException ex = assertThrows(
            IllegalArgumentException.class,
            () -> Validate.isTrue(false, "MSG %s", 6));
 
        assertEquals("MSG 6", ex.getMessage());
    }
}

Innerhalb von ValidateTest gibt es jetzt einen eigenen Block für die Methode. Dem Block habe ich einen sinnvollen Namen gegeben, der Aufschluss darüber gibt, welche Methode hier gerade getestet wird. Darüber hinaus habe ich sprechendere Methodennamen gewählt, die zum Ausdruck bringen, welchen Aspekt von isTrue ich hier eigentlich teste. Darüber hinaus habe ich den try-catch-Block aus dem ursprünglichen Code ersetzt. Stattdessen nutze ich jetzt assertThrows. Das macht mir das Prüfen der Exception Message auch deutlich einfacher.

Eine Ebene weiter…

Das Ergebnis bis hierhin ist schon deutlich besser als der ursprüngliche Code. Es geht aber noch etwas besser. Mit dem jetzigen Ansatz hätten wir am Ende eine Reihe von IsTrueXXX-Klassen, eine für jede Überladung von isTrue. Dasselbe dann für all die anderen Methoden von Validate. Es wäre doch schön, wenn wir das noch etwas besser gruppieren würden. Deshalb führe ich für jede Methode eine eigene Ebene ein und dann für jede Überladung eine Unterebene:

@Nested
class IsTrue {
 
    @Nested
    class WithoutMessage {
         // tests for isTrue(boolean) 
    }
 
    @Nested
    class WithMessage {
         // tests for isTrue(boolean, String) 
    }
 
    @Nested
    class WithLongTemplate {
        // tests for isTrue(boolean, String, long) 
    }
 
    @Nested
    class WithDoubleTemplate {
        // tests for isTrue(boolean, String, double) 
    }
 
    @Nested
    class WithObjectTemplate {
        // tests for isTrue(boolean, String, Object...) 
    }
}

Da IntelliJ geschachtelte Tests erkennt, bekomme ich jetzt auch einen übersichtlichen Bericht angezeigt. Daran kann ich sofort sehen, wo ein Fehler passiert ist, wenn mal ein Test fehlschlägt. Ich muss nicht mehr in die Testimplementierung gucken, um diese Information zu erhalten.

tests

Alternativen

Die in diesem Blogpost beschriebene Struktur bei der je Methode und dann je Überladung ein eigener @Nested-Block verwendet wird, passt für den Fall einer Utility-Klasse wie Validate ziemlich gut. Das liegt daran, dass die einzelnen Methoden nicht so viel miteinander zu tun haben. Es sind eben nur Helfermethoden ohne zusammenhängende Logik. Wenn wir ein Domänen-Objekt testen wollen, dann eignet sich dieser Ansatz jedoch nicht besonders gut. Das liegt daran, dass hier häufig das Verhalten des Objekts interessant ist. Hier ist ein Behavior-Driven-Development-Ansatz mit Tests im „Given-When-Then“-Stil besser geeignet. Mein Kollege Tobias Göschel hat in einem Blogpost beschrieben, wie das mit JUnit 4 geht. Dieses Konzept lässt sich in JUnit 5 noch einfacher anwenden.

Fazit

Die @Nested-Annotation bietet neue Möglichkeiten bei der Strukturierung von JUnit Tests. In diesem Blogpost habe ich anhand eines Beispiels eine Struktur vorgestellt, mit der sich die Lesbarkeit deutlich verbessern lässt. Dabei schreibe ich für jede Methode einen Block und bette dort je Methoden-Überladung einen weiteren inneren Block ein.

Benedikt Ritter arbeitet seit September 2013 als Software Crafter bei der codecentric AG. Sein Können bringt er nicht nur in der Berufswelt zum Einsatz: Benedikt ist Member der Apache Software Foundation und Committer beim Apache Commons Projekt.

Kommentieren

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert.