JUnit 5 – Des Kaisers neue Kleider

Keine Kommentare

JUnit 5 ist im September 2017 in der ersten stabilen Version erschienen. In diesem Post möchte ich Euch die wichtigsten neuen Features vorstellen. Dabei gehe ich davon aus, dass der geneigte Leser mit JUnit 4 halbwegs vertraut ist und Vergleiche dann selber ziehen kann.

JUnit 5 im Überblick

Unser allseits beliebtes Unit-Testframework wurde komplett überarbeitet, unterstützt nun (endlich) Java 8 und hat ein neues API. Im Wesentlichen besteht JUnit 5 aus drei Komponenten:

JUnit5 Overview

Die JUnit Platform ist eine offene, generische Ablaufumgebung zur Ausführung und Protokollierung von Tests durch sog. Engines. Drittanbieter haben die Möglichkeit, eigene Engines zu implementieren. Als Beispiel sei hier etwa jqwik genannt.

In der JUnit Jupiter Engine steckt der eigentliche Kern von JUnit 5. Mit der JUnit Vintage Engine lassen sich bestehende JUnit-4-Tests auf der neuen Plattform ausführen. Im Folgenden möchte ich mich auf die neue Jupiter Engine konzentrieren.

Jupiter Engine

Jede der drei o.g. Komponenten materialisiert sich in mehreren JAR-Artefakten. Um das neue API zum Schreiben von Tests nutzen zu können, benötigt man lediglich diese Abhängigkeit:

...
<properties>
  <junit.platform.version>1.0.0</junit.platform.version>
  <junit.jupiter.version>5.0.0</junit.jupiter.version>
</properties>
<dependency>
  <groupId>org.junit.jupiter</groupId>
  <artifactId>junit-jupiter-api</artifactId>
  <version>${junit.jupiter.version}</version>
  <scope>test</scope>
</dependency>
...

Ein erster Test sieht dann vielleicht so aus:

import static org.junit.jupiter.api.Assertions.assertTrue;
 
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInfo;
 
public class SimpleTest {
 
	@BeforeEach void setUp(TestInfo ti) {
		System.out.println(ti.getDisplayName());
	}
 
	@Test 
	@DisplayName(";-)")
	@Tag("wichtig") 
	@Tag("mathematisch")
	void foo(TestInfo ti) {
		assertTrue(3 == 2+1, "Addition kaputt" );
		System.out.println("Tags: " + ti.getTags());
	}
}

Das API befindet sich im Paket org.unit.jupiter.api. So ist eine Koexistenz mit dem alten JUnit-4-API möglich.

Die Annotation @Test kennzeichnet wie gewohnt einzelne Tests, deren vom Methodennamen abweichende Anzeigenamen mit @DisplayName angegeben werden können.

Zur Kategorisierung einzelner Tests werden ein oder mehrere @Tag/s vergeben. Beim Ausführen der Tests können dann bestimmte Tags bzw. Tag-Gruppen inkludiert bzw. exkludiert werden. Um die Ausführung einzelner oder aller Tests komplett zu unterbinden, kann @Disabled auf Methoden- bzw. Klassen-Level annotiert werden.

Der Lebenszyklus wird durch Annotationen mit neuen Namen gesteuert, die aber im Wesentlichen die gleiche Semantik haben wie zuvor:

@BeforeAllWird 1x vor dem ersten Test ausgeführt
@BeforeEachAusführung vor jedem Test
@AfterEachAusführung nach jedem Test
@AfterAllWird 1x nach dem letzten Test ausgeführt

Methoden-Signaturen von Test-Methoden

Kommen wir nach so trivialen Änderungen wie der Verwendung leicht anders benannter Annotation zu spannenderen Themen.

Endlich ist es soweit, dass Test-Methoden nicht mehr public sein müssen! Das erspart ggf. etwas Tipp-Arbeit oder auch nicht, wenn man in der IDE seiner Wahl mit Code Templates o.ä. gearbeitet hat.

Wesentlich interessanter sind jedoch die Möglichkeiten, die sich daraus ergeben, dass Test-Methoden (und auch Test-Konstruktoren) ab sofort nicht mehr parameterlos sein müssen. Lebenszyklus und Test-Methoden bekommen durch sog. ParameterResolver Parameter injiziert. In unserem Beispiel ist dies ein Parameter vom Typ TestInfo, über den Meta-Daten zu den Tests abgefragt werden können. Eigene ParameterResolver können aber auch implementiert und registriert werden. Dies werden wir weiter unten bei der Mockito-Extension erklären.

Data Driven Tests mit JUnit 5

Durch die Parameter-Injektion sind datengetriebene Tests nun wesentlich einfacher zu formulieren, was aus meiner Sicht eine der großen Stärken von JUnit 5 ist. Ein trivialer Test kann so aussehen:

import static org.junit.jupiter.api.Assertions.assertNotNull;
 
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
 
public class DataDrivenParameterTest {
 
	@ParameterizedTest(name = "{index}: {0}")
	@ValueSource(strings = { "foo", "bar", "baz" })
	void strings(String s) {
		assertNotNull(s);
	}
}

Mit @ParameterizedTest wird festgelegt, dass es sich um einen parametrisierten Test handelt, dessen Namen man mit Platzhaltern ggf. auch selber festlegen kann. Durch eine weitere Annotation wird die Quelle der Parameter festgelegt, in unserem Fall ein Array von Strings. Pro Array-Eintrag ergibt sich dann ein Test.

Als Quelle kann u.a. auch eine CSV-Datei dienen. Mit einer Annotation findet pro Zeile der CSV-Datei eine Test-Ausführung statt. Die Werte aller Spalten werden in separate Parameter injiziert:

	@ParameterizedTest(name = "{index}: {0} + {1} = {2}")
	@CsvFileSource(resources = "/addition.csv")
	void addition(int a, int b, int c) {
		assertTrue(a + b == c);
	}

Es gibt neben den hier erwähnten noch eine Reihe weiterer Sources. Sollten diese nicht ausreichen, kann man auch leicht eigene Parameter-Spender bauen, indem man das Interface ArgumentsProvider implementiert.

Assertions

Alle Assertions sind als statische Methoden in org.junit.jupiter.api.Assertions verfügbar. Einige neue sind hinzugekommen, und fast alle unterstützen Lambdas aus Java 8. Exemplarisch möchte ich die folgenden beiden vorstellen:

@Test
void expectException() {
	// gegeben sei
	String n = null;
 
	// dann
	assertThrows(NullPointerException.class,
			// wenn
			() -> n.toString()
	);
}

Die Assertion assertThrows erhält als Parameter die zu erwartende Exception und als Lamdba-Ausdruck den auszuführenden Code. Damit wird die ExpectedException-Rule aus JUnit 4 überflüssig. So überflüssig, dass es sie gar nicht mehr gibt.

Prüfungen auf Timeouts lassen sich auch sehr schlank formulieren:

@Test
void timeout() {
	assertTimeout(Duration.ofSeconds(1), ()-> Thread.sleep(2000L));
}

Daneben gibt es auch noch weitere Assertions, eine komplette Auflistung spare ich mir an dieser Stelle.

Es fällt auf, dass das Jupiter-API keine Abhängigkeit zu den Hamcrest-Matchern hat und daher auch keine assertThat()-Methode mehr anbietet. Mit dem statischen Import org.hamcrest.MatcherAssert lässt sich Hamcrest allerdings wie gewohnt verwenden:

import static org.hamcrest.MatcherAssert.assertThat;
...
@Test
void hamcrestMatcher() {
	assertThat( 1 + 2, is(3) );
}

Dynamische Test-Erzeugung

Testfälle lassen sich über Factory-Methoden nun auch zur Laufzeit dynamisch erzeugen. Ein Beispiel, das eine zufällige Anzahl an Testfällen erzeugt, kann so aussehen:

import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.DynamicTest.dynamicTest;
...
@TestFactory
Stream<DynamicTest> dynamicTests() {
	return IntStream
		.range( 1, 1 + new Random().nextInt(10) )
		.mapToObj( i -> dynamicTest("rndTest"+i, () -> assertTrue(i>0) ) );
}

Methoden, die mit @TestFactory annotiert sind und einen Stream, Collection, Liste etc. von DynamicTests zurückgeben, dienen als Factories für dynamische Tests. Dieses Beispiel erzeugt ein bis zehn triviale Tests, deren Namen dynamisch vergeben werden. Bei der Testausführung in der IDE wird das dann so dargestellt:

JUnit5 Dynamische Tests

Die Möglichkeit der dynamischen Testerzeugung kann z.B. auch dazu genutzt werden, selbst datengetriebene Tests zu implementieren. Ansonsten fallen mir spontan nicht allzu viele Use Cases ein.

Extension-Modell

Ausgeführt werden die Tests immer von der jeweiligen Engine, das Konzept der Runner gibt es nicht mehr. Ebenso gibt es keine @Rules mehr.

Allerdings gibt es eine Verallgemeinerung dieser Konzepte, die sogenannte Extension, mit der man sich in den Lebenszyklus der Testausführung einmischen kann, eigene ParameterResolver registriert etc. Ein Testfall kann mehrere solcher Extensions haben. Zur Registrierung von Extensions wird die Annotation @ExtendWith verwendet. Extension ist ein Interface, das alle möglichen Extensions markiert:

JUnit5 Extension

Mockito

Als konkretes Beispiel für eine solche Extension möchte ich die (bislang noch experimentelle) MockitoExtension anführen. Damit wird es noch einfacher als bisher mit Mocks zu arbeiten:

@ExtendWith(MockitoExtension.class)
public class VnrGeneratorTest {
 
	private VnrGenerator generator; /** Unit under test. */
 
	@Test 
	void vnr_erzeugung(@Mock VnrDao dao) {		
		// gegeben sei
		generator = new VnrGenerator(dao);
		LocalDate stichtag = LocalDate.of(2017, Month.FEBRUARY, 17);
		String sachgebiet = "LN";
		String name = "Fasel";
		given(dao.naechsteFreieLaufnummer(sachgebiet, name)).willReturn(1);
 
		// wenn
		String vnr = generator.neueVnr(sachgebiet, stichtag, name);
 
		// dann
		assertThat(vnr, is("LN-2017-02-17-F001"));
		then(dao).should().naechsteFreieLaufnummer(sachgebiet, name);
		then(dao).shouldHaveNoMoreInteractions();
	}
}

Getestet wird eine Fach-Komponente VnrGenerator, die von einem VnrDao abhängt. Durch die MockitoExtension wird einerseits Mockito initialisiert, andererseits dient diese Extension auch als ParameterResolver, so dass man der jeweiligen Test-Methode genau nur die Mocks übergeben kann, die von dieser Methode auch verwendet werden. Gerade wenn man viele Mocks verwendet, wird der Code mit JUnit 5 dann übersichtlicher.

JUnit 5 im Build-Prozess

Um JUnit 5 im Build-Prozess verwenden zu können, muss man (hier exemplarisch im Maven-Style) das Surefire-Plugin entsprechend konfigurieren:

...
<plugin>
  <groupId>org.apache.maven.plugins</groupId>
  <artifactId>maven-surefire-plugin</artifactId>
  <version>2.19.1</version>
  <dependencies>
    <dependency>
      <groupId>org.junit.platform</groupId>
      <artifactId>junit-platform-surefire-provider</artifactId>
      <version>${junit.platform.version}</version>
    </dependency>
    <dependency>
      <groupId>org.junit.jupiter</groupId>
      <artifactId>junit-jupiter-engine</artifactId>
      <version>${junit.jupiter.version}</version>
    </dependency>
  </dependencies>
</plugin>
...

Dadurch verwendet das Surefire-Plugin dann die JUnit Platform und die Jupiter Engine zur Ausführung der Tests.

Wenn man in seinem Projekt sowohl JUnit-4- als auch JUnit-5-Tests hat (was für bestehende Projekte sicher immer der Fall sein wird), fügt man noch die Dependency für die JUnit Vintage Engine hinzu, und die Plattform wählt dann für jeden Test automatisch die richtige Engine aus:

...
<dependency>
    <groupId>org.junit.vintage</groupId>
    <artifactId>junit-vintage-engine</artifactId>
    <version>4.12.0</version>
</dependency>
...

Es besteht wohl die Möglichkeit, dass die Vintage Engine nicht völlig abwärts kompatibel ist, insb. in Bezug auf selbstimplementierte Rules. Ich habe aber erst kürzlich ein Projekt mit knapp 7.000 JUnit-4-Tests auf JUnit 5 + Vintage Engine umgestellt und dabei keinerlei Probleme gehabt.

Fazit

Dieser Artikel ist als Überblick über die wesentlichen Neuerungen in JUnit 5 konzipiert. Das Thema an sich ist so umfangreich, dass ich bei Weitem nicht alles vorstellen konnte. Ich hoffe aber, dass ihr einen guten Gesamteindruck gewonnen habt. Wir werden in diesem Blog sicher noch den einen oder anderen Post sehen, der sich mit dem Thema JUnit 5 befasst und dabei weiter ins Detail geht, z.B. diese hier:

Die Beispiele aus diesem Artikel könnt ihr in meinem Git-Repo junit5-examples selber ausprobieren.

Tobias Trelle

Diplom-Mathematiker Tobias Trelle ist Senior IT Consultant bei der codecentric AG, Solingen. Er ist seit knapp 20 Jahren im IT-Business unterwegs und interessiert sich für Software-Architekturen und skalierbare Lösungen. Tobias hält Vorträge auf Konferenzen und Usergruppen und ist Autor des Buchs „MongoDB: Ein praktischer Einstieg“.

Share on FacebookGoogle+Share on LinkedInTweet about this on TwitterShare on RedditDigg thisShare on StumbleUpon

Kommentieren

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