Wie am Fließband

Parametrisierung mit JUnit

Parametrisierte Tests sind ein einfacher Weg, mit geringem  Aufwand eine große Menge an Tests durchzuführen. Benötigt werden dann nur noch weitere Testdaten, um die Abdeckung der Testfälle zu vervollständigen. Wir werden uns diese Funktionalität  für JUnit 4 und JUnit 5 einmal ansehen.

In JUnit 4

JUnit 4 benötigt für eine parametrisierte Testklasse folgende Elemente:

  1. Den Parameterized Test Runner
  2. Eine statische @Parameters Methode, die eine Collection von Arrays zurückgibt
  3. Einen Konstruktor, dessen Signatur den Objekt-Arrays entspricht
@RunWith(Parameterized.class)
public class ParameterizedTest {
     private Object input;
     private Object result;

     public static void ParameterizedTest(Object input, Object result) {
         this.input = input;
         this.result = result;
         // optional parsing or setup
     }

     @Test public void test() { … } // runs test

     @Parameters(name = "{index}: {0}=={1}")
     public static Collection<Object[]> params() {
         return Arrays.asList(new Object[][] {
             { <input value(s)>, <expected result(s)> }, …
         });
     }
 } 

Es gibt hier die Möglichkeit, den Konstruktor durch Injection-Parameter zu ersetzen, und weitere Funktionalitäten von dort in einer @BeforeClass Methode zu verarbeiten:

@Parameter(0) public int expected;
@Parameter(1) public int actual;

@BeforeClass public static void setup() { … } // optional parsing or setup

Beim Start dieses Tests wird vom Test Runner pro Eintrag in der Parameter-Collection (also pro Objekt-Array) die Klasse neu instanziiert und wie eine eigenständige Testklasse verarbeitet. Die Ergebnisse werden in einem Array gesammelt, das dann im Protokoll einsehbar ist

Ein Nachteil dieser Methodik war anfangs, dass immer nur ein Test Runner die Klasse verarbeiten kann. Dies bedeutete, dass nicht gleichzeitig automatisch ein Framework gestartet und eine Parametrisierung verarbeitet werden konnten, weshalb zur Version 4.9 Test Rules eingeführt wurden. Weiterhin musste für jede Parametrisierung eine eigene Klasse bereitgestellt werden.

In JUnit 5

In Junit 5 liegen die Klassen für Parametrisierung in einem eigenen einzubindenden Paket:

org.junit.jupiter:junit-jupiter-params:5.3.1

Unter JUnit 5 ändern sich folgende Elemente:

  1. Runner existieren nicht mehr
  2. Die Annotation geschieht vollständig auf der Test-Methode
  3. Die Parameter werden als Argumente direkt in die Methode übergeben.
class ParameterizedTest {
    @ParameterizedTest(name = "{index}: …") @MethodSource("params")
    void test([param1], [param2]) { … }
    static Stream<Arguments> params() {
        return Stream.of(new Object[][] {
             { <input value(s)>, <expected result(s)> }, …
        }).map(Arguments::of);
    }
}

Die Parameter-Methode kann dabei wahlweise einen Stream, Collection, Iterable oder Iterator vom Typ Arguments zurückgeben. Man ist aber nicht alleine auf eine Methode beschränkt. Es ist auch möglich, mehrere Methoden in einem Test auszuwählen, und so Daten zwischen Testläufen zu teilen, die alle in derselben Test-Klasse liegen können.
Weiterhin gibt eine große Auswahl an weiteren Quellen für Parameter:

  • als ValueSource mit String, Integer, Long oder Double
  • als EnumSource, mit der Möglichkeit, die Enums zu filtern
  • CSVSource und CSVFileSource zur Organisation von Daten im .csv-Format direkt im Testcode oder in einer externen Datei
  • in einer eigenen ArgumentProvider-Klasse

Die letztere ermöglicht somit auch, Testdaten aus beliebigen Datenformaten aufzubereiten und der Testklasse zu übergeben.
In der CSVSource werden die Werte außerdem automatisch in den von der Testmethode vorgegebenen Typ gewandelt, so dass man hier z.B. auch Enums in Textform mit angeben kann.
Abschließend bietet Junit 5 noch einen weiteren Test-Typ an:

 @RepeatedTest(value = 10, name = "{displayName} {currentRepetition}/{totalRepetitions}")
     void test() { … } 

Mit diesem ist es möglich, einen Test ohne Parameter, z.B. für randomisierte Testläufe, beliebig oft durchzuführen.

Dynamische Tests

Üblicherweise sollten Testmethoden immer kleine, einzelne Funkionalitäten prüfen, im Idealfall sogar nur eine Assertion pro Methode. Dies ist allerdings nicht immer praktikabel und mitunter auch gar nicht zu empfehlen. Zwei hypothetische Testfälle sollen das verdeutlichen:

1. Prüfung von DAOs

Eine API-Schnittstelle soll getestet werden. In mehr als einem Fall werden dabei komplexe Datenstrukturen zurückgegeben, die auf ihre Integrität getestet werden müssen. Diese DAOs können mehrere Eigenschaften oder Unterobjekte enthalten, die alle validiert werden müssen.

2. CRUD Tests

Die typischen Operationen einer Datenzugriffs-Klasse, Create, Read, Update, Delete müssen in genau dieser festen Reihenfolge verarbeitet werden, um die Integrität des Ablaufs zu wahren. Da das Prinzip von JUnit ist, alle Tests unabhängig voneinander zu betrachten, müssen diese also im Testcode gekoppelt werden.
Zwar ist es durch die Einführung des ErrorCollector (eine Test Rule) sowie dem assertAll() in Junit 5 möglich, den Test trotz auftretender Fehler bis zum Ende zu bringen und alle Probleme in einem Lauf zurückzugeben.
In beiden Fällen erhalten wir im Protokoll jedoch eine unter Umständen längere Liste dieser Fehler, die mit steigender Anzahl immer unübersichtlicher wird. Zudem sehen wir in der Übersicht nicht, ob nur ein Assert oder gleich alle  fehlgeschlagen sind – es wird genau ein Test als gescheitert gemeldet.
Wäre es nicht wünschenswert, diese Informationen direkt in der Übersicht des Testprotokolls einsehen zu können? Voilá:

Die Test Factory

Beispiel 1.

@TestFactory Stream<DynamicNode> dynamicTests() {
return Stream.of(
    dynamicContainer("Level 1", Stream.of(
        dynamicTest("1st", () -> assertTrue(…)),
        dynamicTest("2nd", () -> assertEquals(…)))),
    dynamicContainer("Level 2", Stream.of("A","B","C").map(str ->
        dynamicTest(str, () -> assertThat(…)))) );
}

Das Ergebnis sieht im Protokoll dann etwa so aus:


Beispiel 1.: Html-Export aus der IntelliJ IDEA

Wir können unsere Testfälle auch in einzelne Methoden auslagern und auf diese Weise miteinander verknüpfen:

Beispiel 2.

@TestFactory Stream<DynamicNode> CRUDTest() {
return Stream.of(
    dynamicTest("C", () -> create()),
    dynamicTest("R", () -> read()),
    dynamicTest("U", () -> update()),
    dynamicTest("D", () -> delete()) );
}

An der Stelle des Tests wird der Baum nun automatisch erweitert und die einzelnen Ergebnisse als jeweils eigener Test aufgeführt und gezählt.
Im eigentlichen Lebenszyklus der Testklasse ist die @TestFactory allerdings nur einmal durchlaufen worden, d.h. mit einem @BeforeEach zuvor und einem @AfterEach danach. Gemeinsam mit der Parametrisierung hat man somit zahlreiche Möglichkeiten auch komplexere Tests, sowohl im Programmcode als auch im Ergebnis, übersichtlich zu gestalten.