Parametrisierte Tests mit JUnit 5 Jupiter

Keine Kommentare

Nachdem wir vor einigen Tagen hier im Blog schon einen ersten Überblick über die neue Architektur von JUnit 5 sowie die wesentlichen Features der neuen Test-Engine Jupiter gegeben haben, möchten wir in diesem Artikel das Thema Parametrisierte Tests vertiefen. Schauen wir uns zuerst einmal an, welche Möglichkeiten wir dafür bisher hatten:

@RunWith(Parameterized.class)
public class FibonacciTest {
 
  @Parameters public static Collection<Object[]> data() {
    return Arrays.asList(new Object[][] {
      {0,0},{1,1},{2,1},{3,2} })};
 
  private int input, expected;
 
  public FibonacciTest(int input, int expected) {
    this.input = input; this.expected = expected;
  }
 
  @Test
  public void test() {
    assertEquals(expected, Fibonacci.compute(input));
  }
}

JUnit 4 beinhaltete einen Test-Runner namens Parameterized, der dafür sorgte, dass die aus einer mit @Parameters annotierten Methode definierten Parameter beim Erstellen einer Test-Instanz an den Konstruktor übergeben wurden. Auf diese Parameter konnte dann in Tests zugegriffen werden. Am Beispiel oben kann man gut erkennen, dass hierbei schnell ziemlich unübersichtliche und schwer wartbare Test-Konstrukte entstehen. Darüber hinaus ist mit diesem Ansatz meist nicht sinnvoll pro Testklasse mehr als eine Testmethode zu umzusetzen.
Andere Alternativen wie JUnitParams vereinfachten den Einsatz von parametrisierten Tests etwas, indem die Definition von Parametern näher am jeweiligen Test selbst erfolgte. Aber auch damit war es nicht immer einfach, den Überblick zu behalten.

@RunWith(JUnitParamsRunner.class)
public class PersonTest {
 
  @Test
  @Parameters({"0, 0", "1, 1", "2, 1", "3, 2" })
  public void personIsAdult(int input, int expected) {
    assertEquals(expected, Fibonacci.compute(input));
  }
 
}

Beide Ansätze basieren zudem auf der Verwendung von Test-Runnern. Da wir pro Testklasse aber jeweils nur einen Runner verwenden konnten war es uns nicht möglich, gleichzeitig Funktionalitäten aus anderen Test-Runnern zu benutzen. Möchten wir beispielweise den HierarchicalContextRunner einsetzen um unsere parametrisierten Tests hierarchisch zu strukturieren, so standen wir vor einem Problem.

Parametrisierte Tests mit JUnit 5

Mit Jupiter lassen sich parametrisierte Tests auf verschiedene Arten umsetzen. Es gibt einerseits die dynamischen Tests, die schon relativ früh zur Verfügung standen, es gibt aber auch die tatsächlich so genannten parametrisierten Tests, die erst mit dem Milestone 4 zu Jupiter hinzugefügt wurden.

Dynamische Tests

Im Normalfall spezifizieren wir unsere Testfälle statisch. Das bedeutet, wir implementieren eine Testmethode und kennzeichnen sie mit der Annotation @Test. JUnit findet dann diese Tests und führt sie aus. Dynamische Tests funktionieren anders.

class DynamicTests {
 
  @TestFactory
  List<DynamicTest> createDynamicTests() {
 
    return Arrays.asList(
      DynamicTest.dynamicTest("First dynamically created test",
        () -> assertTrue(true)),
 
      DynamicTest.dynamicTest("Second dynamically created test",
        () -> assertTrue(true))
    );
  }
}

Im Gegensatz zu statischen Tests implementieren wir eine Methode, die eine Collection oder einen Stream vom Typ DynamicTest zurückliefert. An dieser Methode verwenden wir die Annotation @TestFactory. Die dynamischen Tests selbst werden mithilfe der statischen Methode dynamicTest() erzeugt, die als Parameter einen Anzeigenamen (äquivalent zu @DisplayName) und den als Lambda angegebenen auszuführenden Testcode enthält. Neben DynamicTests kann eine Test-Factory übrigens auch DynamicContainer als dynamisches Äquivalent zu einer statischen Testklasse zurückgegeben.

Eine Einsatzmöglichkeit für dynamische Tests ist beispielsweise, bestimmte Tests gar nicht erst zu generieren, wenn Vorbedingungen für diese nicht erfüllt sind. In statischen Tests lässt sich dasselbe Verhalten zwar über Assumptions oder durch die Verwendung der entsprechenden Extension Points erreichen – damit würden wir aber lediglich auf die Ausführung der bereits vorhandenen Tests Einfluss nehmen.

Wir können dynamische Tests aber natürlich auch verwenden, um auf Grundlage einer Datenquelle Tests zu generieren. Dies könnte immer dann sinnvoll sein, wenn eine aufwändigere Logik bei der Ermittlung der Testparameter zum Einsatz kommt (z.B. Download einer Datei der Fachabteilung, Transformation der enthaltenen Daten und anschließende Generierung der Tests).

Parametrisierte Test

Einfacher lassen sich parametrisierte Tests aber mit der Annotation @ParameterizedTest umsetzen. Wir verwenden diese anstelle der @Test Annotation und definieren die Quelle der zu verwendenden Parameter über eine weitere Annotation, die einen sogenannten ArgumentsProvider anbindet:

class ParameterizedTests {
 
  @ParameterizedTest
  @ValueSource(ints = {1,2,3,4,5})
  void valueSourceTest(int param){
    // ...
  }
 
}

Die von einem ArgumentsProvider zurückgelieferten Werte müssen mit dem Typ des Methodenparameters übereinstimmen. Jupiter bringt dabei verschiedene vordefinierte ArgumentsProvider mit den zugehörigen Annotationen mit:
Die im Beispiel verwendete @ValueSource können wir benutzen, um über mehrere Attribute Werte unterschiedlichen Typs anzugeben. @CsvSource und @CsvFileSource kommen zum Einsatz, wenn wir CSV-Daten zur Grundlage unserer Tests machen möchten. Mit @EnumSource können wir einzelne Werte aus einer Enumeration an eine Testmethode übergeben und @MethodSource ermöglicht die Anbindung einer (im Normalfall) statischen Methode der jeweiligen Testklasse.

Eigene ArgumentsProvider verwenden

Das Konzept der parametrisieren Tests ähnelt in Grundzügen dem von JUnitParams. Es ist aber schon mit den von Jupiter bereitgestellten Bordmitteln deutlich flexibler. Noch interessanter ist die Tatsache, dass wir als Entwickler auch eigene ArgumentsProvider entwickeln können. Eine Implementierung, die JSON-Daten anbindet, sieht beispielsweise so aus:

@ArgumentsSource(JsonArgumentsProvider.class)
public @interface JsonSource {
 
  String[] value();
  Class<?> type();
}

Über die Annotation @JsonSource können wir ein Array von String-Werten sowie den zu deserialiserenden Typ angeben. Der über @ArgumentsSource angebundene Provider sieht dann so aus:

public class JsonArgumentsProvider implements ArgumentsProvider, AnnotationConsumer<JsonSource> {
 
  private String[] values;
  private Class<?> type;
 
  @Override
  public void accept(final JsonSource annotation) {
    values = annotation.value();
    type = annotation.type();
  }
 
  @Override
  public Stream<? extends Arguments> provideArguments(final ExtensionContext context) throws Exception {
    return Arrays.stream(values)
        .map(value -> new Gson().fromJson(value, type))
        .map(Arguments::of);
  }
}

Die über das Interface @AnnotationConsumer implementierte Methode accept() benutzen wir, um auf die Attributwerte der Annotation @JsonSource zuzugreifen und in der Provider-Instanz abzuspeichern. Die für das Interface ArgumentsProvider implementierte Methode provideArguments() sorgt dann dafür, die einzelnen value Werte mithilfe der Bibliothek Gson in den erwarteten Typ zu deserialisieren. In unseren Tests können wir nun wie gewohnt die neue Annotation verwenden:

@ParameterizedTest
@JsonSource(value = "{firstname:'Jane', lastname: 'Doe'}", type = Person.class)
void jsonSourceTest(Person param) {
  System.out.println(param);
}

Fazit

Viele Projekte können erfahrungsgemäß von parametrisierten Tests profitieren. Unter JUnit 4 waren diese bislang unflexibel und schwer zu handhaben. Mit der neuen Testengine Jupiter bieten sich uns viele neue Möglichkeiten. In den meisten Fällen dürfte der Ansatz mit @ParameterizedTest die einfachere Alternative sein und dank des vollständig überarbeiteten Erweiterungskonzepts von Jupiter können wir damit nun auch spezielle Anwendungsfälle wie verschachtelte, parametrisierte Tests abbilden. Für komplexere Szenarien lassen sich gegebenenfalls auch die dynamischen Tests sinnvoll einsetzen.

Die Quellcode-Beispiele aus diesem Artikel stehen auf Github zur Verfügung, für weiterführende Informationen zu JUnit 5 und die neue Testengine Jupiter empfehlen wir die umfangreiche offizielle Dokumentation des Projekts. JUnit 5 ist gerade erst neu erschienen und wir sind gespannt, welche Erfahrungen wir in den nächsten Monaten in den Projekten damit sammeln.

Reinhard Prechtl

Reinhard ist IT Consultant und Softwareentwickler am Nürnberger Standort von codecentric. In den letzten Jahren hat er sich vor allem mit JVM Technologien beschäftigt und sich umfangreiches Expertenwissen mit zahlreichen Frameworks und Tools angeeignet. Seine Interessengebiete umfassen Softwaretesting, Clean Code, Web-basierte APIs und verteilte Systeme.

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.