Na zachętę zdradzimy od razu część odpowiedzi: Testy sparametryzowane mogą sprawić, iż testy jednostkowe będą bardziej intuicyjne i eleganckie. Ułatwiając przetestowanie większej liczby indywidualnych przypadków, zwiększają zasięg i pokrycie kodu testami i ostatecznie podnoszą jakość naszego kodu.

Na prostym przykładzie przedstawimy, jak można ulepszyć konwencjonalne testy jednostkowe za pomocą nowych możliwości JUnit 5. Ta wersja JUnit oferuje nam w tym zakresie szerokie spektrum rozwiązań - od prostych, ale ograniczonych, po bardzo elastyczne, lecz wymagające nieco większej ilości boilerplate code.

Zadanie

W kodzie naszego projektu znajduje się statyczna metoda isWeekend:

boolean isWeekend(java.time.DayOfWeek dayOfWeek);

Nazwa metody w zasadzie zdradza nam, co ona robi:

Dla każdego dnia tygodnia udzielana jest odpowiedź na pytanie, czy jest to weekend.

Aby uniknąć nieporozumień kulturowych, chcemy zweryfikować nasze zrozumienie weekendu za pomocą testów jednostkowych. Czy jest to coś skomplikowanego? Nie, wystarczy porównać każdy z siedmiu dni tygodnia z oczekiwanym rezultatem!

Rozwiązania ze starego świata

Zanim zanurzymy się świat sparametryzowanych testów, przypomnijmy sobie, jakie użyteczne rozwiązania można zrealizować konwencjonalnymi metodami znanymi z JUnit 4 i wcześniejszych. Pierwsza wersja wykorzystuje pętle do sprawdzenia wszystkich możliwych wartości:

    @Test
    public void isWeekend() {
        for (DayOfWeek dayOfWeek : Arrays.asList(
                MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY)) {
            Assertions.assertFalse(isWeekend(dayOfWeek));
        }
        for (DayOfWeek dayOfWeek : Arrays.asList(SATURDAY, SUNDAY)) {
            Assertions.assertTrue(isWeekend(dayOfWeek));
        }
    }
Wersja (1) – wszystkie przypadki w jednej metodzie

Dzięki temu reprezentacja jest stosunkowo zwarta. Dopóki test przechodzi pomyślnie, nie ma żadnych problemów. W przypadku błędu, test nie mówi jednak zbyt wiele: które dokładnie przypadki zawodzą? Czy jest to błąd w setupie, czy dotyczy poszczególnych dni?

Można by zatem przyjąć alternatywne podejście polegające na stworzeniu osobnej metody testowej dla każdego dnia tygodnia:

    @Test
    public void isWeekend_Monday_false() {
        Assertions.assertFalse(DateUtils.isWeekend(MONDAY));
    }

    @Test
    public void isWeekend_Tuesday_false() {
        Assertions.assertFalse(DateUtils.isWeekend(TUESDAY));
    }

    @Test
    public void isWeekend_Wednesday_false() {
        Assertions.assertFalse(DateUtils.isWeekend(WEDNESDAY));
    }

    @Test
    public void isWeekend_Thursday_false() {
        Assertions.assertFalse(DateUtils.isWeekend(THURSDAY));
    }

    @Test
    public void isWeekend_Friday_false() {
        Assertions.assertFalse(DateUtils.isWeekend(FRIDAY));
    }

    @Test
    public void isWeekend_Saturday_true() {
        Assertions.assertTrue(DateUtils.isWeekend(SATURDAY));
    }

    @Test
    public void isWeekend_Sunday_true() {
        Assertions.assertTrue(DateUtils.isWeekend(SUNDAY));
    }
Wersja (2) – Wyliczenie możliwych przypadków

Wersja (2) na pierwszy rzut oka wydaje się być dość długa. Ale w porównaniu z wersją (1) stanowi poprawę w dwóch istotnych kwestiach:

  1. Każdy przypadek testowy jest wykonywany i wyświetlany indywidualnie przez Test Suite. Nieudane przypadki testowe są widoczne na pierwszy rzut oka.

    JUnit w Eclipse - Piątek pokazuje błąd
    Wykonanie testu w Eclipse: piątek to nie weekend
  2. Cykl życia testu JUnit jest respektowany: zazwyczaj inicjalizację chcielibyśmy przeprowadzić w metodzie @BeforeEach. Jednak w wersji (1), zostałaby ona wykonana tylko raz dla wszystkich przypadków. Pomimo że nie odgrywa to większej roli w naszym małym przykładzie, to inicjalizacja musi być powtarzana dla każdego przypadku testowego z osobna. Można to byłoby rozwiązać poprzez ręczne wywołanie metody setUp() na początku każdego przebiegu pętli. Ale byłoby to jednocześnie działaniem przeciwko filozofii JUnit, ograniczające mechanizmy, które są mocno związane z cyklem życia testu, jak np. inicjalizacja oparta na adnotacji w Mockito lub Spring.

Wadą wariantu (2) jest silna redundancja, zwłaszcza jeśli testy stają się dłuższe niż jedna linia. Problem ten może jednak zostać rozwiązany poprzez wyciągnięcie kodu testującego do metody pomocniczej:

    @Test public void isWeekend_Mo() { doTestIsWeekend(MONDAY,    false); }
    @Test public void isWeekend_Tu() { doTestIsWeekend(TUESDAY,   false); }
    @Test public void isWeekend_We() { doTestIsWeekend(WEDNESDAY, false); }
    @Test public void isWeekend_Th() { doTestIsWeekend(THURSDAY,  false); }
    @Test public void isWeekend_Fi() { doTestIsWeekend(FRIDAY,    false); }
    @Test public void isWeekend_Sa() { doTestIsWeekend(SATURDAY,  true); }
    @Test public void isWeekend_Su() { doTestIsWeekend(SUNDAY,    true); }

    private void doTestIsWeekend(DayOfWeek dayOfWeek, boolean isWeekendExpected) {
        Assertions.assertEquals(isWeekendExpected, DateUtils.isWeekend(dayOfWeek));
    }
Wersja (3) - w stylu testów sparametryzowanych

Poszczególne metody testowe są napisane nieco niekonwencjonalnie w jednej linijce. Optycznie, ale również funkcjonalnie, jesteśmy już dość blisko pożądanego stanu docelowego.

Nadal jest to trochę irytujące, że trzeba wybrać unikalną nazwę metody dla każdego przypadku testowego. Przecież powinno wystarczyć podanie parametrów w formie listy. Nadal musimy też określić każdy przypadek testowy osobno. Dynamiczne dodawanie, usuwanie i modyfikacja przypadków testowych również nie mogą być zrealizowane w ten sposób. Wszystko to (i wiele więcej) zapewniają nam sparametryzowane testy w JUnit 5…

Testy sparametryzowane - lek na całe zło?

Ideą testów sparametryzowanych jest rozdzielenie danych testowych i logiki testowej. Powinna istnieć możliwość dodawania kolejnych przypadków do istniejących bez znaczącego wpływu na ich przejrzystość i strukturę. Technicznie jest to osiągane za pomocą dwóch mechanizmów:

  1. Dane wejściowe i oczekiwane są dostarczane do metody testowej jako parametry.
  2. Źródło danych jest konfigurowane przez adnotacje.

Rozważmy to na przykładzie. Test jednoprzypadkowy

    @Test
    void isOdd_5_true() {
        Assertions.assertTrue(NumberUtils.isOdd(5));
    }

zamienia się w wieloprzypadkowy i od razu wzbudza większe zaufanie w poprawność testowanej metody:

    @ParameterizedTest
    @ValueSource(ints = { 1, 3, 5, -1, -3, 15, 10001 })
    void isOdd_true(int number) {
        Assertions.assertTrue(NumberUtils.isOdd(number));
    }

Adnotacja @ParameterizedTest oznacza, że test oczekuje danych wejściowych. @ValueSource jest jedną z kilku możliwych adnotacji dostarczających dane testowe.

W najprostszych przypadkach: @ValueSource

Niestety jeśli teraz oczekujesz, że adnotacja ‘@ValueSource’ może zwracać dowolne obiekty, czeka Cię rozczarowanie. W rzeczywistości istnieją poważne ograniczenia:

  1. Tylko jeden parametr może być przekazany do testu.
  2. Możliwe typy tego jednego parametru są ograniczone do:
    • (literal) Strings
    • primitive types: short, byte, int, long, float, double, char, boolean
    • Klasy

W szczególności nie można przekazać stworzonych instancji klas. Ograniczenie te zasadniczo odzwierciedla ograniczenia funkcjonalności adnotacji w Javie. Projektanci języka zamierzali zachować prostotę, na przykład aby umożliwić przetwarzanie adnotacji w czasie kompilacji.

Jednak w przypadku testów sparametryzowanych, ograniczenia te są frustrujące. @ValueSource może więc być używany tylko w określonych prostych przypadkach.

Zanim przejdziemy do wszechstronnego źródła danych, które pozwala na pełną elastyczność za cenę niewielkiego nakładu pracy (@MethodSource), chcielibyśmy przyjrzeć się źródłu specjalnie dla typów Enum. Intuicja podpowiada, że powinno ono doskonale pasować do naszego przykładu z dniami tygodnia i weekendu.

@EnumSource

Za pomocą @EnumSource możesz uruchomić testy na wszystkich możliwych wartościach danego typu enum. Jeśli chcesz ograniczyć wybór tylko do niektórych wartości, możesz podać adnotacji ich listę za pomocą parametru names. To prowadzi nas do czwartej wersji testu isWeekend:

    @ParameterizedTest
    @EnumSource(value = DayOfWeek.class,
        names = { "MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY" })
    public void isWeekend_false(DayOfWeek dayOfWeek)  {
        Assertions.assertFalse(DateUtils.isWeekend(dayOfWeek));
    }

    @ParameterizedTest
    @EnumSource(value = DayOfWeek.class, names = { "SATURDAY", "SUNDAY" })
    public void isWeekend_true(DayOfWeek dayOfWeek)  {
        Assertions.assertTrue(DateUtils.isWeekend(dayOfWeek));
    }
Wersja (4) – @EnumSource

Test jest zwarty i czytelny, ale ma jedną dużą wadę: wartości parametru names muszą być podane jako String a nie jako wartości Enum. Jest to trochę jak krok wstecz z poziomu języka, który kompilator i IDE rozumieją. Ma to między innymi następujące wady:

  • Literówki i inne błędy w pisowni nie są natychmiast odnajdywane
  • Utrudnione wspomaganie i podpowiadanie wartości przez IDE
  • Refactoring (zmiana nazwy wartości Enum) nie działa już niezawodnie i automatycznie

Enumy są dozwolone jako wartości w adnotacjach w Javie. Więc po co zatem to obejście w postaci przekazywania Stringów? Dzieje się tak, ponieważ adnotacje nie obsługują parametrów generycznych, w związku z czym konkretna klasa Enum musi być znana adnotacji podczas kompilacji.

Jeśli tak, czy nie powinno być możliwe stworzenie ulepszonego źródła dla enum DayOfWeek?

Własne źródło danych

Hammer Dzięki dobrze przemyślanej architekturze rozszerzeń JUnit 5 można przy minimalnym wysiłku zdefiniować własne źródła danych. Braki w wersji (4) można usunąć tworząc własną adnotację @DayOfWeekSource:

    @ParameterizedTest
    @DayOfWeekSource({ MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY })
    public void isWeekend_false(DayOfWeek dayOfWeek)  {
        Assertions.assertFalse(DateUtils.isWeekend(dayOfWeek));
    }

    @ParameterizedTest
    @DayOfWeekSource({ SATURDAY, SUNDAY })
    public void isWeekend_true(DayOfWeek dayOfWeek)  {
        Assertions.assertTrue(DateUtils.isWeekend(dayOfWeek));
    }
Wersja (5) – specjalne źródło danych `DayOfWeek`

Adnotacja @DayOfWeekSource jest zdefiniowana w następujący sposób:

@Retention(RetentionPolicy.RUNTIME)
@ArgumentsSource(DayOfWeekArgumentsProvider.class)
public @interface DayOfWeekSource {
    DayOfWeek[] value();
}

RetentionPolicy.RUNTIME zapewnia tutaj, że wartości adnotacji mogą być uzyskane w rutime przez refleksję. Za pomocą @ArgumentsSource definiujemy klasę, która dostarcza dane testowe (DayOfWeekArgumentsProvider). Klasa ta musi zaimplementować interfejs ArgumentsProvider. Jest to dość proste: wszystko czego potrzebujemy to metoda provideArguments, która zwraca Stream argumentów:

public class DayOfWeekArgumentsProvider implements ArgumentsProvider,
         AnnotationConsumer<DayOfWeekSource> {
    private DayOfWeek[] parameters;

    @Override
    public Stream<? extends Arguments> provideArguments(ExtensionContext context) {
        return Arrays.stream(parameters).map(Arguments::of);
    }

    @Override
    public void accept(DayOfWeekSource source) {
        parameters = source.value();
    }
}

Na koniec użyliśmy interfejsu AnnotationConsumer, aby odczytać wartości z adnotacji @DayOfWeekSource i tymczasowo zapisać je w polu parameters.

Pomimo całej euforii związanej z własnym źródłem danych, trzeba zauważyć jeden fakt: źródła z dokładnie jednym parametrem działają dla tego przykładu, ponieważ są tylko dwa możliwe oczekiwane wyniki: true i false. Jeśli oczekiwany wynik byłby inny dla każdego dnia tygodnia, nasz przykład z @EnumSource lub @DayOfWeekSource nie miałby zastosowania. Na szczęście jest jeszcze jedno źródło danych, w którym zostawiamy za sobą wszystkie te ograniczenia…

Wszechstronny: @MethodSource

To prowadzi nas do szóstej, finalnej wersji testu weekendowego:

    @ParameterizedTest
    @MethodSource("isWeekendTestData")
    public void isWeekend(DayOfWeek dayOfWeek, boolean isWeekendExpected) {
        Assertions.assertEquals(isWeekendExpected, DateUtils.isWeekend(dayOfWeek));
    }

    private static Stream<Arguments> isWeekendTestData() {
        return Stream.of(
                Arguments.of(MONDAY,    false),
                Arguments.of(TUESDAY,   false),
                Arguments.of(WEDNESDAY, false),
                Arguments.of(THURSDAY,  false),
                Arguments.of(FRIDAY,    false),
                Arguments.of(SATURDAY,  true),
                Arguments.of(SUNDAY,    true));
    }
Wersja (6) – wersja ostateczna z @MethodSource

Za pomocą @MethodSource możemy określić metodę, która zwraca dane testowe jako Stream lub List. Nazwa metody jest ponownie przekazywana jako String, a nie jako referencja do metody. W tym przypadku jednak referencje przynajmniej nie są rozproszone po całym kodzie, a jedynie po dwóch metodach, które są idiomatycznie umieszczone w parze.1

Mamy teraz pełną swobodę w zakresie generowania danych. Na przykład, za pomocą pętli zagnieżdżonej da się przetestować wszystkie kombinacje z dwóch lub więcej parametrów wejściowych. Stałe listy z kodu produkcyjnego mogą być również bezpośrednio użyte w kodzie testowym.

Wniosek

Niestety, nadzieja na to, aby pozwolić parametrom testowym istnieć tylko w świecie adnotacji, nie ziściła się. Wynika to głównie z (celowych) podstawowych cech adnotacji Java. Jeżeli jednak @ValueSource i @EnumSource traktuje się tylko jako udogodnienia przydatne w szczególnych przypadkach, to początkowe wrażenie, że testy sparametryzowane ograniczają się do najprostszych scenariuszy, szybko mija. Dzięki adnotacji @MethodSource mamy bowiem czysty i niezwykle elastyczny sposób na stworzenie sparametryzowanych testów.


  1. Parametr @MethodSource jest opcjonalny. Jeżeli zostanie pominięty, JUnit wyszuka metodę o tej samej nazwie co metoda testowa.