JUnit 5 – Rozszerzenia i wstrzykiwanie zależności, część 2
W poprzednim wpisie zajmowaliśmy się standardowymi implementacjami ParameterResolver. Na zakończenie wspomniałem, że własna implementacja wymaga konfiguracji na poziomie silnika testów. Takie podejście jest uciążliwe i wiąże się m.in. z implementacją własnego silnika lub hackowaniem istniejącego. To jest trochę krzywe. Twórcy biblioteki JUnit 5 mając świadomość, że takie rozwiązanie jest kiepskie, przygotowali mechanizm rozszerzeń.
Rozszerzenia można z grubsza podzielić na dwie grupy. Pierwsza to rozszerzenia deklaratywne. Tym przyjrzymy się dzisiaj. Druga to rozszerzenia oparte o mechanizm SPI. W tym przypadku wykorzystujemy rejestr serwisów, a rozszerzenia są rejestrowane w momencie startu kontenera. Przykładem tego typu rozszerzenia jest junit-jupiter-params, z którym mieliśmy już do czynienia. Tym mechanizmem zajmiemy się później (znacznie później, jak go rozkminię 😉 ).
Problem
Zanim przejdę do opisu mechanizmu rozszerzeń, to pozwolę sobie na zdefiniowanie problemu, który będzie stanowić tło naszych rozważań. Mamy pewną ilość testów integracyjnych, z których część może być uruchomiona tylko i wyłącznie na określonych maszynach w ramach CI. Maszyna identyfikuje się za pomocą zmiennej systemowej. Ponadto, chcemy by testy te, można było uruchomić na maszynie deweloperskiej oznaczonej jako DEV. Programiści nie muszą nic konfigurować na swoich maszynach.
Inaczej mówiąc, jeżeli jest obecna zmienna środowiskowa ci\_name, to test będzie uruchomiony, jeżeli jej wartość odpowiada wartości z adnotacji. Jeżeli nie jest definiowana albo jest równa DEV, to test też jest uruchamiany.
Rozwiązanie
Zacznę trochę od dupy strony, czyli od przygotowania mechanizmu, który będzie odpowiadać za oznaczanie testów do uruchomienia. W tym celu należy zaimplementować interfejs TestExecutionCondition. Ma on jedną metodę, evaluate, która przyjmuje TestExtensionContext i zwraca ConditionEvaluationResult. Kod wygląda następująco:
Listing 1. Implementacja TestExecutionCondition
public class SingleTestIntegrationFilter implements TestExecutionCondition {
private static final String CI_NAME = Optional.ofNullable(
System.getenv("ci_name")
)
.orElse("DEV");
@Override
public ConditionEvaluationResult evaluate(TestExtensionContext context) {
return context.getTestMethod()
.filter(m -> isAnnotated(m, Integration.class))
.map(m -> m.getAnnotation(Integration.class).value())
.filter(((Predicate<String>) s -> CI_NAME.equals(s)).or(s1 -> CI_NAME.equals("DEV")))
.map($ -> ConditionEvaluationResult.enabled(""))
.orElse(ConditionEvaluationResult.disabled(format("This test %s cannot be run on %s.", context.getTestMethod().map(Method::getName).get(), CI_NAME)));
}
}
Przeszukujemy metody oznaczone adnotacją @Integration:
Listing 2. Adnotacja @Integration
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD})
public @interface Integration {
String value() default "DEV";
}
Jeżeli wartość w adnotacji jest inna niż zadeklarowana w zmiennej ci\_name, to zwracamy ConditionEvaluationResult.disabled z odpowiednim komunikatem.
Interfejs TestExecutionCondition jest jednym z kilku, które możemy wykorzystać. Zanim jednak przejdę do omówienia innych interfejsów, chciałbym pokazać, jak uruchamiamy nasze rozszerzenie.
Uruchomienie rozszerzenia
Jak wspomniałem na samym początku, w tym wpisie zajmiemy się rozszerzeniami deklaratywnymi. Deklaratywność oznacza tu, że musimy w kodzie explicite wskazać których rozszerzeń używamy. Można pomyśleć o tym mechanizmie jak o runnerach. Różnica polega na tym, że w JUnit 5 możemy użyć wielu rozszerzeń dla jednej klasy testowej.
By uruchomić test z rozszerzeniem należy użyć adnotacji @ExtendWith.
Listing 3. Przykładowy test z rozszerzeniem
@ExtendWith(SingleTestIntegrationFilter.class)
public class FizzBuzzJUnit5CiEnvFilteredIntegrationTest {
private FizzBuzz sut;
@BeforeEach
public void setup() {
sut = new FizzBuzz();
}
@Test
@Integration("CI")
public void shouldReturnFizzBuzzIfDiv3And5() throws Exception {
assertEquals("FizzBuzz", sut.fizzBuzz(15));
}
@Test
@Integration("GUI")
public void shouldReturnBuzzIfDiv5() throws Exception {
assertEquals("Buzz", sut.fizzBuzz(5));
}
@Test
@Integration("NOGUI")
public void shouldReturnFizzIfDiv3() throws Exception {
assertEquals("Fizz", sut.fizzBuzz(3));
}
@Test
@Integration
public void shouldReturnVal() throws Exception {
assertEquals("2", sut.fizzBuzz(2));
}
}
Mamy tu cztery testy integracyjne, z których każdy może zostać uruchomiony tylko na konkretnych środowiskach CI. Przy czym ostatni z nich, shouldReturnVal, może zostać uruchomiony jedynie na środowisku deweloperskim.
Jak można zauważyć ten mechanizm, nie jest jakoś skomplikowany. Jego zaletą jest prostota implementacji oraz jasność intencji. Dla tego konkretnego przypadku konfiguracja tagów mogłaby być wystarczająca, ale wymagałaby wdrożenia profili. Profile są specyficzne dla mavena i trudno nimi zarządzać z poziomu poma. Złożoność będzie szybko rosła wraz ze wzrostem liczby środowisk. W dodatku użycie filtrów opartych o tagi albo nazwy metod uniemożliwia raportowanie przyczyny wyłączenia testu.
Ogólniejsza forma tego mechanizmu jest reprezentowana przez ContainerExecutionCondition. Wykorzystując ten interfejs, możemy decydować czy należy uruchomić wszystkie testy z kontenera (klasy testowej). Oczywiście implementacja logiki będzie podobna, a różnice istnieją jedynie na poziomie nazw wykorzystywanych klas.
Inne punkty rozszerzeń
Oczywiście rozszerzenia to nie tylko decydowanie o uruchomieniu bądź nie testu. Ciekawym przypadkiem może być wprowadzenie własnej implementacji ParameterResolver.
Własny ParameterResolver
Załóżmy, że nasz test potrzebuje pewnych informacji ze świata. Możemy podać je na kilka sposobów. Najprościej jest stworzyć odpowiedniego mocka i przekazać go jakoś do testu. Jakoś w naszym przypadku oznacza parametr metody testowej. Oczywiście jest to przypadek uproszczony, ponieważ mock może być też polem klasy testowej. W takim wypadku potrzebujemy zaimplementować jeszcze inny interfejs. O tym jednak za chwilę. Najpierw zaimplementujmy nasz własny ParameterResolver:
Listing 4. Własna implementacja ParameterResolver
public class MockParameterResolver implements ParameterResolver {
@Override
public boolean supports(ParameterContext parameterContext, ExtensionContext extensionContext) throws ParameterResolutionException {
return parameterContext.getParameter().isAnnotationPresent(Mock.class);
}
@Override
public Object resolve(ParameterContext parameterContext, ExtensionContext extensionContext) throws ParameterResolutionException {
return Mockito.mock(parameterContext.getParameter().getType());
}
}
Cep wydaje się skomplikowanym narzędziem, jeżeli porównamy go z tym kodem. Oczywiście w praktyce należałoby ten kod wzbogacić o kilka elementów np. cache. Tak też robi Mockito Extension, dostępne w repozytorium przykładów.
Konfiguracja instancji klasy testowej
Wspomniałem w poprzednim podpunkcie, że mock może być też polem klasy testowej. By wstrzyknąć takie pole musimy obsłużyć utworzoną, ale jeszcze nie uruchomioną instancję klasy testowej. W tym celu możemy wykorzystać TestInstancePostProcessor:
Listing 5. Implementacja TestInstancePostProcessor
public class MockFieldInjector implements TestInstancePostProcessor {
@Override
public void postProcessTestInstance(Object o, ExtensionContext extensionContext) throws Exception {
MockitoAnnotations.initMocks(o);
}
}
Wywołania wokół testów
Kolejną grupę rozszerzeń stanowią te, które będą uruchamiane wokół testów. Działają jak Before/After, ale pozwalają na operowanie na instancji klasy testowej albo testu. Oczywiście mamy tu zachowane nazewnictwo i tak mamy do dyspozycji następujące rozszerzenia (podstaw Before/After za XXX):
- XXXAllCallback – rozszerzenie, to zostanie wywołane przed/po wszystkich testach w ramach danego kontenera (klasy testowej).
- XXXEachCallback – rozszerzenie, to zostanie wywołane przed/po każdym teście. Przy czym jako test rozumiemy tu metodę testową oraz zdefiniowane w danej klasie testowej metody oznaczone jako @BeforeEach/@AfterEach, uruchamiane wokół metody testowej.
- XXXTestExecutionCallback – podobnie jak poprzednie rozszerzenie, to też zostanie wywołane przed/po każdym teście, ale w tym przypadku test oznacza tylko metodę testową.
Generalna zasada, której musimy przestrzegać, jest taka, że implementacja rozszerzenia musi posiadać konstruktor bezargumentowy.
Uruchamianie wielu rozszerzeń w jednej klasie
Analizując przypadek z wykorzystaniem mocków, pojawia się pytanie, czy możemy rozszerzyć pojedynczą klasę testową na wiele sposobów? To znaczy, czy w pojedynczej klasie możemy użyć wielu rozszerzeń. Oczywiście tak. Jest to znaczny postęp w porównaniu do runnerów z JUnit4, które mogły występować tylko pojedynczo. Wystarczy wielokrotnie użyć adnotacji @ExtendedWith, z odpowiednimi parametrami:
Listing 6. Test z wieloma rozszerzeniami
@ExtendWith(MockParameterResolver.class)
@ExtendWith(MockFieldInjector.class)
public class FizzBuzzJUnit5MockDiTest {
@Mock
private FizzBuzz sut;
@Test
public void shouldReturnFizzBuzzIfDiv3And5() throws Exception {
when(sut.fizzBuzz(anyInt())).then(invocation -> "FizzBuzz");
assertEquals("FizzBuzz", sut.fizzBuzz(15));
}
@Test
public void shouldReturnBuzzIfDiv5(@Mock FizzBuzz sut) throws Exception {
when(sut.fizzBuzz(anyInt())).then(invocation -> "Buzz");
assertEquals("Buzz", sut.fizzBuzz(5));
}
@Test
public void shouldReturnFizzIfDiv3(@Mock FizzBuzz sut) throws Exception {
when(sut.fizzBuzz(anyInt())).then(invocation -> "Fizz");
assertEquals("Fizz", sut.fizzBuzz(3));
}
@Test
public void shouldReturnVal(@Mock FizzBuzz sut) throws Exception {
when(sut.fizzBuzz(anyInt())).then(invocation -> invocation.getArgument(0).toString());
assertEquals("2", sut.fizzBuzz(2));
}
}
Podsumowanie
Mechanizm rozszerzeń dostępny w JUnit 5 jest znacznie bardziej elastyczny niż runnery z JUnit 4. Oferuje nam znacznie więcej możliwości i co najważniejsze wykorzystanie go, nie jest skomplikowane. Z drugiej strony, i jest to wada całego JUnita 5, nie wykorzystamy tego mechanizmu w Javie 7 i wcześniejszych.