JUnit 5 – Die neue Generation des Unit-Testens

#JAVAPRO #JCON2017 #JUnit5

Ohne JUnit kommt heutzutage kein Java-Projekt und kein Java-Entwickler aus. Das Tool zum automatisierten Testen ist seit knapp 20 Jahren der Industriestandard und nahezu ein Synonym für Testen in Java. Der Artikel stellt die Features der neuen Version 5 vor, zeigt wie es sich in bestehende Projekte integrieren lässt und wie man im Parallelbetrieb Testklassen, die in Version 4 und 5 geschrieben sind, zusammen ausführen kann.

Ein erster Testfall

Seit mittlerweile zehn Jahren ist nun die Version 4 von JUnit aktuell, die damals komplett neu geschrieben wurde auf Basis der mit Java 5 neu eingeführten Annotationen. Seit Ende 2015 arbeitet das JUnit-Team an Version 5, um die mit Java 8 eingeführten Lambda-Ausdrücke für das automatisierte Testen nutzbar zu machen und setzt dafür auf eine erweiterungsfähige Architektur und eine komplett neue Code-Basis und Paketstruktur. Momentan ist der angekündigte Erscheinungstermin September 2017 , der aber bereits mehrfach verschoben wurde.

Auf den ersten Blick sieht der erste JUnit-5-Testfall (Listing 1) nicht viel anders aus als mit JUnit 4. Auf den zweiten Blick fällt auf, dass die Imports gegen den neuen Paketnamen org.junit.jupiter.api gehen, statt wie bisher org.junit. Die @Test Annotation und die fail Methode sind aber geblieben. Der Hauptunterschied ist der fehlende public Modifier vor der Testmethode, der nicht mehr notwendig ist. Das spart etwas Schreibarbeit, die sich bei der Vielzahl von Testmethoden schnell summiert.

Listing 1
import static org.junit.jupiter.api.Assertions.*;
import org.junit.jupiter.api.Test;

public class ErsterTestfall {

@Test
void toImplement() {
   fail(„Muss noch implementiert werden!“);
   }
}

Da bisher erst wenige IDEs – namentlich IntelliJ IDEA – die Ausführung von JUnit 5 von Haus aus unterstützen, liefert JUnit 5 einen Runner mit, der auf JUnit 4 basiert und als Adapter in anderen IDEs wie Eclipse oder Netbeans sowie als Übergangslösung in älteren Versionen der IDEs verwendet werden kann. Dafür annotiert man die Testklasse mit @RunWith(JUnitPlatform.class). Um die Tests auf diesem Weg auszuführen, sind drei Artefakte notwendig (Listing 2), die z.B. per Maven oder Gradle importiert werden können. Die angegebene Version bezieht sich auf das letzte Milestone-Release M6.

Listing 2
org.junit.jupiter:junit-jupiter-api:5.0.0-M6
org.junit.jupiter:junit-jupiter-engine:5.0.0-M6
org.junit.platform:junit-platform-runner:1.0.0-M6

Neue Annotationen

Viele bekannte Annotationen haben einen prägnanteren Namen bekommen. Das Setup und der Abbau der zum Test notwendigen Infrastruktur erfolgt jetzt über die Annotationen @BeforeAll@BeforeEach, @AfterEach und @AfterAll statt wie vorher über @BeforeClass, @Before, @After und @AfterClass. Die Funktion der Annotationen ist aber exakt gleichgeblieben: @BeforeEach wird vor und @AfterEach nach jeder Testmethode ausgeführt, @BeforeAll vor und @AfterAll nach allen Testmethoden, weswegen die letzten beiden statisch sein müssen. Auch die @Ignore Annotation wurde in @Disabled umbenannt, funktioniert aber ebenfalls wie bisher.

Als Ersatz für die Categories aus JUnit 4 steht die Annotation @Tag für Klassen und Methoden zur Verfügung, die zur Kennzeichnung oder Gruppierung von Testfällen verwendet werden kann. Die Tags werden als String-Parameter mitgegeben, z.B. @Tag(„end2end“) und bei der Ausführung kann nach diesen Tags gefiltert werden. Bei einer häufigen Verwendung von Tags ist es empfehlenswert, eine eigene abgeleitete Annotation (Listing 3) für den jeweiligen Tag zu definieren, um nicht alle Tests mit einem fehleranfälligen String-Tag markieren zu müssen. In diesem Beispiel gibt es eine eigene Annotation @End2EndTest,die fachlich übergreifende Ende-zu-Ende-Testfälle kennzeichnet und eine nachgelagerte Ausführung erlaubt, wenn die Komponententests grün sind.

Listing 3
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Test
@Tag(„end2end“)
public @interface End2EndTest {}

@End2EndTest
void kompletterEndeZuEndeTest() {
// kompletter Ende-zu-Ende-Testfall
}

Ein weiteres nützliches Feature ist die @Nested-Annotation, mit der Testklassen hierarchisch strukturiert werden können. Die Annotation kann an inneren Klassen einer Testklasse verwendet werden und führt zu einer hierarchischen Aufbereitung im Testergebnis. Damit lassen sich die Testfälle gemäß den Haupteigenschaften der zu testenden Klasse strukturieren und die Übersichtlichkeit der Tests wird erhöht. Außerdem lassen sich auch @BeforeEach und @AfterEach innerhalb geschachtelter innerer Testklassen verwenden, sodass der benötigte Testkontext unterschiedlich aufgebaut werden kann. In (Listing 4) ist ein ausführliches Beispiel anhand der Optional Klasse dargestellt mit der Unterscheidung in die beiden Haupteigenschaften, ob ein Wert vorhanden ist oder nicht.

Listing 4
public class OptionalTest {
   @Nested
   @DisplayName(„Testfälle für ein leeres Optional“)
   class EmptyOptionalTest {
      private Optional<String> emptyOptional;
      @BeforeEach
      void setUp() {
         emptyOptional = Optional.empty();
      }
      @Test
      @DisplayName(„Optional.isPresent() liefert false“)
      void isPresent() {
         assertFalse(emptyOptional.isPresent());
      }
      @Test
      @DisplayName(„Optional.get() liefert Exception“)
      void get() {
         assertThrows(NoSuchElementException.class,
                  emptyOptional::get);
      }
   }
   @Nested
   @DisplayName(„Testfälle für ein vorhandenes Optional“)
   class PresentOptionalTest {
         private static final String VIADEE = „viadee“;
         private Optional<String> presentOptional;
         @BeforeEach
         void setUp() {
            presentOptional = Optional.of(VIADEE);
      }
      @Test
      @DisplayName(„Optional.isPresent() liefert true“)
      void isPresent() {
         assertTrue(presentOptional.isPresent());
      }
      @Test
      @DisplayName(„Optional.get() liefert den Wert“)
      void get() {
         assertEquals(VIADEE, presentOptional.get());
      }
   }
}

Durch @DisplayName wird eine kurze inhaltliche Beschreibung des Testfalls ausgegeben, sodass die ausgeführten Testfälle nicht nur in ihrer Struktur, sondern auch in ihrer Bedeutung direkt verständlich sind, wie aus dem Ergebnis (Abb. 1) ersichtlich ist. Die Annotation @DisplayName ist ebenfalls neu und bietet die Möglichkeit, Testfälle mit einem sprechenden Anzeigenamen zu benennen, der auch Leerzeichen, Sonderzeichen und sogar Emojis enthalten darf.

Ausgabe der Testergebnisse aus (Listing 4). (Abb. 1)

Ausgabe der Testergebnisse aus (Listing 4). (Abb. 1)

 

Neue Assertions

Die @Test Annotation ist eine der wenigen Annotationen aus JUnit 4, die ihren Namen behalten hat. Aber es gibt keine optionalen Argumente mehr, um erwartete Exceptions oder einen Timeout-Zeitraum anzugeben. Für ersteres gibt es mit JUnit 5 eine explizite assertThrows Methode, die in (Listing 4) in der Methode EmptyOptionalTest.get() verwendet wird. Als ersten Parameter gibt man die erwartete Exception-Klasse mit, als zweiten einen Lambda-Ausdruck oder wie in (Listing 4) eine Referenz auf die Methode, die potenziell eine Exception schmeißt. Die assertThrows Methode liefert die geworfene Exception zurück, sodass bei Bedarf weitere Prüfungen auf die Inhalte oder Attribute der Exception durchgeführt werden können.

Die bekannten Assertion-Methoden wie assertEqualsassertTrue, assertFalse und assertNull stehen auch unter der neuen API zur Verfügung, aber es wurden einige Verbesserungen implementiert. Die optionalen Meldungen bei Fehlschlag einer Assertion sind jetzt immer der letzte Parameter, wodurch die Aufrufe mit oder ohne Meldung einheitlicher sind und der Wechsel von einer zur anderen Variante einfacher ist. Die Meldungen können auch lazy über einen Lambda-Ausdruck erstellt werden, falls die Erzeugung der Meldung eine ressourcenintensive Operation ist.
Ein nützliches Feature, um die Anzahl Zyklen bei der Erstellung und Ausführung der Tests zu verringern und die Fehleranalyse bei fehlgeschlagenen Tests zu erleichtern, ist die neue assertAll Methode. Ein guter Anwendungsfall ist die in (Listing 5) dargestellte Überprüfung mehrerer Attribute einer Klasse. Die zusammenhängenden assert Statements werden gemeinsam geprüft und beim Fehlschlag werden alle fehlerhaften Ergebnisse ausgegeben und nicht nur die erste Abweichung. Der einzige Mehraufwand zur Einzelausführung ist die Notwendigkeit, die assert Statements in einen parameterlosen Lambda-Ausdruck
zu verpacken.

Listing 5
Autor autor = new Autor(„Tobias“, „Voß“);
assertAll(„autor“,
      () -> assertEquals(„Tobi“, autor.getVorname()),
      () -> assertEquals(„Voss“, autor.getNachname())
);

// Ergebnis der Testausführung
org.opentest4j.MultipleFailuresError: autor (2 failures)
      expected: <Tobi> but was: <Tobias>
      expected: <Voss> but was: <Voß>

Dynamische Test-Methoden

Ein schöner Anwendungsfall für Lambda-Ausdrücke ist die Möglichkeit, mit JUnit 5 dynamisch Testfälle zur Laufzeit zu erzeugen, z.B. auf Basis einer Menge von Eingabeparametern. Dadurch lässt sich eine Menge Boilerplate-Code in den Testklassen vermeiden. Eine redundante Ausimplementierung von Tests für die verschiedenen Repräsentanzwerte und Grenzwerte im Rahmen der Äquivalenzklassenbildung und Grenzwertanalyse kann dadurch vermieden werden.

Listing 6
@TestFactory
Stream<DynamicTest> testZuGering() {
   return Stream.of(-50, 0)
      .map(i -> dynamicTest(„Wert=“ + i,
         () -> assertFalse(geldautomat.auszahlen(i))));
}

@TestFactory
Stream<DynamicTest> testGueltig() {
   return Stream.of(10, 50, 100, 200, 5, 500)
      .map(i -> dynamicTest(„Wert=“ + i,
         () -> assertTrue(geldautomat.auszahlen(i))));
}

@TestFactory
Stream<DynamicTest> testUngueltig() {
   return Stream.of(42, 421, 1, 499)
      .map(i -> dynamicTest(„Wert=“ + i,
         () -> assertFalse(geldautomat.auszahlen(i))));
}

@TestFactory
Stream<DynamicTest> testZuHoch() {
   return Stream.of(600, 1000, 501, 505)
      .map(i -> dynamicTest(„Wert=“ + i,
         () -> assertFalse(geldautomat.auszahlen(i))));
}

Das (Listing 6) illustriert dieses Prinzip am Beispiel der Auszahlung eines Geldautomaten, dessen Software getestet werden soll. Die Testfälle sind anhand der vier in (Tabelle 1) dargestellten Äquivalenzklassen aufgebaut mit jeweils mehreren Repräsentanz- und Grenzwerten, für die das gleiche Verhalten erwartet wird. Für jede Äquivalenzklasse wird auf Basis eines Streams der Eingabewerte ein Stream von Testfällen erzeugt, indem jeder Eingabewert auf eine Instanz der Klasse DynamicTest gemappt wird. Die wird über die Factory-Methode dynamicTest erzeugt, der ein Anzeigename und ein Lambda-Ausdruck zur Überprüfung der Testbedingungen mitgegeben wird. Die Testmethoden müssen mit @TestFactory annotiert werden und eine Collection, einen Stream, eine Iterable oder einen Iterator von DynamicTest zurückliefern, damit JUnit sie ausführt.

 

Äquivalenzklassen für das Beispiel in (Listing 6). (Tabelle 1)
ÄquivalenzklasseBeschreibungRepräsentanzwerteGrenzwerte
Zu geringe Beträgekleiner gleich 0-500
Gültige Beträgegrößer 0, kleiner gleich 500 und Vielfaches von 510, 50, 100, 2005, 500
Ungültige Beträgegrößer 0, kleiner gleich 500, aber kein Vielfaches von 542, 4211, 499
Zu große Beträgegrößer 500600, 1000501, 505

 

In JUnit 4 gab es zwar auch schon eine ähnliche Möglichkeit über den Parameterized Testrunner, dieser war aber beschränkt darauf, alle mit @Test annotierten Methoden für alle definierten Parameter auszuführen. Die dynamischen Testfälle sind deutlich flexibler und mächtiger und haben das Potenzial als Killer-Feature die Verbreitung von JUnit 5 deutlich zu beschleunigen. Gleichzeitig wird deutlich, welche Ausdruckskraft hinter den mit Java 8 eingeführten Lambda-Ausdrücken steckt und welche Möglichkeiten sich durch die konsequente Ausnutzung eröffnen.

Die neue JUnit-5-Architektur

Bisher hatte JUnit eine monolithische Struktur und genau eine JAR-Datei, die als Abhängigkeit zu nutzenden Projekten hinzugefügt wurde. Im Rahmen der Arbeiten an JUnit 5 haben die Entwickler sich auch der Frage gewidmet, welche Aufgaben ein Test-Framework wie JUnit erfüllen muss:

  1. Es muss Entwicklern ermöglichen, Testfälle zu schreiben.
  2. Es muss IDEs und Build-Werkzeugen ermöglichen, Testfälle auszuführen.

Diese beiden Aufgaben sind sehr unterschiedlich und gemäß dem Prinzip des Separation-of-Concerns wurde die in (Abb. 2) dargestellte modulare Architektur für JUnit 5 entwickelt, die beide Aspekte trennt in „JUnit als Tool“ (JUnit-Jupiter) und „JUnit als Plattform“ (JUnit-Platform). Daneben stand die wichtige Frage im Raum, wie die Migration auf JUnit 5 angesichts der engen Integration von JUnit z.B. in IDEs vereinfacht werden kann. Mit dem dritten Modul JUnit-Vintage steht ein Adapter zur Verfügung, um in Version 4 geschriebene Testfälle mit JUnit 5 auszuführen, sodass Tools zukünftig nicht beide Versionen parallel unterstützen müssen.

Modulare Architektur von JUnit 5. (Abb. 2)

Modulare Architektur von JUnit 5. (Abb. 2)

 

In (Listing 2) sind bereits drei der neuen Artefakte für den ersten Testfall verwendet worden. Die JUnit-Jupiter-API stellt die API bereit, gegen die Testklassen geschrieben werden, also insbesondere die Annotationen und Assertions. Der JUnit-Platform-Runner wird als Teil der Plattform benötigt, damit in Version 5 geschriebene Testfälle mit Version 4 ausgeführt werden können und damit auch z.B. durch IDEs, die Version 5 noch nicht unterstützen. Und die JUnit-Jupiter-Engine implementiert als Teil von Jupiter die Schnittstelle von JUnit-Jupiter-Engine, um die in JUnit 5 geschriebenen Testfälle auf der Plattform auszuführen. Der JUnit-Platform-Launcher wird nicht für die Entwicklung von Testfällen benötigt, sondern nur von IDEs oder Build-Werkzeugen, um JUnit-Testfälle ausführen zu können.

Im Parallelbetrieb

Die wenigsten Projekte starten auf der grünen Wiese und können komplett auf JUnit 5 setzen. Auch eine Migration aller Testfälle von Version 4 auf 5 wird bei Betrachtung des Kosten-Nutzen-Verhältnisses für die wenigsten Anwender in Frage kommen. Deswegen ist der Parallelbetrieb beider Versionen in einem Projekt notwendig.

Dafür gibt es zwei Möglichkeiten. JUnit Vintage als Adapter, um in Version 4 geschriebene Testfälle mit JUnit 5 auszuführen, wurde bereits im letzten Abschnitt zur Architektur erläutert. Die zweite Variante wurde zu Anfang des Artikels im ersten Testfall benutzt. Über den in JUnit 5 enthaltenen Testrunner JUnitPlatform lassen sich JUnit-5-Testfälle mit IDEs oder Build-Werkzeugen wie Maven oder Gradle ausführen, ohne dass diese Version 5 explizit unterstützen müssen. Das Maven-Goal maven test führt auch alle Testklassen aus, die mit @RunWith(JUnitPlatform.class) annotiert sind, wenn die in (Listing 2) aufgeführten Artefakte als Abhängigkeiten mit <scope>test</scope> in der pom.xml hinterlegt sind. Falls man im Maven-Report die üblichen technischen Klassen-/Methodennamen statt der mit @DisplayName definierten Anzeigenamen ausgegeben haben möchte, kann man die Annotation @UseTechnicalNames verwenden.

Zur Integration in bestehende Test-Suites liefert JUnit 5 die Annotation SelectPackages mit, die in (Listing 7) benutzt wird, um eine Suite aller Testklassen im Package de.viadee.junit5 zu bilden.

Listing 7
@RunWith(JUnitPlatform.class)
@SelectPackages({„de.viadee.junit5“})
public class JUnit5Suite {
}

Das neue Erweiterungsmodell

Primärer Nutzerkreis für das neue Erweiterungsmodell sind Entwickler von Dritt-Bibliotheken oder Frameworks, die sich in JUnit integrieren oder JUnit erweitern. Aber auch Anwendungsentwickler haben die Möglichkeit, mit eigenen Erweiterungen den Boilerplate-Code zu reduzieren und die Lesbarkeit und Wartbarkeit ihrer Testfälle zu erhöhen.

Das Erweiterungsmodell bietet Erweiterungspunkte, um sich in beliebige Stellen des JUnit-Lebenszyklus einzuklinken. Zur Implementierung von Erweiterungen gibt es für jeden Erweiterungspunkt ein Interface wie z.B. BeforeEachCallback oder BeforeAllCallback entsprechend der mit @BeforeEach und @BeforeAll annotierten Methoden. Besonders nützlich sind die Erweiterungspunkte BeforeTestExecutionCallback und AfterTestExecutionCallback, um sich vor bzw. nach der Ausführung der einzelnen Testfälle einzuklinken. Damit lässt sich z.B. die Dauer einzelner Testmethoden für die Auswertung von Lasttests messen.

Bisher durften Testmethoden keine Parameter besitzen. Mit JUnit 5 ist diese Einschränkung aufgehoben und es gibt die Erweiterung ParameterResolver, um die Parameter mit Werten zu füllen. JUnit 5 liefert zwei Standarderweiterungen mit, um Parameter in eine Methode zu injizieren. Parameter vom Typ TestInfo liefern Metadaten über die Testmethode oder –klasse. Ein Parameter vom Typ TestReporter ermöglich die Eintragung individueller Informationen in den Standard-JUnit-Report. Ein weiterer Anwendungsfall ist das Beispiel der MockitoExtension, um Mockito-Mock-Objekte als Parameter für Testmethoden zu
verwenden.

Erweiterungen können auch mehrere Erweiterungspunkte implementieren. Das macht sich z.B. die SpringExtension zunutze, um das Spring TestContext Framework in das JUnit-5-Programmiermodell zu integrieren.

Fazit:

Es ist positiv überraschend, wieviel Potenzial auch nach 20 Jahren in einem neuen Release von JUnit steckt. Die neue Version ist sehr gelungen und bietet einige interessante Features durch die konsequente Nutzung von Lambda-Ausdrücken. Angesichts von zwei Varianten für den Parallelbetrieb mit der Vorversion ist eine sanfte Migration auch bei bestehenden Projekten möglich. Die modulare Architektur verspricht eine gute Wartbarkeit für die Zukunft. Und mit dem Erweiterungsmodell wird die Integration anderer Testwerkzeuge verbessert und die Nutzung von JUnit als Plattform vereinfacht.

Tobias Voß

Tobias Voß ist Software-Architekt und Projektleiter bei der viadee IT-Unternehmensberatung. Als Berater begleitet er Kunden im Versicherungs- und Bankenumfeld bei der Umsetzung von individuellen Softwaresystemen. Das beinhaltet sowohl Java-/Spring-basierte Anwendungen als auch Mainframe-Anwendungen.


(Visited 81 times, 1 visits today)

Leave a Reply