Personas für Unit Tests

“Every person is defined by the communities she belongs to.”

Orson Scott Card, Speaker for the Dead

Mit dem Extension Model von JUnit 5 gibt es Vielzahl neuer Möglichkeiten, das Schreiben von Unit Tests zu vereinfachen. In den Beiträgen Dependency Injection mit ParameterResolver in JUnit 5 und Zufallswerte in JUnit 5 wurde der JUnit 5 ParameterResolver behandelt. Dieser Beitrag stellt den InvocationInterceptor vor.

@ExtendWith(PersonParameterResolver.class)
class PersonaTest {
  @Test
  void hitchhiking (PersonEntity person) {
    person.setFirstName("Arthur");
    person.setLastName("Dent");
    person.setEmail("arthur.dent@heart-of-gold");
    person.setId(42L);
    ...
  }
  @Test
  void illuminatus(PersonEntity person) {
    person.setFirstName("Adam");
    person.setLastName("Weißhaupt");
    person.setEmail("adam.weisshaupt@bayrische-illuminaten.de");
    person.setId(23L);
    ...
  }
}

Die hier dargestellten Unit Tests verwenden einen eigenen PersonParameterResolver, der Instanzen vom Typ PersonEntity erzeugt.

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

  @Override
  public Object resolveParameter(ParameterContext parameterContext, ExtensionContext extensionContext)
      throws ParameterResolutionException {
    return new PersonEntity();
  }
}

Der ParameterResolver erzeugt eine leere Instanz vom Typ PersonEntity. Innerhalb der Test-Methode werden noch diverse Details zur Person und die ID der Entity gesetzt. Obwohl diese Werte auch innerhalb des ParameterResolvers gesetzt werden könnten, wird in diesem Beitrag ein anderer Weg beschritten.

Hier kommen nun die Personas ins Spiel. Dies sind Prototypen einer bestimmten Nutzergruppe und besitzen konkrete Eigenschaften und Verhalten. An dieser Stelle sind insbesondere die konkreten Eigenschaften der Persona von Interesse. Statt eine Instanz vom Typ PersonEntity mit festgelegten Werten zu befüllen, werden die Eigenschaften einer Persona auf die Parameter gemappt.

Das Extension Model von JUnit 5 beinhaltet das Interface InvocationInterceptor, dessen Implementierungen an diversen Stellen während der Testaufführung aufgerufen werden können. Mit dem InvocationInterceptor, Test-Umgebungen zu verändern, die Test-Ausführung zu verändern oder zu unterbinden und auch alle Test-Parameter zu verändern.

public interface InvocationInterceptor extends Extension {

	default <T> T interceptTestClassConstructor(Invocation<T> invocation,
			ReflectiveInvocationContext<Constructor<T>> invocationContext, ExtensionContext extensionContext)
			throws Throwable {
		return invocation.proceed();
	}

	default void interceptBeforeAllMethod(Invocation<Void> invocation,
			ReflectiveInvocationContext<Method> invocationContext, ExtensionContext extensionContext) throws Throwable {
		invocation.proceed();
	}

	default void interceptBeforeEachMethod(Invocation<Void> invocation,
			ReflectiveInvocationContext<Method> invocationContext, ExtensionContext extensionContext) throws Throwable {
		invocation.proceed();
	}

	default void interceptTestMethod(Invocation<Void> invocation, ReflectiveInvocationContext<Method> invocationContext,
			ExtensionContext extensionContext) throws Throwable {
		invocation.proceed();
	}

	default <T> T interceptTestFactoryMethod(Invocation<T> invocation,
			ReflectiveInvocationContext<Method> invocationContext, ExtensionContext extensionContext) throws Throwable {
		return invocation.proceed();
	}

	default void interceptTestTemplateMethod(Invocation<Void> invocation,
			ReflectiveInvocationContext<Method> invocationContext, ExtensionContext extensionContext) throws Throwable {
		invocation.proceed();
	}

	default void interceptDynamicTest(Invocation<Void> invocation, ExtensionContext extensionContext) throws Throwable {
		invocation.proceed();
	}

	default void interceptAfterEachMethod(Invocation<Void> invocation,
			ReflectiveInvocationContext<Method> invocationContext, ExtensionContext extensionContext) throws Throwable {
		invocation.proceed();
	}

	default void interceptAfterAllMethod(Invocation<Void> invocation,
			ReflectiveInvocationContext<Method> invocationContext, ExtensionContext extensionContext) throws Throwable {
		invocation.proceed();
	}

	interface Invocation<T> {
		T proceed() throws Throwable;
	}
}

Da alle Methoden in diesem Interface Default-Methoden sind, müssen eigene Implementierungen nur die gewünschten Methoden überschreibe. In diesem Fall reicht es also aus, die Methode interceptTestMethod zu überschreiben.

public class IdInvocationInterceptor implements InvocationInterceptor {
  ...
  @Override
  public void interceptTestMethod(Invocation<Void> invocation, ReflectiveInvocationContext<Method> invocationContext,
      ExtensionContext extensionContext) throws Throwable {
    Parameter[] parameters = invocationContext.getExecutable().getParameters();
    for (int i = 0, n = parameters.length; i < n; i++) {
      Id id = parameters[i].getAnnotation(Id.class);
      Object argument = invocationContext.getArguments().get(i);
      Field field = argument.getClass().getDeclaredField("id");
      Object value = calculateId(field, id == null ? 1L : id.value());
      if (value != null) {
        field.setAccessible(true);
        field.set(argument, value);
      }
    }
    invocation.proceed();
  }
}

Der IdInvocationInterceptor prüft für jeden Parameter der Test-Methode, ob der aktuelle Wert ein Feld mit dem Namen id besitzt. In diesem Fall wird das Feld auf den Wert 1 gesetzt oder dem Wert aus der Annotation @Id. Damit dies mit dem richtigen Typ (Integer, int, Long, long) passiert, wird in der Methode calculateId eine typspezifische Methode aus einer Map verwendet.

private static final Map<Class<?>, Function<Long, ?>> functions = Map.ofEntries(
  Map.entry(Integer.class, Long::intValue), Map.entry(int.class, Long::intValue),
  Map.entry(Long.class, identity()), Map.entry(long.class, identity()));

private Object calculateId(Field id, long value) {
  return functions.get(id.getType()).apply(value);
}

Die eingangs dargestellen Unit Tests ändern sich kaum. Die ID wird nun durch den IdInvocationInterceptor gesetzt und nicht mehr im Test.

@ExtendWith(PersonParameterResolver.class)
@ExtendWith(IdInvocationInterceptor.class)
class PersonaTest {
  @Test
  void hitchhiking (@Id(42) PersonEntity person) {
    person.setFirstName("Arthur");
    person.setLastName("Dent");
    person.setEmail("arthur.dent@heart-of-gold");
    ...
  }
  @Test
  void illuminatus(@Id(23) PersonEntity person) {
    person.setFirstName("Adam");
    person.setLastName("Weißhaupt");
    person.setEmail("adam.weisshaupt@bayrische-illuminaten.de");
    ...
  }
}

Auf ähnliche Weise kann mit den Details zur Person verfahren werden. Der FakeInvocationInterceptor verwendet die Annotation @Fake um seine Manipulationen konfigurierbar zu gestalten.

@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface Fake {
  PersonaAttribute[] ignore() default {};
  String persona() default "";
}

Die @Fake Annotation besitzt zwei Konfigurationsattribute. Mit dem Attribut ignore können spezielle Attribute der Persona beim Befüllen des Test-Parameters ignoriert werden. Das persona Attribut entscheidet darüber, ob eie spezielle Persona gewählt wird oder zufällig gewürfelt wird.

public class FakeInvocationInterceptor implements InvocationInterceptor {
  @Override
  public void interceptTestMethod(Invocation<Void> invocation,
      ReflectiveInvocationContext<Method> invocationContext,
      ExtensionContext extensionContext) throws Throwable {
    Parameter[] parameters = invocationContext.getExecutable().getParameters();
      Fake annotation = parameters[i].getAnnotation(Fake.class);
      if (annotation != null) {
        Object argument = invocationContext.getArguments().get(i);
        FakePersonaBuilder.on(parameters[i].getType()).ignore(annotation.ignore())
            .build(annotation.persona(), argument);
      }
    invocation.proceed();
  }
}

Im FakeInvocationInterceptor wird der Wert des Test-Parameters an den FakePersonaBuilder übergeben. Anhand des Typs des Parameters und der übergebenen Konfigurationsattribute füllt der FakePersonaBuilder dann alle nicht gesetzten Persona Attribute darin. Was ein Persona Attribut ist und wie der FakePersonaBuilder dies erkennt, wird im Folgebeitrag erklärt.

Die beiden Unit Tests verkürzen sich jetzt merklich, da die gesamte Initialisierung der PersonEntity Instanzen entfällt.

@ExtendWith(PersonParameterResolver.class)
@ExtendWith(IdInvocationInterceptor.class)
@ExtendWith(FakeInvocationInterceptor.class)
class PersonaTest {
  @Test
  void hitchhiking (@Id(42) @Fake(persona="arthur") PersonEntity person) {
    ...
  }
@Test
  void illuminatus(@Id(23) @Fake(persona="adam") PersonEntity person) {
    ...
  }

Das Extension Model von JUnit 5 bietet den Entwicklern sehr gute Mechanismen um kurze, selbsterklärende Tests zu schreiben, indem wiederkehrende Aufgaben unter eine leichtgewichtige Implementierung versteckt werden können.