Migration JUnit 4 zu JUnit 5 (mit Bezug auf Spring Boot)

  • von

Michael Wellner, Softwareentwickler bei sidion

JUnit 5 und Spring Boot

Wie oben beschrieben, verwendet Spring Boot mit seiner spring-boot-starter-test Dependency seit der Version 2.2.0 JUnit 5. Aus org.junit wird org.junit.jupiter.api. Damit man aber nicht sofort mit den Breaking Changes konfrontiert wird, gibt es als default noch die junit-vintage-engine, welche es ermöglicht, alte JUnit 4 Tests weiterhin laufen zu lassen. Möchte man allerdings eine aktuellere Version von Spring Boot verwenden (zum Zeitpunkt des des Schreibens ist Spring Boot 2.5.3 die aktuelle Version) ist die Vintage Engine nicht mehr mit an Board. Diese wurde ab 2.4.0 entfernt, sie kann aber jederzeit als separate Dependency hinzugefügt werden. Was muss man nun aber bei der Migration von JUnit 4 auf JUnit 5 alles beachten und wie geht man vor?

Migration

Unterstützung IntelliJ

In IntelliJ gibt es mehrere Möglichkeiten, sich unterstützen zu lassen.

  • Einerseits kann man sich eine zusätzliche Inspection anschalten namens JUnit 4 test can be JUnit 5, damit werden Tests markiert, welche ohne Probleme JUnit 5 Tests sein können (außerdem kann man sich hier auch noch ganz andere Inspections dazu schalten, die ich empfehlen kann - z. B. wenn man keine Assertion in einem Test hat - das mag Sonar nämlich auch nicht).

  • Wurden nun Tests über die Inspection markiert, kann man hier einfach mittels Migrate to JUnit 5 IntelliJ die Arbeit übernehmen lassen.

Manuelles Vorgehen

Bei unterstützter Migration mittels IntelliJ empfehle ich auf jeden Fall dann das git-Diff anzuschauen, um die Unterschiede zu erkennen. Die IntelliJ Migration kann aber nicht alles abfangen, bei einigen Dingen ist auch manuelle Arbeit notwendig. Meine Erfahrungen habe ich hier notiert - außerdem auch die Code-Stellen, die man 1:1 ersetzen kann (das was dann auch IntelliJ für einen machen kann).

Folgende Imports können 1:1 im Code ersetzt werden

Achtung: Bei statischen Methoden muss es nicht zwangsläufig sein, dass man das über Imports machen kann, falls man mal keine statischen Imports in einer Klasse hat.

  • import org.junit.Test; mit import org.junit.jupiter.api.Test;
  • import org.junit.Before; mit import org.junit.jupiter.api.BeforeEach;
  • import org.junit.After; mit import org.junit.jupiter.api.AfterEach;
  • import org.junit.BeforeClass; mit import org.junit.jupiter.api.BeforeAll;
  • import org.junit.AfterClass; mit import org.junit.jupiter.api.AfterAll;
  • import org.junit.runner.RunWith; mit import org.junit.jupiter.api.extension.ExtendWith;
  • import org.mockito.junit.MockitoJUnitRunner; mit import org.mockito.junit.jupiter.MockitoExtension;
  • import org.springframework.test.context.junit4.SpringRunner;

mit

  • import org.springframework.test.context.junit.jupiter.SpringExtension;
  • import static org.junit.Assert. mit import static org.junit.jupiter.api.Assertions.
  • import org.junit.Ignore; mit import org.junit.jupiter.api.Disabled;

Folgende Annotations können 1:1 im Code ersetzt werden

  • @Before mit @BeforeEach
  • @After mit @AfterEach
  • @BeforeClass mit @BeforeAll
  • @AfterClass mit @BAfterAll
  • @RunWith(MockitoJUnitRunner.class) mit @ExtendWith(MockitoExtension.class)
  • @RunWith(SpringRunner.class) mit @ExtendWith(SpringExtension.class)
  • @Ignore mit @Disabled // wobei man diese Annotation eigentlich vermeiden sollte ;)

das "public" kann auch überall weg

Achtung: je nachdem welche Einrückung der Code hat, muss das hier angepasst werden vor dem "Find/Replace"

@Test
    public void

ersetzen mit

@Test
    void
  • beim nächsten Punkt gehe ich bein "Find/Replace" davon aus, dass @Before schon mit @BeforeEach ersetzt wurde
@BeforeEach
    public void

ersetzen mit

@BeforeEach
    void

Test die Fehler erwarten

  • hier muss folgendes mit assertThrows() ersetzt werden (Beispiel):
@Test(expected = IllegalArgumentException.class)
public void testConvertToEntityAttributeThrowsExceptionEmptyString() {
    uut.convertToEntityAttribute("");
}

ersetzen mit

@Test
public void testConvertToEntityAttributeThrowsExceptionEmptyString() {
    assertThrows(IllegalArgumentException.class, () -> uut.convertToEntityAttribute(""));
}

Parametrized Tests

  • Diese müssen auch umgebaut werden
  • @RunWith(Parameterized.class) als Annotation auf der Klasse entfernen - und durch nichts ersetzen
  • @Parameters als Annotation auf der Methode, welche die Parameter liefert, ebenfalls entfernen - und durch nichts ersetzen
  • Da es nicht so einfach ist hier pauschal irgendwelche Dinge zu ersetzen, hier ein Beispiel Code:
// erwartet wird ein Stream mit Arguments
static Stream<Arguments> getArgs() {
    return Stream.of(
        Arguments.of(5, 5, 10),
        Arguments.of(1, 1, 2)
    );
}

@ParameterizedTest(name = "{displayName} - [{index}] {argumentsWithNames}")
@MethodSource("getArgs")
// hier muss dann bei den Methodenparameter die Typisierung erfolgen
void test_addition_withParameters(int val1, int val2, int result) {
    int res = val1 + val2; // hier eigentlicher test, methodenaufruf, etc.
    assertThat(res).isEqualTo(result);
}

Spring ContextConfiguration

  • Bei Spring kann man Tests andere Klassen im Kontext bereitstellen. Für JUnit 5 kann man diese beiden Annotations
@ExtendWith(SpringExtension.class)
@ContextConfiguration(classes = {SomeConfig.class, SpecialMapper.class})

mit folgender Annotation ersetzen

@SpringJUnitConfig(classes = {SomeConfig.class, SpecialMapper.class})

UnnecessaryStubbingException

Mockito hat als default seit Version 2.x ein "strict stubbing"

  • Dies ist eigentlich gedacht, um auf unnötige when(...).thenReturn(...) mit einem Fehler hinzuweisen.
  • Am besten man schaut sich die Tests genauer und strukturiert sie um.
  • Wenn man dennoch ein paar Mocks an einer zentralen Stelle behalten will (z.B. im @BeforeEach), kann man einfach folgende Annotation auf der Klasse hinzufügen @MockitoSettings(strictness = Strictness.LENIENT).

Temporary Test Folder

  • Um ein temporäres Verzeichnis zu bekommen einfach folgendes
@Rule
public final TemporaryFolder folder = new TemporaryFolder();

ersetzen mit

@TempDir
Path directory; // darf nicht private sein!

Surefire Plugin

Falls das Projekt bisher kein Surefire Plugin verwendet hat, dann muss man das jetzt mit JUnit 5 auf jeden Fall einbauen.

Dependency Exclusions

Hat man alle Schritte gemacht und die offensichtlichen JUnit 4 bzw. die Vintage Engine Dependency entfernt und man findet doch noch eine JUnit 4 JAR im Classpath, dann muss man sich mittels mvn dependency:tree -DoutputFile=dep-tree.txt auf die Suche machen, welche andere Library JUnit 4 als Abhängigkeit mit bringt.

  • Das Entfernen der Abhängigkeit kann man dann folgendermaßen erreichen:
<dependency>
    <groupId>org.springframework.batch</groupId>
    <artifactId>spring-batch-test</artifactId>
    <scope>test</scope>
    <exclusions>
        <exclusion>
            <groupId>junit</groupId>
            <artifactId>junit</artifactId>
        </exclusion>
    </exclusions>
</dependency>

Persönliches Fazit

JUnit 5 bringt schon einige Vereinfachungen mit, dass z.B. überall das "public" weg fällt oder, dass es neue "Extensions Modell". Außerdem finde ich, gehört die Unterstützung von Java 8 zum heutigen Zeitpunkt schon zu den Mindestanforderungen (ich weiß, eigentlich kann man nun Java 8 auch schon als "legacy" betrachten). Zusätzlich machen noch so Themen wie Testcontainer und eigene Extensions, etc. die Verwendung von JUnit 5 interessanter. Aus diesen Gründen sollte eine Migration von JUnit 4 auf JUnit 5 spätestens jetzt passieren, falls noch nicht geschehen. Mit IntelliJ hat man ein Tool, welches einem bei den Basics unterstützt. Wenn man sich jedoch ein bisschen mit JUnit 5 auseinandersetzen möchte/sollte, rate ich dazu, die Änderungen mindestens an ein paar Klassen manuell durchzuführen, um ein Gefühl dafür zu bekommen. Bei komplizierteren Konstellationen kommt man um manuelle Änderungen ohnehin nicht herum.

Zurück