Um einen Teil der Antwort vorwegzunehmen: Richtig eingesetzt, können wir Unit-Tests intuitiver und eleganter formulieren. Wir werden dazu eingeladen, mehr Einzelfälle zu vertesten. Dadurch erhöht sich Abdeckung und letztlich die Qualität unserer Software.

An einem einfachen Beispiel stellen wir vor, wie sich herkömmliche Unit Tests mit den neuen Werkzeugen verbessern lassen. Das Spektrum der Möglichkeiten reicht dabei von einfach, aber beschränkt, bis hin zu sehr flexibel, dafür mit geringfügig mehr Boilerplate-Code verbunden.

Die Aufgabe

In dem Code unseres Projekts finden wir die statische Methode isWeekend:

boolean isWeekend(java.time.DayOfWeek dayOfWeek);

Der Name lässt erahnen, was die Methode tut:

Zu jedem Wochentag wird die Frage beantwortet, ob es sich um das Wochenende handelt.

Um kulturelle Missverständnisse zu vermeiden und auf Nummer Sicher zu gehen, möchten wir unsere Verständnis mit Unit-Tests verifizieren. Wie schwer kann das sein? Man gleicht einfach jeden der 7 Wochentage mit der Erwartung ab!

Lösungsansätze aus der alten Welt

Bevor wir uns kopfüber in die Welt der parametrisierten Tests stürzen, stellt sich die Frage, welche brauchbaren Lösungen mit konventionellen Mitteln realisiert werden können. Die erste Version fragt über Schleifen alle möglichen Werte ab:

    @Test
    public void isWeekend() {
        for (DayOfWeek dayOfWeek : Arrays.asList(
                MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY)) {
            Assertions.assertFalse(isWeekend(dayOfWeek));
        }
        for (DayOfWeek dayOfWeek : Arrays.asList(SATURDAY, SUNDAY)) {
            Assertions.assertTrue(isWeekend(dayOfWeek));
        }
    }
Version (1) – alle Testfälle in einer Methode

Das ergibt eine relativ kompakte Darstellung. Solange der Test erfolgreich durchläuft, gibt es keine Probleme. Im Falle eines Fehlers ist der Test aber nicht besonders auskunftsfreudig: Welche Fälle schlagen genau fehl? Ist es ein Fehler im Setup oder sind einzelne Tage betroffen?

Man könnte also den alternativen Ansatz verfolgen, für jeden Wochentag eine separate Testmethode zu erstellen:

    @Test
    public void isWeekend_Monday_false() {
        Assertions.assertFalse(DateUtils.isWeekend(MONDAY));
    }

    @Test
    public void isWeekend_Tuesday_false() {
        Assertions.assertFalse(DateUtils.isWeekend(TUESDAY));
    }

    @Test
    public void isWeekend_Wednesday_false() {
        Assertions.assertFalse(DateUtils.isWeekend(WEDNESDAY));
    }

    @Test
    public void isWeekend_Thursday_false() {
        Assertions.assertFalse(DateUtils.isWeekend(THURSDAY));
    }

    @Test
    public void isWeekend_Friday_false() {
        Assertions.assertFalse(DateUtils.isWeekend(FRIDAY));
    }

    @Test
    public void isWeekend_Saturday_true() {
        Assertions.assertTrue(DateUtils.isWeekend(SATURDAY));
    }

    @Test
    public void isWeekend_Sunday_true() {
        Assertions.assertTrue(DateUtils.isWeekend(SUNDAY));
    }
Version (2) – Ausmultiplizieren der Testfälle

Version (2) kommt auf den ersten Blick recht wortreich daher. Sie ist aber gegenüber Version (1) in zwei Punkten eine Verbesserung:

  1. Jeder Testfall wird von der Test-Suite einzeln ausgeführt und dargestellt. Die fehlgeschlagenen Testfälle sind auf einen Blick erkennbar.

    JUnit test in Eclipse - Friday failing
    Testausführung in Eclipse: Freitag ist kein Wochenende
  2. Der JUnit-Life-Cycle wird respektiert: Allgemeine Initialisierungen möchten wir gerne in einer @BeforeEach-Methode unterbringen. In Version (1) würde diese aber nur ein einziges Mal aufgerufen. Auch wenn es in unserem minimalen Beispiel noch keine besondere Rolle spielt: Im Allgemeinen muss die Initialisierung für jeden Testfall erneut erfolgen. Man könnte sich damit behelfen, eine setUp()-Methode händisch am Anfang jedes Schleifendurchlaufs aufzurufen. Damit arbeitet man aber gegen JUnit an und verschließt sich Mechanismen, die fest an den Life-Cycle gebunden sind. Das wären beispielsweise annotationsbasierte Initialisierung in Mockito oder Spring.

Der Nachteil von Variante (2) ist die starke Redundanz, gerade wenn die Tests länger als eine Zeile werden. Dieses Problem kann aber durch Auslagerung in eine Hilfsmethode gelöst werden:

    @Test public void isWeekend_Mo() { doTestIsWeekend(MONDAY,    false); }
    @Test public void isWeekend_Tu() { doTestIsWeekend(TUESDAY,   false); }
    @Test public void isWeekend_We() { doTestIsWeekend(WEDNESDAY, false); }
    @Test public void isWeekend_Th() { doTestIsWeekend(THURSDAY,  false); }
    @Test public void isWeekend_Fi() { doTestIsWeekend(FRIDAY,    false); }
    @Test public void isWeekend_Sa() { doTestIsWeekend(SATURDAY,  true); }
    @Test public void isWeekend_Su() { doTestIsWeekend(SUNDAY,    true); }

    private void doTestIsWeekend(DayOfWeek dayOfWeek, boolean isWeekendExpected) {
        Assertions.assertEquals(isWeekendExpected, DateUtils.isWeekend(dayOfWeek));
    }
Version (3) – im Stil parametrisierter Tests

Die einzelnen Testmethoden sind etwas unkonventionell als Einzeiler geschrieben. Optisch, aber auch funktional, sind wir dadurch schon recht nah an dem gewünschten Zielzustand.

Etwas lästig ist es noch, für jeden Testfall einen eindeutigen Methodennamen wählen zu müssen. Es sollte doch ausreichen, die Parameter als Liste anzugeben? Auch müssen wir jeden Testfall explizit angeben. Eine dynamische Erzeugung ist auf diese Weise nicht zu realisieren. Das alles, und noch viel mehr versprechen wir uns von den parametrisierten Tests in JUnit 5…

Parametrisierte Tests – Retter in der Not?

Die Idee hinter parametrisierten Tests ist eine Trennung von Testdaten und Testlogik. Bestehende Tests sollen um weitere Beispiele ergänzt werden können, ohne dass die Übersichtlichkeit und Struktur wesentlich leidet. Technisch wird dies durch zwei Mechanismen erreicht:

  1. Die Eingaben und erwarteten Ausgaben werden der Testmethode als Methodenparameter zur Verfügung gestellt.
  2. Die Datenquelle wird über Annotationen konfiguriert.

Damit wird beispielsweise

    @Test
    void isOdd_5_true() {
        Assertions.assertTrue(NumberUtils.isOdd(5));
    }

zu dem deutlich vertrauenserweckenderen Test:

    @ParameterizedTest
    @ValueSource(ints = { 1, 3, 5, -1, -3, 15, 10001 })
    void isOdd_true(int number) {
        Assertions.assertTrue(NumberUtils.isOdd(number));
    }

Mit der Annotation @ParameterizedTest wird angezeigt, dass der Test Eingabedaten erwartet. @ValueSource ist eine von mehreren möglichen Annotationen, um die Testdaten bereitzustellen.

Für einfachste Fälle: ValueSource

Wer jetzt allerdings erwartet, die @ValueSource Annotation könnte nach Lust und Laune mit Objekten befüllt werden, der muss sich auf eine Ernüchterung gefasst machen. Tatsächlich gelten erhebliche Einschränkungen:

  1. Es kann nur ein Parameter an den Test übergeben werden. Ein Tupel aus Eingabe und erwarteter Ausgabe, wie in den meisten Tests üblich, ist damit nicht zu realisieren.
  2. Die möglichen Typen des einen Paramters sind eingeschränkt auf:
    • (literale) Strings
    • primitive Typen: short, byte, int, long, float, double, char, boolean
    • Klassen

Insbesondere lassen sich keine instanziierten Objekte übergeben. Diese Beschränkung spiegelt im Wesentlichen den eingeschränkten Funktionsumfang von Java-Annotationen wider. Die Einfachheit ist von den Sprachdesignern aber beabsichtigt, um beispielsweise Annotation-Processing zur Kompilierzeit zu ermöglichen.

Im Bereich von parametrisierten Tests sind diese Einschränkungen aber frustrierend. @ValueSource kann damit nur in wenigen einfachen Spezialfällen zum Einsatz kommen.

Bevor wir zu dem Alleskönner unter den Datenquellen kommen, der um den Preis eines kleinen Overheads volle Flexibilität ermöglicht (@MethodSource), möchten wir noch eine Quelle speziell für Enums anschauen. Die sollte doch genau auf das Wochenende-Beispiel zugeschnitten sein?

EnumSource

Mit @EnumSource kann man Tests über alle möglichen Werte eines Enums laufen lassen. Soll die Auswahl auf bestimmte Enum-Werte eingeschränkt werden, so kann man der Annotation über den Parameter names eine Liste mitgegeben. Damit kommen wir zur 4. Iteration des isWeekend-Tests:

    @ParameterizedTest
    @EnumSource(value = DayOfWeek.class,
        names = { "MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY" })
    public void isWeekend_false(DayOfWeek dayOfWeek)  {
        Assertions.assertFalse(DateUtils.isWeekend(dayOfWeek));
    }

    @ParameterizedTest
    @EnumSource(value = DayOfWeek.class, names = { "SATURDAY", "SUNDAY" })
    public void isWeekend_true(DayOfWeek dayOfWeek)  {
        Assertions.assertTrue(DateUtils.isWeekend(dayOfWeek));
    }
Version (4) – EnumSource

Der Test ist kompakt und lesbar, hat aber einen Schönheitsfehler: Die Werte des names-Parameters müssen als Strings angegeben werden und nicht etwa als Enum-Literale. Man springt damit gewissermaßen aus der Sprachebene, die der Compiler und die IDE verstehen: Es werden u. a.

  • Tippfehler nicht unmittelbar angezeigt
  • Referenzen nicht von der IDE gefunden und
  • Refactoring (Umbenennung der Enum-Werte) funktioniert nicht mehr zuverlässig.

Enums sind ja eigentlich als Werte in Java-Annotationen zugelassen. Warum also die Krücke über Strings? Das liegt darin begründet, dass Annotationen keine generischen Typ-Parameter unterstützen. Die konkrete Enum-Klasse müsste der Annotation demnach bereits zur Kompilierzeit bekannt sein.

Wenn das so ist, sollte es doch möglich sein, eine verbesserte Quelle, speziell für DayOfWeek-Enums zu erstellen?

Datenquelle im Eigenbau

Hammer Dank der durchdachten Erweiterungsarchitektur von JUnit 5 lassen sich mit minimalem Aufwand eigene Datenquellen definieren. Das Manko aus Version (4) lässt sich mit einer speziellen @DayOfWeekSource-Annotation beheben:

    @ParameterizedTest
    @DayOfWeekSource({ MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY })
    public void isWeekend_false(DayOfWeek dayOfWeek)  {
        Assertions.assertFalse(DateUtils.isWeekend(dayOfWeek));
    }

    @ParameterizedTest
    @DayOfWeekSource({ SATURDAY, SUNDAY })
    public void isWeekend_true(DayOfWeek dayOfWeek)  {
        Assertions.assertTrue(DateUtils.isWeekend(dayOfWeek));
    }
Version (5) – spezielle DayOfWeek-Datenquelle

Die @DayOfWeekSource-Annotation ist dabei wie folgt definiert:

@Retention(RetentionPolicy.RUNTIME)
@ArgumentsSource(DayOfWeekArgumentsProvider.class)
public @interface DayOfWeekSource {
    DayOfWeek[] value();
}

RetentionPolicy.RUNTIME stellt hier sicher, dass die Werte der Annotation zur Laufzeit per Reflection abgefragt werden können. Mit @ArgumentsSource legen wir eine Klasse fest, welche die Testdaten bereitstellt (DayOfWeekArgumentsProvider). Diese Klasse muss das Interface ArgumentsProvider implementieren. Das ist denkbar einfach: Nötig ist nur eine Methode provideArguments, welche einen Stream von Argumenten zurückgibt:

public class DayOfWeekArgumentsProvider implements ArgumentsProvider,
         AnnotationConsumer<DayOfWeekSource> {
    private DayOfWeek[] parameters;

    @Override
    public Stream<? extends Arguments> provideArguments(ExtensionContext context) {
        return Arrays.stream(parameters).map(Arguments::of);
    }

    @Override
    public void accept(DayOfWeekSource source) {
        parameters = source.value();
    }
}

Schließlich nutzten wir noch das AnnotationConsumer-Interface, um die Werte aus der @DayOfWeekSource-Annotation auszulesen und in dem Feld parameters zwischenzuspeichern.

Bei aller Euphorie über die selbstgebaute Datenquelle muss man jetzt aber eingestehen: Die Quellen mit genau einem Parameter funktionieren für dieses Beispiel, weil es überhaupt nur zwei mögliche erwartete Ergebnisse gibt: true und false. Wäre das Ergebnis für jeden Wochentag ein anderes, so wäre @EnumSource oder @DayOfWeekSource überhaupt nicht anwendbar. Zum Glück gibt es noch eine Datenquelle, bei der wir all diese Beschränkungen hinter uns lassen…

Der Alleskönner: MethodSource

Damit kommen wir zur 6. und letzten Version des Wochenende-Tests:

    @ParameterizedTest
    @MethodSource("isWeekendTestData")
    public void isWeekend(DayOfWeek dayOfWeek, boolean isWeekendExpected) {
        Assertions.assertEquals(isWeekendExpected, DateUtils.isWeekend(dayOfWeek));
    }

    private static Stream<Arguments> isWeekendTestData() {
        return Stream.of(
                Arguments.of(MONDAY,    false),
                Arguments.of(TUESDAY,   false),
                Arguments.of(WEDNESDAY, false),
                Arguments.of(THURSDAY,  false),
                Arguments.of(FRIDAY,    false),
                Arguments.of(SATURDAY,  true),
                Arguments.of(SUNDAY,    true));
    }
Version (6) – finale Version mit MethodSource

Mit @MethodSource können wir eine Methode angeben, welche die Testdaten als Stream oder Liste zurückgibt. Der Name der Methode wird hier wieder als String und nicht etwa als Methodenreferenz übergeben. In diesem Fall sind aber die Referenzen wenigstens nicht über die gesamte Codebasis verstreut, sondern es betrifft nur zwei Methoden, die idiomatisch als Paar beieinander stehen.1

Bei der Datenerzeugung haben wir jetzt also völlig freie Hand. Beispielsweise könnte man über verschachtelte Schleifen alle Kombinationen aus zwei oder mehr Eingabeparametern durchtesten. Auch lassen sich konstante Listen aus dem Produktivcode unmittelbar nutzten.

Fazit

Das Heilsversprechen, Testparameter nur noch in der Annotationswelt leben zu lassen, erfüllt sich leider nicht. Dies ist vor allem den (absichtlich) rudimentären Features von Java-Annotationen geschuldet. Sieht man aber @ValueSource und @EnumSource nur noch als syntaktischen Zucker für spezielle Sonderfälle an, so relativiert sich der anfängliche Eindruck, auf die einfachsten Testszenarien beschränkt zu sein. Mit @MethodSource haben wir nämlich eine saubere und äußerst flexible Art, parametrisierte Tests aufzusetzen.


  1. Alternativ kann der Parameter von @MethodSource weggelassen werden. Dann sucht JUnit nach einer Methode mit dem selben Namen, wie die Testmethode.