“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.
2 thoughts on “Personas für Unit Tests”
Comments are closed.