JUnit 5 – Extension Model

JUnit jest najpopularniejszym frameworkiem (lub – jak kto woli – biblioteką) stosowaną przy tworzeniu testów jednostkowych w Javie. W jego nowej wersji – JUnicie 5, miejsce Rules oraz test runnerów zajął nowy koncept – Extension Model. Daje on bardzo duże możliwości oraz elastyczność, ale dzieje się to kosztem gotowej funkcjonalności, którą zapewniały Rules z JUnita 4.

Jak to drzewiej bywało?

W JUnicie 4 mieliśmy do dyspozycji test runnery oraz Rules.

Test runnery odpowiadały za uruchamianie testów i jeśli nie określiliśmy tego inaczej, to domyślnie wykorzystywany był test runner JUnitowy. Jeśli jednak chcieliśmy skorzystać z jakiegoś innego – gotowego lub customowego – test runnera, na przykład bardzo popularny był (i nadal zresztą jest) test runner do frameworka Mockito, to musieliśmy użyć adnotacji @RunWith:

@RunWith(MockitoJUnitRunner.class)

Natomiast Rules były wykorzystywane do tego, aby w klasach i metodach testowych korzystać z jakichś gotowych funkcjonalności.

Załóżmy, że we wszystkich metodach testowych, w wielu klasach testowych chcielibyśmy przed uruchomieniem testów przygotować specjalny plik tekstowy. W obrębie jednej klasy testowej, żeby nie powtarzać takiego kodu w każdej metodzie testowej, można by taki kod umieścić w metodzie z adnotacją @Before (w JUnicie 5 jest to adnotacja @BeforeEach). Dzięki temu odpowiednie działanie byłoby wykonane przed uruchomieniem każdej metody testowej.

Natomiast w tym przykładzie chcemy z takiego kodu korzystać w wielu klasach. Bez sensu byłoby zatem ten sam kod umieszczać w każdej klasie, w metodzie z adnotacją @Before. Wówczas kod nie powtarzałby się co prawda w obrębie klasy, ale w obrębie na przykład paczki – już tak. Żeby temu zaradzić możemy skorzystać z funkcjonalności, którą zapewnia nam zestaw Rules.

Żeby z nich skorzystać, należy najpierw utworzyć instancję interesującej nas klasy (już istniejącej, gotowej z paczki org.junit.rules) i umieścić nad nią adnotację @Rule:

@Rule
private TemporaryFolder temporaryFolder = new TemporaryFolder();

Tutaj mamy przykład klasy TemporaryFolder, dzięki której można utworzyć jakiś plik albo katalog, który jest automatycznie usuwany po zakończeniu danego testu jednostkowego:

temporary folder dev foundry blog programowanie java spring kursy
Przykład kilku metod z klasy TemporaryFolder

I tutaj, jak widać, korzystamy z gotowych, zdefiniowanych metod i możemy to robić w dowolnej ilości klas testowych bez ryzyka powtarzania kodu.


Jeśli chcesz prześledzić zmiany krok po kroku w formie wideo, to zapraszamy na nasz kanał na YouTube.


Wracamy do teraz – Junit 5

W JUnicie 5 mamy za to do dyspozycji Extension Model. Działa on na podobnej zasadzie do Rules, ale zamiast korzystać z gotowej funkcjonalności, mamy do dyspozycji zestaw interfejsów, których metody implementujemy, aby zaczepić się w odpowiednim miejscu life cycle (cyklu życia) danego testu, żeby wykonać tam jakieś działanie.

Cykl życia testu oznacza po prostu kolejność operacji, które są kolejno uruchamiane przez mechanizm przetwarzający daną metodę testową (w przypadku JUNita 5 jest to silnik JUnit Jupiter) w ramach danego testu.

I ta kolejność na przykładzie interfejsów z paczki org.junit.jupiter.api.extension, których metody możemy implementować, prezentuje się mniej więcej tak:

  • TestInstancePostProcessor
  • TestTemplateInvocationContext
  • ExecutionCondition
  • BeforeAllCallback
  • BeforeEachCallback
  • ParameterResolver
  • BeforeTestExecutionCallback
  • AfterTestExecutionCallback
  • TestExecutionExceptionHandler
  • AfterEachCallback
  • AfterAllCallback

Lub jeśli ktoś woli uproszczoną wersję graficzną:

junit 5 extension model dev foundry blog programowanie java spring kursy
JUnit 5 Extension Model – Lifecycle Callbacks

Pomarańczowym kolorem zaznaczone są znane nam adnotacje umieszczane nad metodami, które powinny być uruchamiane przed wszystkimi testami z danej klasy (adnotacja @BeforeAll), przed każdym pojedynczym testem w klasie (@BeforeEach), po każdym pojedynczym teście (@AfterEach) i po wszystkich testach w klasie (@AfterAll).

Natomiast niebieskim kolorem zaznaczone są Callbacks, będące interfejsami z cyklu życia danego testu. I to właśnie ich metody możemy implementować – wówczas będą one uruchamiane w takiej kolejności, jak na grafice powyżej.

Zatem, jeśli na przykład zaimplementujemy metodę interfejsu BeforeEachCallback, to kod takiej metody będzie wykonywany po metodzie oznaczonej adnotacją @BeforeAll, a przed metodą oznaczoną adnotacją @BeforeEach.

Praktyka czyni mistrza

Zobaczmy teraz w jaki sposób możemy to wykorzystać w jakimś przykładzie. Mamy już przygotowaną klasę testową OrderTest:

class OrderTest {

    private Order order;

    @BeforeEach
    void initializeOrder() {
        System.out.println("Before each");
        order = new Order();
    }

    @AfterEach
    void cleanUp() {
        System.out.println("After each");
        order.cancel();
    }

    @Test
    void testAssertArrayEquals() {

        //given
        int[] ints1 = {1, 2, 3};
        int[] ints2 = {1, 2, 3};

        //then
        assertArrayEquals(ints1, ints2);

    }

    @Test
    void mealListShouldBeEmptyAfterCreationOfOrder() {
        //then
        assertThat(order.getMeals(), empty());
        assertThat(order.getMeals().size(), equalTo(0));
        assertThat(order.getMeals(), hasSize(0));
        assertThat(order.getMeals(), emptyCollectionOf(Meal.class));

    }

    @Test
    void addingMealToOrderShouldIncreaseOrderSize() {

        //given
        Meal meal = new Meal(15, "Burger");
        Meal meal2 = new Meal(5, "Sandwich");

        //when
        order.addMealToOrder(meal);

        //then
        assertThat(order.getMeals(), hasSize(1));
        assertThat(order.getMeals(), contains(meal));
        assertThat(order.getMeals(), hasItem(meal));
        assertThat(order.getMeals().get(0).getPrice(), equalTo(15));

    }

    @Test
    void removingMealFromOrderShouldDecreaseOrderSize() {

        //given
        Meal meal = new Meal(15, "Burger");

        //when
        order.addMealToOrder(meal);
        order.removeMealFromOrder(meal);

        //then
        assertThat(order.getMeals(), hasSize(0));
        assertThat(order.getMeals(), not(contains(meal)));

    }

    @Test
    void mealsShouldBeInCorrectOrderAfterAddingThemToOrder() {

        //given
        Meal meal1 = new Meal(15, "Burger");
        Meal meal2 = new Meal(5, "Sandwich");

        //when
        order.addMealToOrder(meal1);
        order.addMealToOrder(meal2);

        //then
        assertThat(order.getMeals(), containsInAnyOrder(meal2, meal1));

    }

    @Test
    void testIfTwoMealListsAreTheSame() {

        //given
        Meal meal1 = new Meal(15, "Burger");
        Meal meal2 = new Meal(5, "Sandwich");
        Meal meal3 = new Meal(11, "Kebab");

        List<Meal> meals1 = Arrays.asList(meal1, meal2);
        List<Meal> meals2 = Arrays.asList(meal1, meal2);

        //then
        assertThat(meals1, is(meals2));

    }

}

Znajduje się w niej sześć metod testowych i dwie metody z adnotacjami @BeforeEach oraz @AfterEach.

Są to dość małe testy z prostymi asercjami, których zadaniem jest sprawdzenie działania klasy Order:

class Order {

    private List<Meal> meals = new ArrayList<>();

    void addMealToOrder(Meal meal) {
        this.meals.add(meal);
    }

    void removeMealFromOrder(Meal meal) {
        this.meals.remove(meal);
    }

    List<Meal> getMeals() {
        return meals;
    }

    void cancel() {
        this.meals.clear();
    }


}

Powiązaną klasą jest również klasa Meal:

class Meal {

    private int price;
    private String name;

    Meal(int price) {
        this.price = price;
    }

    Meal(int price, String name) {
        this.price = price;
        this.name = name;
    }

}

I załóżmy, że chcielibyśmy napisać kod, który byłby wykonywany przed każdym wywołaniem metody z adnotacją @BeforeEach oraz po każdym wywołaniu metody z adnotacją @AfterEach. Dotyczyłoby to zatem każdego testu jednostkowego w klasie OrderTest.

Żeby to zrobić, musimy najpierw utworzyć nową klasę – BeforeAfterExtension. Ta klasa powinna implementować interfejsy BeforeEachCallback oraz AfterEachCallback (można się posiłkować grafiką powyżej):

public class BeforeAfterExtension implements BeforeEachCallback, AfterEachCallback {

}

Kolejnym krokiem jest zaimplementowanie metod zadeklarowanych w tych interfejsach. Będzie to metoda beforeEach dla interfejsu BeforeEachCallback oraz metoda afterEach dla interfejsu AfterEachCallback.

I żeby niepotrzebnie nie komplikować tego przykładu, w środku tych metod wyświetlimy prosty komunikat:

public class BeforeAfterExtension implements BeforeEachCallback, AfterEachCallback {

    @Override
    public void beforeEach(ExtensionContext extensionContext) {
        System.out.println("Inside before each extension");
    }

    @Override
    public void afterEach(ExtensionContext extensionContext) {
        System.out.println("Inside after each extension");
    }

}

I to tyle! Teraz możemy powrócić do klasy testowej OrderTest.

Jak zastosować dane rozszerzenie?

Aby skorzystać z rozszerzenia, które utworzyliśmy wcześniej, musimy umieścić nad klasą testową specjalną adnotację @ExtendWith. Jako argument tej adnotacji podajemy nazwę naszej klasy rozszerzenia i dodajemy końcówkę .class:

@ExtendWith(BeforeAfterExtension.class)
class OrderTest {

Wówczas takie rozszerzenie będzie stosowane dla całej klasy, a więc dla wszystkich metod testowych. Możemy jednak ograniczyć je tylko do jednej metody. Wtedy adnotacje umieścić należy nad wybraną metodą.

Gdy teraz uruchomimy wszystkie testy z klasy OrderTest, to w konsoli otrzymamy komunikat:

Inside before each extension
Inside after each extension
Inside before each extension
Inside after each extension
Inside before each extension
Inside after each extension
Inside before each extension
Inside after each extension
Inside before each extension
Inside after each extension
Inside before each extension
Inside after each extension

Widać zatem, że para wiadomości:

Inside before each extension
Inside after each extension

została powtórzona sześć razy – tyle ile jest metod testowych w tej klasie. A więc wszystko się zgadza.

Jednak żeby upewnić się, że na pewno metody z naszego rozszerzenia uruchamiają się przed i po metodach z adnotacjami @BeforeEach i @AfterEach, to do nich również dodamy proste komunikaty:

@BeforeEach
void initializeOrder() {
    System.out.println("Before each");
    order = new Order();
}

@AfterEach
void cleanUp() {
    System.out.println("After each");
    order.cancel();
}

Teraz uruchomimy już tylko jeden test – testAssertArrayEquals, żeby nie widzieć wszystkich komunikatów powtórzonych sześciokrotnie:

Inside before each extension
Before each
After each
Inside after each extension

I widzimy, że zgodnie z planem, metody z naszej klasy rozszerzeń – BeforeAfterExtension – były uruchomione odpowiednio przed oraz po metodach z adnotacjami @BeforeEach oraz @AfterEach.

Nie wszystko stracone

Dla osób, które tęsknią za dawną funkcjonalnością Rules, a jednocześnie chcą się przenieść na JUnita 5 – proszę nie panikować! Społeczność programistyczna przygotowała listę silników testowych oraz rozszerzeń, z których można korzystać we własnych projektach: https://github.com/junit-team/junit5/wiki/Third-party-Extensions

Na powyższej liście znajdziemy między innymi pozycję zatytułowaną „JUnit Extensions”: https://glytching.github.io/junit-extensions/index.

Znajduje się tam kilka rozszerzeń, które są odpowiednikami popularnych Rules z JUNita 4. Wśród nich jest także znany nam z początku wpisu TemporaryFolder: https://glytching.github.io/junit-extensions/temporaryFolder

Podsumowanie

Chociaż przykład podany w tym wpisie był dość prosty, to myślę, że dość dobrze pokazuje jak duże możliwości daje Extension Model. I tak naprawdę tylko od nas zależy w jaki sposób zdecydujemy się wykorzystać jego potencjał.

Kod z przykładami do powyższego wpisu znajdziesz na githubie.


Podziel się tym wpisem:

2 komentarze do wpisu „JUnit 5 – Extension Model”

Dodaj komentarz