Integrationstest-Set-up mittels Extensions vereinfachen

Foto des Autors
Marvin Rensing

JUnit 5 bietet verschiedene Möglichkeiten, Tests zu erweitern. Wie du JUnit 5 Extensions verwendest, um das Set-up von Integrationstests zu vereinfachen, erfährst du in diesem Artikel.

Irisa Limani und Laura Sundara im Conciso Workgarden bei einem Integrationstest.
Irisa Limani und Laura Sundara im Conciso Workgarden – © ImageKollektiv GmbH

In Zeiten von Microservices räumen wir Integrationstests eine weitaus größere Rolle ein, als noch vor einigen Jahren. Das stellen wir auch bei unseren Kunden fest.

Integrationstests, genauer gesagt automatisierte Tests im Allgemeinen, sollten stets unabhängig voneinander sein, damit diese einzeln, parallel und in beliebiger Reihenfolge ausgeführt werden können. Dies bedeutet, dass wir für jeden Integrationstest ein eigenes Set-up durchführen müssen, um einen unabhängigen Kontext für jeden Integrationstest herzustellen. Wie wir dieses Set-up mithilfe von JUnit 5 Extensions vereinfachen und somit Code-Duplizierung vermeiden, erfahrt ihr in diesem Artikel.

Da jede:r etwas anderes unter dem Begriff Integrationstest versteht, definieren wir ihn zunächst.

Was ist ein Integrationstest?

Im Kontext dieses Artikels verstehen wir unter Integrationstest eine Art von automatisiertem Software-Test. Dieser fährt unser zu testendes System zusammen mit abhängigen Systemen (entweder als Mock oder echtes System) hoch und verifiziert die Korrektheit des SUT (= „System under test“) über seine externen Schnittstellen.

Als konkretes Beispiel für diesen Artikel verwenden wir einen sehr simplen Webshop, der es uns ermöglicht Warenkörbe von Nutzer:innen zu verwalten. Die genaue Funktionalität unseres SUT ist für diesen Artikel irrelevant, da wir uns auf das Set-up der Integrationstests fokussieren. Das Set-up für unsere Testfälle besteht aus dem Aufsetzen neuer Nutzer:innen. Wir zeigen euch, wie wir dies auf verschiedene Arten in unseren Testfällen vornehmen können.

Sämtlichen Code aus diesem Artikel findet ihr auch im begleiteten GitHub-Repository.

Das Problem mit Integrationtest Set-ups

Um einen unabhängigen Kontext für jeden Test aufzusetzen, verwendet man klassischerweise Set-up-Methoden: Mithilfe von @BeforeEach oder @BeforeAll annotierten Methode können wir vor jedem Test in einer Testklasse ein Set-up ausführen. Ist dieses Set-up für mehrere Testklassen identisch, können wir diese Methode in eine abstrakte Klasse auslagern.

Problematisch wird es, wenn wir ein nicht komplett identisches Set-up für unsere Testfälle haben. In unserem Beispiel des Webshops benötigen wir für fast jeden Test eine:n Nutzer:in. Teilweise benötigen wir aber auch keine:n Nutzer:in (z. B. wenn wir ungeschützte Ressourcen testen möchten) oder eine:n mit besonderen Rechten. Das bedeutet: teilweise benötigen wir kein Set-up oder ein etwas anderes Set-up. In diesen Fällen ist es schwierig, mit einer zentralen Set-up-Methode zu arbeiten und wir benötigen eine andere Lösung, wenn wir Code-Duplizierung vermeiden möchten. Hier kommen nun JUnit 5 Extensions ins Spiel.

Testgetriebene Entwicklung

Für unseren Kollegen Andrej Thiele ist dies der einzig wahre Ansatz. Warum erfahrt Ihr im Video:


Mit dem Klick auf das Video ist Ihnen bewusst, dass Google einige Cookies setzen und Sie ggfs. auch tracken wird.

Wie JUnit 5 Extensions das Test Set-up vereinfachen

JUnit 5 Extensions geben uns verschiedene Möglichkeiten, unseren Test-Code zu erweitern. Neben ParameterResolver, die uns ermöglichen, Methoden-Parameter in unsere Testmethoden zu injizieren, gibt es Lifecycle-Callbacks, über die wir uns in JUnit Lifecycle Events einklinken können. Zunächst schauen wir uns die ParameterResolver an und danach die Lifecycle-Callbacks.

Mit JUnit 5 ParameterResolver Tests flexibel aufsetzen

Mithilfe von ParameterResolvern kann JUnit Parameter in Testmethoden injizieren. Dies können wir uns zunutze machen, um das Set-up für jeden Testfall individuell zu definieren.

Im nachfolgenden Beispiel wollen wir über einen ParameterResolver einen zufälligen User in unsere Testmethode injizieren. In einem zweiten Schritt erweitern wir diese Funktionalität, um eine Parametrisierung des zu injizierenden Users.

Die Testmethode könnte dann wie folgt aussehen:

@Test
void testWithUserInjected(UserPropertyResolver.KeycloakUser user) {
  HttpHeaders headers = new HttpHeaders();
  headers.setBearerAuth(user.token());

  HttpEntity<Item> httpEntity = new HttpEntity<>(new Item("Toothpaste", 5),   headers);
  ResponseEntity<String> response =
      restTemplate.exchange("/shopping-cart/item", HttpMethod.POST, httpEntity, String.class);

  assertEquals(HttpStatus.OK, response.getStatusCode());
}
Code-Sprache: Java (java)

Damit JUnit den KeycloakUser tatsächlich injizieren kann, müssen wir unsere Extension schreiben. Für einen ParameterResolver müssen wir lediglich das Interface ParameterResolver in einer Klasse erweitern und die beiden Methoden supportParameter und resolveParameter implementieren:

public class UserPropertyResolver implements ParameterResolver {

  // ...

  @Override
  public boolean supportsParameter(
      ParameterContext parameterContext, ExtensionContext extensionContext)
      throws ParameterResolutionException {
    return parameterContext.getParameter().getType().equals(KeycloakUser.class);
  }

  @Override
  public Object resolveParameter(
      ParameterContext parameterContext, ExtensionContext extensionContext)
      throws ParameterResolutionException {
    String username = createRandomUser();
    String token = fetchBearerToken(username);
    return new KeycloakUser(username, token);
  }
  
  // ...
}
Code-Sprache: Java (java)

Zudem müssen wir diese Extension registrieren. Dies geht über verschiedene Wege: global, pro Testklasse oder nur für eine Testmethode. In diesem Fall registrieren wir die Extension über die Annotation @ExtendWith(UserPropertyResolver.class) an der Testklasse.

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
@ExtendWith(UserPropertyResolver.class)
class ShoppingCartControllerTest extends ITSetup {
  // ...
}
Code-Sprache: Java (java)

Die Test-Parameter können wir mithilfe von selbstdefinierten Annotationen konfigurieren. Dadurch können wir das Set-up unseres Tests pro Test konfigurieren. In unserer Testmethoden könnte das dann wie folgt aussehen:

@Test
void testWithUserHavingReadPrivileged(@RandomUser(value = "readonly") UserPropertyResolver.KeycloakUser user) {
  // ...
}

@Test
void testWithUserHavingWritePrivileged(@RandomUser(value = "readwrite") UserPropertyResolver.KeycloakUser user) {
  // ...
}
Code-Sprache: Java (java)

Um diese Funktionalität zu verwenden, erzeugen wir zunächst eine eigene Annotation, welche in unserem Beispiel die Berechtigung als Wert des zu erzeugenden Nutzers erhält:

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.PARAMETER)
public @interface RandomUser {
    String value() default "readonly";
}
Code-Sprache: Java (java)

Unsere ParameterResolver-Klasse müssen wir nun so erweitern, dass wir den Wert der Annotation auslesen und beim Erzeugen des Nutzers berücksichtigen:

public class UserPropertyResolver implements ParameterResolver {

  // ...

  @Override
  public Object resolveParameter(
      ParameterContext parameterContext, ExtensionContext extensionContext)
      throws ParameterResolutionException {
      
    String permission = 
        parameterContext
            .findAnnotation(RandomUser.class)
            .map(RandomUser::value)
            .orElse("readonly");

    String username = createRandomUser(permission);
    String token = fetchBearerToken(username);


    return new KeycloakUser(username, token);
  }
  
  // ...
}
Code-Sprache: Java (java)

Mithilfe von JUnits Lifecycle Callbacks das Test Set-up über Annotationen steuern

Neben der Möglichkeit, Parameter dynamisch in Testmethoden zu injizieren, ermöglicht JUnit 5, über Extensions in den Test Lifecycle einzugreifen. Dies erlaubt es uns in einer zentralen Extension den Set-up-Code zu schreiben, welchen wir dann in verschiedenen Testklassen wiederverwenden.

In folgendem Beispiel haben wir die Funktionalität des ParameterResolvers nachgebaut. Über ein BeforeEachCallback injizieren wir einen zufälligen User in die Testklasse.

In unserer Testklasse fügen wir ein Attribut vom Typ KeycloakUser hinzu, welches wir mit der @RandomUser annotieren. Die Annotation hilft uns, das Attribut, in welches der User injiziert werden soll, zu identifizieren. Zudem aktivieren wir unsere Extension über die @ExtendWith-Annotation:

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
@ExtendWith(UserViaLifecycleHookInjector.class)
class ShoppingCartControllerTest extends ITSetup {

  @RandomUser
  UserViaLifecycleHookInjector.KeycloakUser user;

  // ...
}
Code-Sprache: Java (java)

Das entsprechende Interface für das Lifecycle Event müssen wir in der Klasse für unsere Extension implementieren. In diesem Fall ist es das Interface BeforeEachCallback. Wir identifizieren nun in der zu implementierenden Methode beforeEach die Klassenattribute, welche die @RandomUser-Annotation besitzen. Danach injizieren wir zufällige User:

public class UserViaLifecycleHookInjector implements BeforeEachCallback {

  // ...

  @Override
  public void beforeEach(ExtensionContext context) {
    List<Field> annotatedFields =
        AnnotationSupport.findAnnotatedFields(context.getRequiredTestClass(), RandomUser.class);

    annotatedFields.forEach((field) -> injectRandomUser(context.getRequiredTestInstance(), field));
  }

  private void injectRandomUser(Object testInstance, Field field) {
    String username = createRandomUser();
    String token = fetchBearerToken(username);

    KeycloakUser user = new KeycloakUser(username, token);

    field.setAccessible(true);
    try {
      field.set(testInstance, user);
    } catch (IllegalAccessException e) {
      throw new RuntimeException(e);
    }
  }
  
  // ...
}
Code-Sprache: Java (java)

Fazit

In diesem Artikel haben wir gelernt, wie wir unsere JUnit-Tests mithilfe von Extensions so erweitern können, dass wir das Test-Set-up in verschiedenen Testmethoden bzw. Testklassen wiederverwenden können. Über ParameterResolver und Lifecycle Callbacks können wir somit duplizierten Code in beforeEach-Methoden und abstrakte Klassen vermeiden.

Besonders attraktiv ist die Möglichkeit des ParameterResolvers, da wir so das Set-up für den Test über die Parameter der Testmethode bestimmen können.

Übrigens analysieren wir auch eure automatisierten Testverfahren und helfen, mit unserem Beratungsangebot eure Herausforderungen anzugehen. Dabei profitieren wir von unseren Erfahrungen, die wir in vielen Projekteinsätzen gemacht haben.

Das könnte Dich auch noch interessieren

Titelbild zum Blogpost "Mit Terraform im Team arbeiten"

Mit Terraform im Team arbeiten

Terraform hält sowohl Vorteile als auch Herausforderungen für die Teamarbeit bereit. In diesem Artikel schauen wir uns die Probleme und ...
Beitragsbild für den Wissensbeitrag Practical WebAssembly

Practical WebAssembly

In this article we’ll compile C code to WebAssembly (WASM) and interact with it from JavaScript. Compiling code to WASM ...
Titelbild zum Beitrag "Behavior Driven Development und seine Umsetzung mit Cucumber"

Behavior Driven Development und seine Umsetzung mit Cucumber

Tauchen wir gemeinsam in die Welt des Behavior Driven Development ein. Lass uns entdecken, wie diese Methode die Art und ...