Beliebte Suchanfragen
//

Test Fixtures mit JUnit 5

25.3.2024 | 7 Minuten Lesezeit

Wir Softwareentwickler leben in einem ständigen Dilemma. Jede Funktionalität der Software sollte durch Unit-Tests und Integrationstest abgesichert werden. Es sollten dabei so viel Tests wie nötig, aber nur so wenige wie möglich geschrieben werden.

Schreiben wir zu wenige Tests, dann sind nicht alle Funktionalitäten unserer Software ausreichend abgesichert. Zu viele Tests bedeuten teure Entwicklungszeit, die wir in unnötige Tests investieren. Da sich die Software im Laufe der Zeit stetig verändert, müssen wir die Tests auch immer wieder anpassen. Mit der puren Anzahl der Tests steigt natürlich auch der Aufwand für die Pflege unserer Testbasis.

Ein recht aufwendiger Teil der Tests ist die Bereitstellung der Testdaten. Daher werden ziemlich schnell standarisierte Testdaten bzw. Test Fixtures in den Projekten genutzt. Ein Vorteil dieser Test Fixtures ist der reduzierte Aufwand bei den Anpassungen. Statt hunderte von Tests zu ändern, müssen nur einige wenige Bereitstellungsmethoden aktualisiert werden.

Für diese Bereitstellungsmethoden werden in den eigenen JUnit 5-Tests diverse Ansätze herangezogen, der Extension-Mechanismus des Frameworks aber häufig außer Acht gelassen. Wie dieser genutzt werden kann, werde ich im Folgenden an einem einfachen Test vorstellen.

Der JUnit 5 Extension-Mechanismus

Der Extension-Mechanismus modifiziert das Verhalten von Testklassen oder -methoden. Vor Junit 5 verwendete JUnit nur die Erweiterungspunkte TestRunner und Rule. JUnit 5 ersetzt diese durch eine vollständige Extension-API. Die API gestattet es an fünf Extension-Points in den Lifecycle eines Tests einzugreifen. In diesem Beitrag nutzen wir den Extension-Point Parameter Resolution, aber dazu kommen wir später.

Der Bibliotheksbenutzer, seine Ausleihe und die Gebühren aka die Domäne

Ein Problem und seine Lösung erklärt man am einfachsten an einem prägnanten Beispiel. Dieses Beispiel bedient sich an meinen IT-Erfahrungen aus der Studentenzeit. Ein Bibliotheksnutzer hat diverse Ausleihen von Büchern und ggf. stehen Gebühren für die Nutzung oder überfällige Medien an.

Ein erster Test

In unserem Beispiel werden die aktuellen Gebühren für einen Nutzeraccount berechnet. Dazu wird ein Account für einen Bibliotheksnutzer erstellt und zwei Ausleihen hinzugefügt. Da eine der beiden Ausleihen überfällig ist, steht eine Mahngebühr von 3 Euro an.

1class LibraryTest {
2  @Test
3  void currentFees() {
4    Patron patron = Patron.id("id-001").name("Jens Kaiser").fees(LibraryFees.ADULT).build();
5    Book book9 = Book.title("Gevatter Tod").author("Terry Pratchett").build();
6    Book book14 = Book.title("Lachs im Zweifel").author("Douglas Adams").build();
7    Booking booking9 = Booking.media(book9).till(LocalDate.now().minusDays(2)).build(),
8    Booking booking14 = Boocking.media(book14).till(LocalDate.now().plusDays(2)).build()
9
10    Account account = Account.by(patron).with(List.of(booking9, boocking14)).build();
11
12    assertEquals(3, account.getCurrentFees());
13  }
14}

Für den ersten Test eine Menge Code für die Erstellung der Testdaten. Alle weiteren Tests mit ähnlichen Voraussetzungen benötigen ähnliche Codefragmente. Daher sollten wir versuchen diese Fragmente bestmöglich wiederzuverwenden.

Weniger Boilerplate-Code

Für weitere Tests in der Klasse LibraryTest zentralisieren wir das Erstellen der Testdaten. Die bekannteste Möglichkeit dafür ist eine mit @BeforeEach annotierte Methode.

1class LibraryTest {
2  private Patron patron;
3  private Booking booking9;
4  private Booking booking14;
5
6  @BeforeEach
7  void setUp() {
8    Patron patron = Patron.id("id-001").name("Jens Kaiser").fees(LibraryFees.ADULT).build();
9    Book book9 = Book.title("Gevatter Tod").author("Terry Prattchet").build();
10    Book book14 = Book.title("Lachs im Zweifel").author("Douglas Adams").build();
11    Booking booking9 = Booking.media(book9).till(LocalDate.now().minusDays(2)).build(),
12    Booking booking14 = Boocking.media(book14).till(LocalDate.now().plusDays(2)).build()
13  }
14
15  @Test
16  void currentFees() {
17    Account account = Account.by(patron).with(List.of(booking9, boocking14)).build();
18
19    assertEquals(3, account.getCurrentFees());
20  }
21}

Nun können sich alle Testmethoden die Text Fixture teilen. Da die setUp-Methode vor jedem Test aufgerufen wird, werden diese Daten leider auch für Tests erstellt, die sie gar nicht benötigen.

Parameter Resolver in JUnit 5

JUnit 5 erlaubt es, Parameter an Test und Lifecycle-Methoden zu übergeben. Dafür können eigene ParameterResolver implementiert werden.

1public class PatronParameterResolver implements ParameterResolver {
2
3  @Override
4  public boolean supportsParameter(ParameterContext parameter, ExtensionContext extension) {
5      return parameter.getParameter().getType() == Patron.class;
6  }
7
8  @Override
9  public Patron resolveParameter(ParameterContext parameter, ExtensionContext extension) {
10    return Patron.id("id-001").name("Jens Kaiser").fees(LibraryFees.ADULT).build();
11  }
12}

Der PatronParameterResolver ersetzt alle Methodenparameter vom Typ Patron mit der Instanz, die von der Methode resolveParameter erzeugt wird. Wir dürfen nicht die @ExtendWith Annotation vergessen, sonst weiß JUnit 5 nicht, woher der Patronkommen soll.

Die Test-Klasse verkürzt sich nun um den vom PatronParameterResolver generierten Kunden.

1@ExtendWith(PatronParameterResolver.class)
2class LibraryTest {
3  private  Booking booking9;
4  private  Booking booking14;
5
6  @BeforeEach
7  void setUp() {
8    Book book9 = Book.title("Gevatter Tod").author("Terry Pratchett").build();
9    Book book14 = Book.title("Lachs im Zweifel").author("Douglas Adams").build();
10    Booking booking9 = Booking.media(book9).till(LocalDate.now().minusDays(2)).build(),
11    Booking booking14 = Boocking.media(book14).till(LocalDate.now().plusDays(2)).build()
12  }
13
14  @Test
15  void currentFees(Patron patron) {
16    Account account = Account.by(patron).with(List.of(booking9, boocking14)).build();
17
18    assertEquals(3, account.getCurrentFees());
19  }
20}

Der Bibliotheksnutzer wird nun nur noch für die Testmethoden erzeugt, die diesen auch tatsächlich benötigen. Nur schade, dass es immer derselbe Nutzer ist. Kann man das nicht ändern?

Variationen mit Annotationen

Eine Annotation am Parameter kann durch den Parameter Resolver ausgewertet werden und zur Modifizierung der Werte genutzt werden. Mit der Annotation @Fixture sind dann die folgenden Tests möglich.

1@ExtendWith(PatronParameterResolver.class)
2class LibraryTest {
3  private Booking booking9;
4  private Booking booking14;
5
6  @BeforeEach
7  void setUp() {
8    Book book9 = Book.title("Gevatter Tod").author("Terry Pratchett").build();
9    Book book14 = Book.title("Lachs im Zweifel").author("Douglas Adams").build();
10    Booking booking9 = Booking.media(book9).till(LocalDate.now().minusDays(2)).build(),
11    Booking booking14 = Boocking.media(book14).till(LocalDate.now().plusDays(2)).build()
12  }
13
14  @Test
15  void currentFeesAdult(@Fixture("Jens") Patron patron) {
16    Account account = Account.by(patron).with(List.of(booking9, boocking14)).build();
17    assertEquals(3, account.getCurrentFees());
18  }
19
20  @Test
21  void currentFeesChild(@Fixture("Christopher") Patron patron) {
22    Account account = Account.by(patron).with(List.of(booking9, boocking14)).build();
23    assertEquals(2, account.getCurrentFees());
24  }
25}

Der PatronParameterResolver entscheidet mit Hilfe der @Fixture Angabe, welcher Bibliotheksnutzer erstellt werden soll. Der zweite Bibliotheksnutzer bekommt geringere Mahngebühren, weil er noch ein Kind ist.

Wir verwenden an dieser Stelle Personas, um unseren Tests, im wahrsten Sinne des Wortes, ein Gesicht zu geben. Die Persona Jens Kaiser ist ein älterer Herr, der gelegentlich die Bibliothek besucht und Christopher Robin ist ein kleiner Junge mit einem Teddybär.

Das geht auch generisch

Häufig hat man es nicht nur mit einer Domänenklasse zu tun, sondern mit sehr vielen. Daher kann ein Großteil der Implementierung in einer generische Basisklasse verschoben werden.

1public class PatronFixtureParameterResolver 
2      extends AbstractFixtureParameterResolver<Customer, Long> { 
3  public PatronFixtureParameterResolver() {
4    super(Patron.class, x -> (long) x);
5  }
6
7  @Override
8  protected Patron createInstance(Long identifier, String persona) {
9    String id = "id-%03d".formatted(identifier);
10    return switch (persona) {
11       case "Christopher" -> Patron.id(id).name("Christopher Robin").fees(CHILD).build();
12       case "Jens", "default" -> Patron.id(id).name("Jens Kaiser").fees(ADULT).build();
13       default -> throw new ParameterResolutionException("unknown patron: " + persona);   
14     }
15  }
16}

Der PatronFixtureParameterResolver erbt von dem AbstractFixtureParameterResolver. Dieser erzeugt nicht nur eine Instanz der gewünschten Klasse, sondern beherrscht auch Collections und Arrays dieser Klasse und wertet die @Fixture Annotation aus. Der PatronFixtureParameterResolver kümmert sich nur noch um die Erzeugung der Varianten.

1public abstract class AbstractFixtureParameterResolver<T, I> implements ParameterResolver {
2    private final Class<T> type;
3    private final Function<Integer, I> identifierMapper;
4
5    protected AbstractFixtureParameterResolver(Class<T> type, Function<Integer, I> mapper) {
6        this.type = type;
7        this.identifierMapper= mapper;
8    }
9
10    private boolean isCollection(Parameter parameter) {
11        ParameterizedType type = (ParameterizedType) parameter.getParameterizedType();
12        return type.getActualTypeArguments()[0] == type;
13    }
14
15    private boolean isArray(Class<?> parameterType) {
16        return parameterType.isArray() && parameterType.getComponentType() == type;
17    }
18
19    @Override
20    public boolean supportsParameter(ParameterContext context, ExtensionContext extension) {
21        Parameter parameter = context.getParameter();
22        Class<?> parameterType = parameter.getType();
23        return parameterType == type || isArray(parameterType) || isCollection(parameter);
24    }
25
26    protected abstract T createInstance(I identifier, String variation);
27
28    @Override
29    @SuppressWarnings("unchecked")
30    public Object resolveParameter(ParameterContext context, ExtensionContext extension) {
31        Optional<Fixture> fixture = context.findAnnotation(Fixture.class);
32        List<String> variations = fixture.map(Fixture::value).filter(l -> l.length > 0)
33            .map(Arrays::asList).orElseGet(() -> List.of("default"));
34        Class<?> parameterType = context.getParameter().getType();
35        if (parameterType == type) {
36            return createInstance(identifierSupplier.apply(0), variations.getFirst());
37        }
38        int size = fixture.map(Fixture::size).orElse(3);
39        int offset = fixture.map(Fixture::offset).orElse(10);
40        if (parameterType.isArray()) {
41            List<T> list = createStream(offset, size, variations).toList();
42            return list.toArray((T[]) Array.newInstance(type, list.size()));
43        }
44        if (parameterType == List.class) {
45            return createStream(offset, size, variations).toList();
46        }
47        if (parameterType == Set.class) {
48            return createStream(offset, size, variations).collect(Collectors.toSet());
49        }
50        throw new ParameterResolutionException("something went wrong: " + parameterType);
51    }
52
53    private Stream<T> createStream(int offset, int size, List<String> variations) {
54        return IntStream.range(offset, offset + size)
55            .mapToObj(x -> createInstance(identifierMapper.apply(x), 
56                variations.get(x % variations.size())));
57    }
58}

Mehrere Parameter-Resolver

Es geht natürlich auch mit mehreren ParameterResolvern. Neben dem PatronFixtureParameterResolver wird im folgenden Test auch ein BookingFixtureParameterResolver verwendet. Dieser erzeugt die Ausleihen für den Benutzeraccount.

1@ExtendWith(PatronFixtureParameterResolver.class)
2@ExtendWith(BookingFixtureParameterResolver.class)
3class LibraryTest {
4  @Test
5  void currentFees(@Fixture("Jens) Patron patron, 
6      @Fixture(variations={"Pratchett-2", "Adams+2"}, size=2) List<Booking> bookings) {
7    Account account = Account.by(patron).with(bookings)).build();
8
9    assertEquals(3, account.getCurrentFees());
10  }
11}

Der BookingFixtureParameterResolver generiert diverse Varianten von Booking-Instanzen. In der Testklasse werden keine Test Fixture mehr erzeugt. Alle Test Fixtures kommen nun aus Parameter Resolvern.

Fazit

Mit dem Parameter Resolver Extension Point bietet JUnit 5 einen mächtigen Mechanismus, um Test-Methoden mit Test Fixtures zu versehen. Wir konnten sehen, dass eigene Parameter-Resolver ohne besonderen Aufwand erstellt und eingebunden werden können.

Die Testdatenerzeugung mit Parameter-Resolvern bringt einige Vorteile. Der Mechanismus ist integraler Bestandteil des JUnit 5 Frameworks. Er ist etabliert, dokumentiert und bietet eine uniforme Lösung für die eigene Datenbereitstellung. Die Testdaten werden nur punktuell erzeugt und verwendet. Nur dort, wo sie als Parameter einer Testmethode angegeben sind, werden sie auch tatsächlich instanziiert. Außerdem können Variationen in den Testdaten über Annotationen direkt an den Parametern der Testmethoden konfiguriert werden.

Für alle, die neugierig geworden sind: einfach mal testen!

Beitrag teilen

//

Weitere Artikel in diesem Themenbereich

Entdecke spannende weiterführende Themen und lass dich von der codecentric Welt inspirieren.

//

Gemeinsam bessere Projekte umsetzen.

Wir helfen deinem Unternehmen.

Du stehst vor einer großen IT-Herausforderung? Wir sorgen für eine maßgeschneiderte Unterstützung. Informiere dich jetzt.

Hilf uns, noch besser zu werden.

Wir sind immer auf der Suche nach neuen Talenten. Auch für dich ist die passende Stelle dabei.