JUnit 5: Die Neuerungen im Überblick

Vor etwa einem Jahr, im Sommer 2015, wurde auf der Crowdfunding-Plattform Indigogo eine Spendenkampagne für die Weiterentwicklung von JUnit gestartet. Ziel der JUnit Lambda getauften Aktion war es, das am weitesten verbreitete Unit-Testing-Framework für Java zu überarbeiten und fit für die Zukunft zu machen. Insbesondere sollte JUnit so erweitert werden, dass das Framework möglichst umfassend von den neuen Sprach-Features von Java 8 profitieren kann. Um das zu erreichen, war eine komplette Neukonzipierung des Kerns von JUnit notwendig.

Die Crowdfunding-Kampagne, an der sich auch die OIO GmbH beteiligt hat, war ein voller Erfolg, sodass die Entwicklungsarbeiten wie geplant beginnen konnten. Aus dem Projekt JUnit Lambda wurde JUnit 5. Heute, ein Jahr später, gibt es schon den zweiten Milestone Release, mit dem man die neuen Features ausprobieren kann. Es sind aber noch nicht alle geplanten Neuerungen vollständig umgesetzt. Dennoch hinterlässt dieses Release schon einen sehr guten Eindruck von der neuen Version des Testing-Frameworks. Weitere Milestones und ein erstes finales Release sind für Ende 2016/Anfang 2017 geplant.

Ausführen von JUnit 5 Tests

Für die Ausführung von Tests, die für JUnit 5 geschrieben wurden, gibt es inzwischen schon erste Toolunterstützung. Für Maven und Gradle gibt es entsprechend angepasste Plugins. Diese sind aber noch nicht final, sondern wurden provisorisch vom JUnit-Team erstellt.

Auf Seiten der Entwicklungsumgebungen bietet schon IntelliJ seit Version 2016.2 die Möglichkeit, JUnit 5 Tests auszuführen. Die Unterstützung von JUnit 5 steckt hier zwar noch in den Kinderschuhen, funktioniert aber schon ganz passabel. Für Eclipse wird noch an einer Unterstützung gearbeitet.

Wenn man gleich loslegen und die Neuerungen von JUnit 5 einmal selbst ausprobieren will, kann man sich von GitHub eines der vorkonfigurierten JUnit 5 Demoprojekte klonen. Mit diesen Projekten muss man sich nicht mehr selbst um die korrekte Einbindung von JUnit 5 in Maven oder Gradle kümmern, sondern kann die Beispieltests gleich mit den üblichen Kommandos der beiden Build-Tools ausführen.

Zu guter Letzt bietet JUnit 5 mit dem Console Launcher auch ein Kommandozeilenprogramm an, mit dem Tests auch ohne Verwendung eines Build Tools oder einer IDE direkt auf der Kommandozeile ausgeführt werden können.

Open Test Alliance

Neben der Modernisierung und Neukonzeption des beliebten Testing-Frameworks hat sich das JUnit 5 Team auch Gedanken darüber gemacht, wie man die vielen verschiedenen Java Frameworks zum Schreiben von Softwaretests auf eine gemeinsame Basis stellen kann. Zurzeit kocht im Ökosystem der Java Testing-Frameworks jeder sein eigenes Süppchen. Das heißt, es gibt kein einheitliches Konzept für die Ausführung von Tests, für das Reporting oder zur Behandlung von Ausnahmen. Die einzige gemeinsame Basis, auf der alle Testing-Frameworks aufsetzen, besteht aus der JDK Klasse java.lang.AssertionError.

Das JUnit Team hat daher mit der Open Test Alliance ein Projekt ins Leben gerufen, das alle Hersteller und Entwickler von Testing-Bibliotheken, IDEs und Build Tools an einen Tisch holen möchte, um eine einheitliche und gemeinsame Basis für Softwaretests zu entwickeln.

Mit JUnit Platform wird schon ein Fundament zur Verfügung gestellt, das von einem konkret verwendeten Testing-Framework abstrahiert. Die Idee hierbei ist, dass die Hersteller von IDEs und Build Tools nur gegen die API dieser einheitlichen Plattform entwickeln brauchen. Damit werden automatisch alle Testing-Frameworks (JUnit, TestNG, Spock, Spek, etc.) unterstützt, wenn diese ebenfalls auf diese Plattform aufsetzen.

Die Neuerungen im Überblick

Im Folgenden werden die neuen Features von JUnit 5 kurz vorgestellt. Diese werden im Detail im JUnit 5 User Guide beschrieben.

Einfache Tests

Einen Unit-Test schreibt man mit JUnit 5 ganz so, wie man es schon von den Vorgängerversionen her kennt: man schreibt eine Testmethode und annotiert diese mit der Annotation @Test.

@Test
void myFirstTest() {
  assertEquals(2, 1 + 1, "1 + 1 should equal 2");
}

Die Besonderheit von @Test ist, dass diese Annotation keine Parameter hat. Erwartete Ausnahmen (Parameter expected in JUnit 4) oder Timeouts (timeout) werden also auf eine andere Art und Weise behandelt.

Der Name eines Tests ergab sich bisher immer aus dem Namen der Testmethode (also myFirstTest im Beispiel). Dadurch kann das Benennen von komplexeren Testkonstellationen ziemlich umständlich werden (jeder ist sicherlich schon Testnamen begegnet, die mit allen enthaltenen Vor- und Nachbedingungen fast schon über die ganze Bildschirmbreite gehen). Mit der Annotation @DisplayName bietet JUnit 5 eine Alternative: Diese Annotation gibt einem Test einen Namen, der nicht an die Regeln für Java Identifier gebunden ist.

@Test
@DisplayName("Test that one plus one equals two.")
void myFirstTest() {
  assertEquals(2, 1 + 1, "1 + 1 should equal 2");
}

In der Übersicht über alle ausgeführten Tests wird dann dieser Display Name anstelle des Namens der Testmethode angezeigt.

JUnit-Testmethoden haben üblicherweise keine Parameter. Bei JUnit 5 ist das anders. Man kann sich von der Laufzeitumgebung bestimmte Werte injizieren lassen (siehe auch weiter unten Dependency Injection). Dazu gehört die Klasse TestInfo. Dies ist eine Datenklasse, die einige Metadaten zu dem gerade ausgeführten Test enthält, darunter das Testklassenobjekt oder der Display Name.

@Test
@DisplayName("my first test")
void someTest(TestInfo testInfo) {
    assertEquals("my first test", testInfo.getDisplayName());
}

Testkonfiguration

JUnit 5 enthält auch wieder Annotationen zum Spezifizieren von Setup-Methoden, die entweder einzelne Testmethoden oder eine ganze Testklasse konfigurieren. Diese Annotationen haben zur Vereinheitlichung andere Namen erhalten als die äquivalenten Annotationen aus der Vorgängerversion:

  • @BeforeEach: Für Setup-Methoden, die vor jedem Test aufgerufen werden (entspricht JUnit 4 @Before).
  • @AfterEach: Für Tear-Down-Methoden, die nach jedem Test aufgerufen werden (entspricht JUnit 4 @After).
  • @BeforeAll: Für statische Methoden, die einmal vor der Ausführung einer Testklasse aufgerufen werden (entspricht JUnit 4 @BeforeClass).
  • @AfterAll: Für statische Methoden, die einmal nach der Ausführung einer Testklasse aufgerufen werden (entspricht JUnit 4 @AfterClass).

Assertions und Assumptions

JUnits Assertions und Assumptions wurden um die Möglichkeit erweitert, Lambda-Ausdrücke als optionalen Parameter zu verwenden.

Assertions werden verwendet, um die Ausgabe oder das Ergebnis des getesteten Codes auf Korrektheit zu überprüfen. JUnit 5 bietet nun die Möglichkeit, den Fehlertext einer fehlgeschlagenen Assertion über einen Lambda-Ausdruck vorzugeben (als ein java.util.function.Supplier<String>). Die Supplier-Funktion wird erst dann ausgewertet, wenn die Assertion tatsächlich fehlschlägt. Bei komplexen Ausdrücken spart das Rechenzeit.

assertTrue(2 == 2, () -> "Wird erst ausgewertet, " +
                         "wenn Assertion fehlschlägt");

Die neu eingeführte Methode Assertions.assertAll() erlaubt die Gruppierung von Auswertungen:

assertAll("address",
         () -> assertEquals("John", address.getFirstName()),
         () -> assertEquals("Doe", address.getLastName())
);

Gruppierte Assertions werden immer erst vollständig ausgeführt, bevor ein eventuell aufgetretener Fehler ausgegeben wird. Damit sieht man gleich alle fehlschlagenden Assertions auf einen Blick, ohne dass der erste Fehler den Test sofort abbricht.

Schließlich lässt sich mithilfe von einem Lambda-Ausdruck und Assertions.expectThrows() ein Stück Code auf erwartete Exceptions prüfen, ohne dass man dazu einen try-catch-Block benötigt, oder die Testmethode gesondert annotiert werden müsste:

@Test
void exceptionTesting() {
    Throwable exception = 
      expectThrows(IllegalArgumentException.class, () -> {
        throw new IllegalArgumentException("a message");
      });
    assertEquals("a message", exception.getMessage());
}

Assumptions sind Annahmen, die man für einen Unit-Test treffen kann und die dazu führen, dass der Test abgebrochen wird, wenn die Annahme im Kontext der Testausführung nicht zutrifft. Abgebrochene Tests werden nicht als fehlgeschlagen interpretiert. Zum Beispiel kann man mit einer Assumption sicherstellen, dass ein Test nur in der Entwicklungsumgebung ausgeführt wird:

@Test
void testOnlyOnDeveloperWorkstation() {
    assumeTrue("DEV".equals(System.getenv("ENV")),
               () -> "Aborting test: not on developer workstation");
    // Rest des Tests
}

Auch hier kann die Nachricht, die bei einer nicht zutreffenden Assumption ausgegeben wird, mithilfe eines Lambda-Ausdrucks angegeben werden.

Daneben hat man die Möglichkeit, Test-Code, der nur unter einer bestimmten Bedingung ausgeführt werden soll, mit Assumptions.assumingThat() innerhalb eines Lambda-Ausdrucks zu definieren:

@Test
void testInAllEnvironments() {
    assumingThat("CI".equals(System.getenv("ENV")),
        () -> {
            // diesen Teil nur auf dem CI System ausführen
            assertEquals(2, 2);
        });

    // diesen Teil überall ausführen
    assertEquals("a string", "a string");
}

Tagging und filtern

Mit der Annotation @Tag können einzelne Unit-Tests kategorisiert werden.

@Tag("fast")
void reallyFastTest() {
  assertTrue(true);
}

Diese Tags können später bei der Ausführung der Tests von der Laufzeitumgebung ausgewertet werden. So kann man Tests nach bestimmten Tags filtern, um zum Beispiel in einem Testlauf nur schnelle Tests oder nur die Tests eines bestimmten Moduls auszuführen.

Meta-Annotationen

Die Annotationen von JUnit 5 sind meta-annotationsfähig. Das bedeutet, man kann mit ihnen auch die Definition einer Annotation erweitern. Damit lassen sich eigene Annotationen erstellen, die die Eigenschaften dieser Meta-Annotationen übernehmen.

Man kann damit zum Beispiel eine eigene Annotation @FastTest schreiben:

@Target({ ElementType.TYPE, ElementType.METHOD })
@Retention(RetentionPolicy.RUNTIME)
@Tag("fast")
public @interface FastTest {
}

Schnelle Tests können dann wie folgt definiert werden:

@FastTest
void reallyFastTest() {
  assertTrue(true);
}

Geschachtelte Tests

JUnit 5 erlaubt es, eine Testklasse in eine hierarchische Struktur von inneren Klassen zu unterteilen, die jeweils den Testkontext ihrer umgebenden Klasse erben. Diese inneren Klassen dürfen nicht statisch sein und werden mit der Annotation @Nested markiert. Jede dieser inneren Klassen darf natürlich ein eigenes Paar von @BeforeEach/@AfterEach-Methoden haben (@BeforeAll und @AfterAll dürfen nicht verwendet werden, da diese Annotationen nur an statischen Methoden erlaubt sind, welche wiederum in nicht-statischen inneren Klassen nicht verwendet werden dürfen). Bevor eine Testmethode aus einer inneren Klasse ausgeführt wird, werden sämtliche @BeforeEach-Methoden der umgebenden Klassen und der inneren Klasse selbst ausgeführt, und zwar in hierarchischer Abfolge von der Wurzelklasse beginnend. Analog dazu werden nach Ausführung des Tests alle @AfterEach-Methoden ausgeführt.

Die umgebenden Klassen können damit einen ganz speziellen Testkontext für ihre inneren Klassen vorgeben. Im folgenden Beispiel wird die Stack-Klasse getestet. Die innere Klasse testet dabei den Zustand, wenn ein einzelnes Element auf den Stack gepusht wurde.

@DisplayName("A stack")
class StackTest {
    private Stack<Object> stack;

    @Test
    @DisplayName("is instantiated with new Stack()")
    void isInstantiatedWithNew() {
        new Stack<>();
    }

    @Nested
    @DisplayName("when new")
    class WhenNew {

        @BeforeEach
        void createNewStack(TestInfo testInfo) {
            stack = new Stack<>();
        }

        @Test
        @DisplayName("is empty")
        void isEmpty() {
            assertTrue(stack.isEmpty());
        }

        @Nested
        @DisplayName("after pushing an element")
        class AfterPushing {

            String anElement = "an element";

            @BeforeEach
            void pushAnElement(TestInfo testInfo) {
                stack.push(anElement);
            }

            @Test
            @DisplayName("it is no longer empty")
            void isEmpty() {
                assertFalse(stack.isEmpty());
            }
        }
    }
}

Dependency Injection

Neben der automatischen Übergabe eines TestInfo-Objekts an eine Testmethode, die einen Parameter vom Typ TestInfo definiert, kann man auch beliebige andere Daten in seine Tests injizieren lassen. JUnit 5 unterstützt das Prinzip der Dependency Injection.

Die Regeln, nach denen die erforderlichen Daten in einen Test injiziert werden müssen, kann man selbst festlegen. Dazu implementiert man das Interface ParameterResolver und registriert diesen als eigene Extension (siehe unten).

Eine Beispielanwendung dafür wäre, dass man sich durch diesen Mechanismus vorkonfigurierte Mock-Objekte injizieren lässt, die man in einem Test verwenden möchte. Das Erzeugen der Mock-Objekte geschieht dann durch den ParameterResolver.

Dynamische Tests

Zum Ausführen einer Test Suite mit JUnit 4 müssen alle Tests schon zu Beginn eines Testlaufs bekannt und fest definiert sein. JUnit 5 hingegen erlaubt das dynamische Erzeugen von Tests während der Laufzeit einer Test Suite. Es können also während gerade Tests ausgeführt werden, weitere Tests generiert werden, die im Anschluss ausgeführt werden.

Dynamische Tests werden durch Methoden erzeugt, die mit @TestFactory annotiert sind. Solche Methoden müssen einen Stream, eine Collection, ein Iterable oder einen Iterator von DynamicTest-Instanzen zurückliefern.

Die Implementierung eines dynamischen Tests selbst wird in Form eines Objekts vorgegeben, das das Interface Executable implementiert. Executable ist ein @FunctionalInterface, sodass dieses auch als Lambda-Ausdruck angegeben werden kann.

Eine Anwendungsmöglichkeit für dieses Feature ist das Schreiben von parametrisierten Testfällen.

Interface Default Methoden

Seit Java 8 ist es auch möglich, sogenannte Default Methoden in einer Interface-Definition zu verwenden. Das sind Methoden mit einer Implementierung, die keinen direkten Zugriff auf einen Objektzustand haben (da ein Interface ja keine Variablen definieren kann), dafür aber die anderen Methoden der Schnittstelle aufrufen dürfen.

Diese Art von Interface-Methoden kann man sich auch in JUnit 5 zunutze machen. Es ist erlaubt, Default Methoden mit @Test, @TestFactory, @BeforeEach oder @AfterEach zu annotieren. Damit lassen sich Test-Interfaces schreiben, mit denen der Kontrakt einer bestimmten zu testenden Schnittstelle getestet werden kann. Gibt es zum Beispiel das Interface Foo, so kann man ein weiteres Interface FooTest schreiben, dessen Default Methoden die Methoden von Foo testen. Jede Testklasse, die anschließend eine Foo implementierende Klasse testet, kann selbst FooTest implementieren, wodurch automatisch der Kontrakt von Foo mitgetestet wird.

Extensions

Schon die alten Versionen von JUnit haben die Möglichkeit geboten, den Kern der Testing-Bibliothek mit eigenen Erweiterungen anzureichern. Dies wurde allerdings mit verschiedenen, mehr schlecht als recht ineinandergreifende Konzepten erreicht, die noch dazu sehr umständlich in der Verwendung waren. Es gab das Konzept der Test Runner, @Rules und @ClassRules. Test Runner konnten nicht miteinander kombiniert werden, sodass man sich pro Testklasse für einen bestimmten Test Runner entscheiden musste.

JUnit 5 räumt hier auf. Die alten Konzepte für die Erweiterung von JUnit wurden über Bord geworfen und durch ein neues, einheitliches Konzept von Extensions ersetzt. Ein JUnit 5 Test kann mit einer oder mit mehreren Extensions gleichzeitig erweitert werden.

@ExtendWith(FooExtension.class)
@ExtendWith(BarExtension.class)
class FoobarTests {
    // ...
}

Es ist sogar möglich, eine einzelne Testmethode mit einer Extension zu erweitern.

@ExtendWith(MockitoExtension.class)
@Test
void mockTest() {
    // ...
}

JUnit 5 Extensions erlauben die Beeinflussung und Steuerung von Tests auf unterschiedliche Art und Weise:

  • Bedingte Ausführung von Tests
  • Nachbearbeitung von Test-Instanzen
  • Auflösen von Test-Parametern zur Laufzeit
  • Callbacks für Lebenszyklusereignisse (BeforeEachCallback, BeforeTestExecutionCallback, AfterTestExecutionCallback, AfterAllCallback, usw.)
  • Beeinflussung des Exception Handlings

Fazit

Alles in allem macht der aktuelle Stand des Testing-Frameworks einen sehr durchdachten Eindruck, und die jetzt schon verfügbaren Features erleichtern das Schreiben von Unit-Tests enorm. Die neuen Möglichkeiten, Tests zu gestalten, erlauben ganz neuartige Vorgehensweisen beim Schreiben von Unit-Tests. Da die Test Engine von JUnit 5 auch dazu in der Lage ist, Tests auszuführen, die noch mit JUnit 4 geschrieben wurden, wird man nach dem ersten stabilen Release von JUnit 5 in den eigenen Projekten schnell auf diese Version migrieren können. Man kann heute schon davon ausgehen, dass mit dieser neuen Generation von JUnit das Schreiben von Unit-Tests wesentlich eleganter und effizienter möglich sein wird als vorher.

Short URL for this post: http://wp.me/p4nxik-2Kc
Roland Krüger

About Roland Krüger

Software Engineer at Orientation in Objects GmbH. Find me on Google+, follow me on Twitter.
This entry was posted in Java and Quality and tagged , , . Bookmark the permalink.

Leave a Reply