Zufallswerte in JUnit 5

Normalerweise möchte jeder Entwickler den Zufall aus seinen Unit Tests verbannen, Für eine verlässliche Eingabe wird eine vorhersehbare Ausgabe gewünscht. Aber wer kennt nicht die vielen hundert Unit Tests, die alle den Benutzer Max Mustermann verwenden, der 42 Jahre alt ist.

In vielen Fällen ist dies kein Problem, manchmal ändert den Entwickler auch das Alter von Max, dann ist er 16 Jahre alt, weil ein Jugend-Tarif berechnet wird, oder aus Max wird Maxi, weil ein Frauen-Tarif geprüft werden soll. Aber es bleibt immer das ungute Gefühl, irgend etwas zu übersehen, wenn die Testdaten nahezu identisch sind.

Zufallswerte in Unit Tests sind keine Alternative zu erschöpfende Tests über den gesamten Problemraum und auch keine Möglichkeit explizite Test für Randbedingungen wegzulassen. Sie sind ein Mittel um den Faktor Mensch in der Testerstellung zu beachten.

Denn nicht alle Entwickler besitzen die gleiche Qualität in ihrer Arbeit und in großen Projekten kann dann bisweilen sehr schlechter Code entstehen. Auch der Umfang der tatsächlich geprüften Funktionalitäten kann rasant fallen, wenn die Komplexität des getesteten Code zunimmt und die Unit Tests nicht entsprechend angepasst werden.

QA Teams verwenden häufig explorative Tests um eine Software zu prüfen, dann probieren sie Dinge, die noch niemand zuvor probiert hat. Häufig mit erschreckenden Resultaten. Unit Tests mit Zufallswertes sind mikroexplorative Test. Vom Entwickler übersehen Randbedingungen führen dann über kurz oder lang zu einem fehlschlagenden Test. Im folgenden ein Test, der in Abonnement für Erwachsene prüft. Damit wir explorativ unterwegs sind, lassen wir das Alter zwischen 18 und 100 würfeln.

void testAdultSubscription(@Randomize(min=18, max=100) int age) {
 ...
}

Dieser Test wird irgendwann einen Fehler werfen, weil unser Kollege diesen Tests für Senioren- und Studenten-Abonnements nicht angepasst hat.

Damit wir erkennen, welche Parameter wir zufällig füllen wollen, nutzen wir hier eine eigene Annotation @Randomize.

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.PARAMETER)
public @interface Randomize {
  long min() default null;
  long max() default null;
  boolean nullable() default false; 
}

Die Zufallswerte gelangen durch einen ParameterResolver in unsere Tests, der mit @Random annotierte Werte füllt.

public class RandomValueResolver implements ParameterResolver {
  ...
  @Override
  public boolean supportsParameter(ParameterContext parameterContext, ExtensionContext extensionContext)
    throws ParameterResolutionException {
    Parameter parameter = parameterContext.getParameter();
    return parameter.getAnnotation(Randomize.class) != null && isSupported(parameter.getType());
  }

  private boolean isSupported(Class<?> type) {
    return SUPPORTED_TYPES.contains(type);
  }

  @Override
  public Object resolveParameter(ParameterContext parameterContext, ExtensionContext extensionContext)
    throws ParameterResolutionException {
    Class<?> type = parameterContext.getParameter().getType();
    if (type == Boolean.class || type == boolean.class) {
      return random.nextBoolean();
    }
    if (type == UUID.class) {
      return UUID.randomUUID();
    }
    ...
}

Dieser ParameterResolver unterstützt die gängigen numerischen Typen und die Klasse UUID. Werden weitere Typen benötigt, so kann ein eigenen ParameterResolver hinzugefügt werden, oder die Zufallswerte basierend auf den bislang unterstützten Typen berechnet werden.

void testWithCharset(@Randomize boolean charsetCodeFlag) {
 Charset wrongCharset = charCodeFlag ? StandardCharsets.US_ASCII : StandardCharsets.ISO_8859_1;
 ...
}

Zu Sourcen zum Beitrag sind wieder auf gitlab zu finden. Etwas Zufall in den eigenen Unit Tests wird die Welt nicht retten, aber für etwas Abwechslung sorgt er allemal.