JUnit5-Umstellung

05.10.2020

Seit Einführung von JUnit 5 ist schon einige Zeit ins Land gegangen. Dennoch nutzen viele Projekte noch JUnit 4. Die neue Version bringt jedoch diverse Neuerungen mit, deren Einsatz Tests deutlich verbessern können, so dass sich eine Umstellung lohnt.

Highlights

Freitextnamen für Tests

Mit @DisplayName können Tests nun frei definierbare Namen erhalten. Diese werden in der Entwicklungsumgebung (z.B. Intellij) im Testfenster entsprechend angezeigt, was die Lesbarkeit der Tests deutlich verbessert. Der Zusammenhang zu den fachlichen Anforderungen lässt sich so viel deutlicher ausdrücken.

Test Ausführung ohne @DisplayNameTest Ausführung mit @DisplayName

 

In Zusammenhang mit @Nested lässt sich das Ganze noch weiter optimieren, so dass sich sehr gut lesbare Testszenarien darstellen lassen.

Tagging und Filtern von Tests

Ein weiteres Feature in JUnit5 ist das Taggen von Tests, durch welche man die Möglichkeit hat, die Tests zu filtern und nur bestimmte Tests ausführen zu lassen.

Beispiel

@Test
@Tag("production")
public void emptyString() {
     result = stringCalc.add("");
     assertEquals(0, result);
}

Und so können die Tags inkludiert oder exkludiert werden:

<plugin>
    <artifactId>maven-surefire-plugin</artifactId>
    <version>${maven-surefire-plugin.version}</version>
    <configuration>
        <!-- Include tags -->
        <groups>production</groups>
        <!-- Exclude tags -->
        <excludedGroups>development</excludedGroups>
    </configuration>
</plugin>

Eine weitere Möglichkeit, Tests mit bestimmten Tags auszuführen, ist mit Intellij. Unter Run/Debug Configurations können Tags angegeben werden, um filtern zu können, welche Tests ausgeführt werden sollen. Es können auch mehrere Tags gleichzeitig angegeben werden („UnitTest | IntegrationsTest“).

Run und Debug Konfiguration für das Ausführen der Tests

Dynamische Tests

Ein weiteres Highlight sind parametrisierte Tests auf Methodenebene, deren Werte aus unterschiedlichen Quellen kommen können. Wird @ParameterizedTest an einen Test angefügt, kann er mehrmals mit unterschiedlichen Paramenter ausgeführt werden.

Beispiel

import static org.junit.jupiter.api.Assertions.assertEquals;

import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;

class StringCalculatorApplicationTests {

    StringCalculator stringCalc = new StringCalculator();
    int result;

    @BeforeEach
    void setUp(){
        result = 0;
    }

    @ParameterizedTest
    @ValueSource(strings = {"1", "2", "3"})
    @DisplayName("Die Addition mit einer Zahl ergibt sie selbst")
    void oneDigit(String numbers) {
        result = stringCalc.add(numbers);
        assertEquals(Integer.valueOf(numbers), result);
    }

    @ParameterizedTest(name = "Die Zahlen {0} ergeben {1}")
    @CsvSource(value = {"10,3:13", "2,2:4", "3,2:5"}, delimiter = ':')
    void twoDigits(String input, String expected) {
        result = stringCalc.add(input);
        assertEquals(Integer.valueOf(expected), result);
    }
}

Ausgabe von parametrisierten Tests

JUnit führt die Methode oneDigit dreimal aus, wobei jeweils zuvor die mit BeforEach annotierten Methoden aufgerufen werden, und weist dem Übergabeparameter der Methode immer einen anderen Wert zu. Es ist auch möglich, eine CSV als Quelle direkt über der Methode oder als externe Datei (@CsvFileSource(resources = „/datei.csv“)) anzugeben, zusätzlich zu dem kann der Default Delimiter geändert und die Anzahl der übersprungenen Zeilen mit angegeben werden. So wird die CSV-Datei als Input und als erwartender Wert verwendet. Dazu kann man jedem Durchlauf einen Anzeigenamen mit den jeweiligen Parametern vergeben.

Jeder einzelne dieser Tests durchläuft dabei den normalen Lebenszyklus eines Tests, Setup- und Aufräummethoden werden also bei jedem erzeugten Testfall ausgeführt. Auch mit TestFactory lassen sich ebenfalls dynamisch Tests erzeugen. Allerdings gilt der Lebenszyklus hier für den gesamten Test und nicht für die einzelnen erzeugten Testfälle.

Änderungen

In JUnit5 hat sich einiges geändert darunter die Imports für die Annotationen für @Test, sie werden mit org.junit.jupiter.api. importiert, statt wie zuvor mit org.junit. Dazu hat sich die Schreibweise der Timeouts bei Tests verändert, mit assertTimeoutPreemptively(Duration.ofMillis(3000), …) wird der Timeout gesetzt, der Import org.junit.jupiter.api.Assertions.assertTimeoutPreemptively wird dazu benötigt.

Zusätzlich wurden einige Namen der Annotationen geändert.

@Before @BeforeEach
@BeforeClass @BeforeAll
@After @AfterEach
@AfterClass @AfterAll
@Ignore @Disabled
@RunWith wurde durch @ExtendWith ersetzt, es wird z.B.

@RunWith(SpringRunner.class) als Extension @ExtendWith(SpringExtension.class) ersetzt,

zusätzlich dazu können auch mehrere Extensions kommasepariert kombiniert oder auch untereinander geschrieben werden.

@Rule Durch @ExtendWith ersetzt (siehe Zusätzliches), hier muss geprüft werden, durch welche JUnit5-Extension die betreffende Rule abgelöst wird.

In Spezialfällen kann es sein, dass eine eigene Extension implementiert werden muss.

Eine Liste einiger Extensions wurde von der Github Commutiy zusammengestellt: Extensions Liste

Testklassen und Methoden müssen übrigens nicht mehr public sein, es genügt mittlerweile die Standardsichbarkeit. JUnit 5 bietet ebenfalls eine Parameter-Injection an, was Baeldung gut erklärt: Inject Parameters into JUnit. Unter anderem zeigt Baeldung dort auch, das es möglich ist, zwei unterschiedliche Extensions in einer Main-Test-Klasse einzubinden, was in JUnit4 nicht möglich war. Was sich ebenfalls geändert hat, ist die Ausgabe zusätzlicher Informationen über die Tests, früher wurde bei JUnit4 dies mit stdout oder stderr über @RunWith(JUnitPlatform.class) realisiert, dies sollte bei JUnit5 durch den TestReporter ersetzt werden.

Kleines Beispiel oder auch im JUnit5 Guide zufinden.

@ParameterizedTest(name = "Die Addition von {0} ergibt {1}")
@CsvSource(value = {"10,3:13", "2,2:4", "3,2:5"}, delimiter = ':')
void twoDigits(String input, String expected, TestReporter testReporter) {
    assertEquals(Integer.valueOf(expected), result);
    Map<String, String> values = new HashMap<>();
    values.put(input, expected);

    testReporter.publishEntry(values);

    result = stringCalc.add(input);
}

Es gibt auch einige weitere Neuerungen, die hier nicht aufgeführt sind. Im JUnit5 Guide unter Annotations gibt es eine Auflistung aller Annotationen.

Die Umstellung (mit Intellij)

Als erstes muss die junit.jupiter Dependency hinzugefügt werden, danach ersetzt man die JUnit 4 Dependency durch die junit.jupiter.vintage, damit die alten Tests noch lauffähig sind. Sie wird nach Abschluss der Migration wieder entfernt.

Intellij bietet eine automatische Migration. Damit diese funktioniert, muss zusätzlich zur Vintage-Dependency folgendes in Intellij eingestellt werden:

  • CTRL+ALT+SHIFT+H
  • Configure Inspections
  • Im Suchfeld nach JUnit suchen,
  • in der Liste die Checkbox JUnit 4 test can be JUnit 5 aktivieren

Inspection-Einstellung für JUnit5-Migration

  • mit 2x Shift taucht das Popup der allgemeine Suche auf
  • dort Migrate eingeben und wählt die Action aus

Migrate Suche

  • im darauf erscheinenden Drop-Down die JUnit Migration auswählen und mit Run bestätigen

Auswahl der JUnit4 zu JUnit5 Migration

  • In der Vorschau können die Änderungen vor Anwendung betrachtet werden

So gut die Migrations-Funktion auch ist, sie funktioniert nicht bei allen Klassen. Folgendes erforderte bei uns im Projekt Handarbeit:

  • Klassen mit @RunWith-Annotationen und JUnit-Rules (@Rule) mussten mit @ExtendWith durch die entsprechende JUnit5-Extension ersetzt werden
  • Nach diesem Schritt kann auf den Klassennamen mit Rechtsclick (ALT+Enter) die Migration für die Klasse ausgeführt werden

JUnit4 zu JUnit5 Migration an einer Klasse

  • SpringRunner → SpringExtension
  • MockitoJUnitRunner → MockitoExtension
    Durch die Umstellung von MockitoRunner auf MockitoExtension traten bei uns UnsupportedStubbingExceptions auf. Grund war ein anderes Strictness-Verhalten. Um die alte laxe Prüfung beizubehalten, kann entsprechend mit @MockitoSettings(strictness = Strictness.LENIENT) eingestellt werden. Dies ist allerdings nicht unbedingt zu empfehlen. Besser ist es, die Tests so anzupassen, dass tatsächlich nur noch die benötigten Stubs erstellt werden. Wenn dies nicht in allen Fällen einfach möglich ist, bietet sich zumindest als Zwischenlösung lenient().when(…) an, um gezielt bei bestimmten Stubs die strikte Prüfung zu deaktivieren.

Der Hintergrund ist der, dass dadurch nur eine Annotation für die Erweiterungen der Tests zuständig ist, nicht wie zuvor zwei Annotationen. Mit der Extension API wird versucht, dies zu vereinfachen.

Um JUnit5-Tests als Java-Code ausführen zu können, müssen ebenfalls Änderungen vorgenommen werden, diese werden in Runnig JUnit Tests genau beschrieben.

Besonderheiten bei Verwendung von AssertJ-Soft-Assertions

Wenn Soft-Assertions benutzt werden, muss bei der Umstellung auf JUnit5 darauf geachtet werden, sodass sie nicht mehr global deklariert und nicht mehr als @Rule gekennzeichnet werden. Denn in JUnit 5 müssen SoftAssertions mit @ExtendWith(SoftAssertionsExtension.class) geschrieben werden. Und zusätzlich muss die Soft-Assertion in der Methode, die sie benutzt, als Übergabeparameter angegeben werden. Dies hat den Vorteil, dass der Soft-Assertion-Mechanismus nicht mehr auf alle Methoden angewendet wird, sondern nur explizit auf Methoden, die sie benötigen. Dies erfordert mindestens AssertJ 3.13.0.

Vorher

class TestClass(){
    @Rule
    final JUnitSoftAssertions softly = new JUnitSoftAssertions();

    @Test
    void testExample(){
        softly.assertThat(cityTester.isCity("Hamburg")).isTrue();
        softly.assertThat(CityTester.isCity("Berlin")).isTrue();
        ...
    }
}

Nacher

@ExtendWith(SoftAssertionsExtension.class)
class TestClass(){

    @Test
    void testExample(final SoftAssertions softly){
        softly.assertThat(cityTester.isCity("Hamburg")).isTrue();
        softly.assertThat(cityTester.isCity("Berlin")).isTrue();
        ...
    }
}

Alternativ

class TestClass(){

    @ParameterizedTest
    @ValueSource(strings = {"Hamburg", "Berlin"})
    void testExample(String city){
        assertThat(cityTester.isCity(city)).isTrue();
    }
}

Dadurch sind hier Soft-Assertions überflüssig und können entfernt werden. Bei der Ausführung sieht man den Unterschied deutlich, denn sollte mal ein Test fehlschlagen, erkennt man dies bei den parametrisierten Tests auf den ersten Blick, was bei den SoftAssertions nicht der Fall ist.

Fehlgeschlagener Test mit Soft-Assertions
Fehlgeschlagener Test mit Parameter

Achtung: Parametrisierte Tests haben pro Ausführung einen vollständigen Test-Lebenszyklus, d.h. Setup- und Aufräummethoden werden für jede Ausführung aufgerufen, nicht nur für die gesamte Testmethode.

Abschluss

Da die Vintage-Dependency der Rückwärtskompatibilität zu JUnit 4 dient, kann und sollte diese nach Abschluss der Migration entfernt werden.

Mögliche Probleme

  • Wenn Maven die Tests nicht ausführt oder eine Exception bei der Ausführung der Tests geworfen wird, kann es sein, dass die Dependency junit-jupiter-api, junit-jupiter-engine benutzt wurde. Es genügt jedoch, die junit-jupiter einzubinden.
  • Ein weiteres Problem, welches auftreten kann, ist, dass die mockito-junit-jupiter (Version 3.3.3) Dependency mit ihrer mitgebrachten junit-jupiter-api (Version 5.4.2) das Ausführen der Tests verhindert, da die junit-jupiter (Version 5.6.2) Dependency mit ihrer mitgebrachten junit-junit-api (Version 5.6.2) nicht in die Dependency aufgenommen wird. Bei uns half es, bei der Mockito Dependency die junit-jupiter-api zu exkludieren.
Zurück zur Übersicht

Kommentar verfassen

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

*Pflichtfelder

*