JUnit 5 – Testy parametryzowane

Sama możliwość wielokrotnego uruchomienia testu, to nie wszystko. W pewnych przypadkach chcielibyśmy, by nasz test został uruchomiony dla różnych zestawów danych. Ma to sens, jeżeli nasze dane wejściowe reprezentują pewien spójny podzbiór przypadków testowych. Mówiąc inaczej mamy dane, które mają sprawdzić jedną ze ścieżek.

Listing 1. Każdy test sprawdza jedną ze ścieżek – JUnit 4

public class FizzBuzzJUnit4WithoutRunnersTest {

	private FizzBuzz sut;
	
	@Before
	public void setup() {
		sut = new FizzBuzz();
	}

	@Test
	public void shouldReturnFizzBuzzIfDiv3And5() throws Exception {
		assertEquals("FizzBuzz", sut.fizzBuzz(15));
		assertEquals("FizzBuzz", sut.fizzBuzz(30));
		assertEquals("FizzBuzz", sut.fizzBuzz(150));
	}

	@Test
	public void shouldReturnBuzzIfDiv5() throws Exception {
		assertEquals("Buzz", sut.fizzBuzz(5));
		assertEquals("Buzz", sut.fizzBuzz(10));
		assertEquals("Buzz", sut.fizzBuzz(50));
	}

	@Test
	public void shouldReturnFizzIfDiv3() throws Exception {
		assertEquals("Fizz", sut.fizzBuzz(3));
		assertEquals("Fizz", sut.fizzBuzz(6));
		assertEquals("Fizz", sut.fizzBuzz(99));

	}
	@Test
	public void shouldReturnVal() throws Exception {
		assertEquals("2", sut.fizzBuzz(2));
		assertEquals("8", sut.fizzBuzz(8));
		assertEquals("11", sut.fizzBuzz(11));

	}
}

Ten kod ma dość dużą powtarzalność. Poszczególne testy odpowiadają, za jeden przypadek, ale kod duplikuje się. Używając JUnit 4, możemy, to poprawić za pomocą odpowiedniego runnera.

Listing 2. Testy parametryzowane w JUnit 4

@RunWith(JUnitParamsRunner.class)
public class FizzBuzzJUnit4WithRunnersTest {

	private FizzBuzz sut;

	@Before
	public void setup() {
		sut = new FizzBuzz();
	}

	@Test
	@Parameters(value = {"15", "30", "150"})
	public void shouldReturnFizzBuzzIfDiv3And5(int p) throws Exception {
		assertEquals("FizzBuzz", sut.fizzBuzz(p));
	}

	@Test
	@Parameters(value = {"5", "10", "50"})
	public void shouldReturnBuzzIfDiv5(int p) throws Exception {
		assertEquals("Buzz", sut.fizzBuzz(p));
	}

	@Test
	@Parameters(value = {"3", "6", "99"})
	public void shouldReturnFizzIfDiv3(int p) throws Exception {
		assertEquals("Fizz", sut.fizzBuzz(p));
	}


	@Test
	@Parameters(value = {"2", "8", "11"})
	public void shouldReturnVal(int p) throws Exception {
		assertEquals("" + p, sut.fizzBuzz(p));
	}
}

Już jest zdecydowanie lepiej.

Uwaga, choć metody testowe nadal wyglądają bardzo podobnie i można by brnąć w kolejną refaktoryzację, to każda z nich dotyczy innego warunku. Nie należy pisać ubertestu, który z wykorzystaniem parametrów, będzie obsługiwać wszystkie przypadki.

Tyle tylko, że wpadliśmy z deszczu pod rynnę, lub jak kto woli z COBOLa w phpa. Największą wadą runnerów JUnit 4, jest brak możliwości łączenia kliku z nich. Zatem do naszego testu nie dołączymy już na przykład runnera Springa.

Jeszcze inaczej do problemu podchodzi TestNG. Tu mamy do dyspozycji mechanizm związany z podawaniem parametrów z pliku testng.xml oraz mechanizm związany z adnotacją @DataProvider i własnością dataProvider w @Test:

Listing 3. Testy parametryzowane w TestNG

public class FizzBuzzTestNGParametrizedTest {

    private FizzBuzz sut;

    @BeforeTest
    public void setup() {
        sut = new FizzBuzz();
    }

    @Test(dataProvider = "3 and 5")
    public void shouldReturnFizzBuzzIfDiv3And5(int p) throws Exception {
        assertEquals("FizzBuzz", sut.fizzBuzz(p));
    }

    @Test(dataProvider = "5 only")
    public void shouldReturnBuzzIfDiv5(int p) throws Exception {
        assertEquals("Buzz", sut.fizzBuzz(p));
    }

    @Test(dataProvider = "3 only")
    public void shouldReturnFizzIfDiv3(int p) throws Exception {
        assertEquals("Fizz", sut.fizzBuzz(p));
    }

    @Test(dataProvider = "other values")
    public void shouldReturnVal(int p) throws Exception {
        assertEquals(p + "", sut.fizzBuzz(p));
    }

    @DataProvider(name = "3 and 5")
    public Object[][] data3And5() {
        return new Object[][]{
                new Object[]{15},
                new Object[]{30},
                new Object[]{150}
        };
    }

    @DataProvider(name = "3 only")
    public Object[][] data3Only() {
        return new Object[][]{
                new Object[]{3},
                new Object[]{6},
                new Object[]{99}
        };
    }

    @DataProvider(name = "5 only")
    public Object[][] data5Only() {
        return new Object[][]{
                new Object[]{5},
                new Object[]{10},
                new Object[]{50}
        };
    }

    @DataProvider(name = "other values")
    public Object[][] dataOtherValues() {
        return new Object[][]{
                new Object[]{2},
                new Object[]{8},
                new Object[]{11}
        };
    }
}

Mechanizm ten jest dość elastyczny. Jeżeli chcemy zdefiniować dostawcę w innej klasie, to musi być on statyczną metodą w klasie z konstruktorem bezargumentowym. Dodatkowo podajemy jeszcze dataProviderClass. Nadal jednak musimy samodzielnie zaimplementować pobieranie danych z zewnętrznych źródeł np. plików csv.

Testy parametryzowane w JUnit 5

W JUnit 5 w M4 wprowadzono możliwość tworzenia testów parametryzowanych. Wymaga to od nas trochę zachodu, bo na początku musimy dodać odpowiedni moduł do naszej konfiguracji, ale efekty są świetne.

Konfiguracja

Obsługa testów parametryzowanych została wydzielona do osobnego modułu. By z niego skorzystać musimy zmodyfikować konfigurację w mavenie, dodając odpowiednią zależność:

Listing 4. Dodatkowa zależność

    
        
            org.junit.jupiter
            junit-jupiter-params
            5.0.0-M4
        
    

Dodanie zależności do modułu junit-jupiter-params otwiera na drogę do pisania testów parametrzowanych. Samo pisanie testów wymaga od nas podania adnotacji @ParametrizedTest oraz odpowiedniej adnotacji określającej źródło parametrów. Adnotacja ta jest traktowana jak adnotacja @Test.

Istnieje kilka możliwości podania parametrów. Przyjrzymy się tym, które wydają się najprzydatniejsze. O przydatności metody decyduje moje doświadczenie z TestNG.

Parametry zaszyte w kodzie

Najprostszą metodą jest zaszycie parametrów bezpośrednio w kodzie, za pomocą adnotacji @ValueSource. Adnotacja ta może przyjąć listę int-ów albo String-ów.

Listing 5. Parametryzacja za pomocą @ValueSource

public class FizzBuzzJUnit5ParametrizedValueSourceTest {

    private FizzBuzz sut;

    @BeforeEach
    public void setup() {
        sut = new FizzBuzz();
    }

    @ParameterizedTest
    @ValueSource(ints = {15, 30, 150})
    public void shouldReturnFizzBuzzIfDiv3And5(int p) throws Exception {
        assertEquals("FizzBuzz", sut.fizzBuzz(p));
    }

    @ParameterizedTest
    @ValueSource(ints = {5, 10, 50})
    public void shouldReturnBuzzIfDiv5(int p) throws Exception {
        assertEquals("Buzz", sut.fizzBuzz(p));
    }

    @ParameterizedTest
    @ValueSource(ints = {3, 6, 99})
    public void shouldReturnFizzIfDiv3(int p) throws Exception {
        assertEquals("Fizz", sut.fizzBuzz(p));
    }

    @ParameterizedTest
    @ValueSource(ints = {2, 8, 11})
    public void shouldReturnVal(int p) throws Exception {
        assertEquals(p + "", sut.fizzBuzz(p));
    }
}

Metoda ta jest wygodna dla niewielkiego zbioru danych.

Metoda fabrykujaca

Kolejnym sposobem na podanie argumentów do naszego testu jest użycie metody fabrykującej. Działa to na tej samej zasadzie co dostawcy w TestNG. Wystarczy w adnotacji @MethodSource wskazać na metodę z tej samej klasy:

Listing 6. Metoda jako źródło danych

public class FizzBuzzJUnit5ParametrizedMethodSourceTest {

    private FizzBuzz sut;

    @BeforeEach
    public void setup() {
        sut = new FizzBuzz();
    }

    @ParameterizedTest
    @MethodSource(names = "data3And5")
    public void shouldReturnFizzBuzzIfDiv3And5(int p) throws Exception {
        assertEquals("FizzBuzz", sut.fizzBuzz(p));
    }

    @ParameterizedTest
    @MethodSource(names = "data5Only")
    public void shouldReturnBuzzIfDiv5(int p) throws Exception {
        assertEquals("Buzz", sut.fizzBuzz(p));
    }

    @ParameterizedTest
    @MethodSource(names = "data3Only")
    public void shouldReturnFizzIfDiv3(int p) throws Exception {
        assertEquals("Fizz", sut.fizzBuzz(p));
    }

    @ParameterizedTest
    @MethodSource(names = "dataOtherValues")
    public void shouldReturnVal(int p) throws Exception {
        assertEquals(p + "", sut.fizzBuzz(p));
    }

    static Stream data3And5() {
        return Stream.of(15, 30, 150);
    }

    static Stream data3Only() {
        return Stream.of(3, 6, 99);
    }

    static Stream data5Only() {
        return Stream.of(5, 10, 50);
    }

    static Stream dataOtherValues() {
        return Stream.of(2, 8, 11);
    }
}

Należy tu zwrócić uwagę na dwie rzeczy. Pierwsza to konieczność wykorzystania klasy Stream do produkcji danych. Niestety w przypadku próby użycia np. IntStream otrzymamy błąd w czasie uruchomienia testów. Druga to konieczność wykorzystania metod statycznych.

Dostawcy

Wykorzystując metody, jesteśmy ograniczeni do tych, które są w klasie testowej. Jeżeli chcemy zdefiniować źródło danych w osobnej klasie, to musimy wykorzystać adnotację @ArgumentSource. Jako parametr przyjmuje ona klasę, która implementuje interfejs ArgumentsProvider. Interfejs ten ma jedną metodę arguments, w której mamy dostęp do ContainerExtensionContext. Co to jest, kiedy indziej.

Listing 7. Wykorzystanie klasy jako dostawcy

public class FizzBuzzJUnit5ParametrizedArgumentSourceTest {

    private FizzBuzz sut;

    @BeforeEach
    public void setup() {
        sut = new FizzBuzz();
    }

    @ParameterizedTest
    @ArgumentsSource(Data3And5.class)
    public void shouldReturnFizzBuzzIfDiv3And5(int p) throws Exception {
        assertEquals("FizzBuzz", sut.fizzBuzz(p));
    }

    @ParameterizedTest
    @ArgumentsSource(Data5Only.class)
    public void shouldReturnBuzzIfDiv5(int p) throws Exception {
        assertEquals("Buzz", sut.fizzBuzz(p));
    }

    @ParameterizedTest
    @ArgumentsSource(Data3Only.class)
    public void shouldReturnFizzIfDiv3(int p) throws Exception {
        assertEquals("Fizz", sut.fizzBuzz(p));
    }

    @ParameterizedTest
    @ArgumentsSource(DataOtherValues.class)
    public void shouldReturnVal(int p) throws Exception {
        assertEquals(p + "", sut.fizzBuzz(p));
    }

    static class Data3And5 implements ArgumentsProvider {
        @Override
        public Stream<? extends Arguments> arguments(ContainerExtensionContext containerExtensionContext) throws Exception {
            return Stream.of(15, 30, 150).map(ObjectArrayArguments::create);
        }
    }

    static class Data3Only implements ArgumentsProvider {
        @Override
        public Stream<? extends Arguments> arguments(ContainerExtensionContext containerExtensionContext) throws Exception {
            return Stream.of(3, 6, 99).map(ObjectArrayArguments::create);
        }
    }

    static class Data5Only implements ArgumentsProvider {
        @Override
        public Stream<? extends Arguments> arguments(ContainerExtensionContext containerExtensionContext) throws Exception {
            return Stream.of(5, 10, 50).map(ObjectArrayArguments::create);
        }
    }

    static class DataOtherValues implements ArgumentsProvider {
        @Override
        public Stream<? extends Arguments> arguments(ContainerExtensionContext containerExtensionContext) throws Exception {
            return Stream.of(2, 8, 11).map(ObjectArrayArguments::create);
        }
    }
}

Tu użyłem wewnętrznej klasy statycznej, ale tylko po to, by całość mieściła się na jednym listingu. Wygląda to już całkiem dobrze.

Format CSV

Kolejnym rodzajem źródeł danych jest format CSV. Możemy go użyć na dwa sposoby. Pierwszy z nich jest obsługiwany za pomocą adnotacji @CsvSource. Działa ona na tej samej zasadzie co @ValueSource. Jako wartość przyjmuje tablicę String-ów. Jeden String jeden rekord.

Listing 8. Format CSV w pliku źródłowym

public class FizzBuzzJUnit5ParametrizedCsvSourceTest {

    private FizzBuzz sut;

    @BeforeEach
    public void setup() {
        sut = new FizzBuzz();
    }

    @ParameterizedTest
    @CsvSource({"15", "30", "150"})
    public void shouldReturnFizzBuzzIfDiv3And5(int p) throws Exception {
        assertEquals("FizzBuzz", sut.fizzBuzz(p));
    }

    @ParameterizedTest
    @CsvSource({"5", "10", "50"})
    public void shouldReturnBuzzIfDiv5(int p) throws Exception {
        assertEquals("Buzz", sut.fizzBuzz(p));
    }

    @ParameterizedTest
    @CsvSource({"3", "6", "99"})
    public void shouldReturnFizzIfDiv3(int p) throws Exception {
        assertEquals("Fizz", sut.fizzBuzz(p));
    }

    @ParameterizedTest
    @CsvSource({"2", "8", "11"})
    public void shouldReturnVal(int p) throws Exception {
        assertEquals(p + "", sut.fizzBuzz(p));
    }
}

Format ten będzie sprawdzać się, gdy nie chcemy tworzyć osobnych dostawców. Jedna format CSV, to przede wszystkim pliki. Do obsługi plików w tym formacie jest dedykowana adnotacja @CsvFileSource.

Listing 9. Obsługa plików CSV

public class FizzBuzzJUnit5ParametrizedCsvFileSourceTest {

    private FizzBuzz sut;

    @BeforeEach
    public void setup() {
        sut = new FizzBuzz();
    }

    @ParameterizedTest
    @CsvFileSource(resources = "/data3And15.csv")
    public void shouldReturnFizzBuzzIfDiv3And5(int p) throws Exception {
        assertEquals("FizzBuzz", sut.fizzBuzz(p));
    }

    @ParameterizedTest
    @CsvFileSource(resources = "/data5Only.csv")
    public void shouldReturnBuzzIfDiv5(int p) throws Exception {
        assertEquals("Buzz", sut.fizzBuzz(p));
    }

    @ParameterizedTest
    @CsvFileSource(resources = "/data3Only.csv")
    public void shouldReturnFizzIfDiv3(int p) throws Exception {
        assertEquals("Fizz", sut.fizzBuzz(p));
    }

    @ParameterizedTest
    @CsvFileSource(resources = "/dataOtherValues.csv")
    public void shouldReturnVal(int p) throws Exception {
        assertEquals(p + "", sut.fizzBuzz(p));
    }
}

Ścieżka liczona jest w tym przypadku od katalogu test/resources w mavenie.

Inne źródła

Używając adnotacji @EnumSource możemy potraktować typ wyliczeniowy jako źródło danych. JUnit 5 potrafi też samodzielnie dokonać konwersji pomiędzy String, a podstawowymi typami takimi jak liczny, enumy czy daty w różnych odmianach.

Konwerery

Korzystając z różnych źródeł danych, staniemy przed problemem konwersji do jakiegoś złożonego typu. Przykładowo odczytując dane z pliku CSV, chcemy zamienić pojedynczy rekord na opis transakcji. W innym przypadku dane ze źródła mogą być w innym formacie niż oczekiwany.

W takim wypadku musimy napisać własny konwerter oraz określić, że parametr testu ma zostać skonwertowany.

Listing 10. Test z konwersją HEX na DEC

public class FizzBuzzJUnit5ParametrizedConvertersTest {

    private FizzBuzz sut;

    @BeforeEach
    public void setup() {
        sut = new FizzBuzz();
    }

    @ParameterizedTest
    @ValueSource(strings = {"f", "1E", "96"})
    public void shouldReturnFizzBuzzIfDiv3And5(@ConvertWith(HexToInt.class) int p) throws Exception {
        assertEquals("FizzBuzz", sut.fizzBuzz(p));
    }

    @ParameterizedTest
    @ValueSource(strings = {"5", "a", "32"})
    public void shouldReturnBuzzIfDiv5(@ConvertWith(HexToInt.class) int p) throws Exception {
        assertEquals("Buzz", sut.fizzBuzz(p));
    }

    @ParameterizedTest
    @ValueSource(strings = {"3", "6", "63"})
    public void shouldReturnFizzIfDiv3(@ConvertWith(HexToInt.class) int p) throws Exception {
        assertEquals("Fizz", sut.fizzBuzz(p));
    }

    @ParameterizedTest
    @ValueSource(strings = {"2", "8", "B"})
    public void shouldReturnVal(@ConvertWith(HexToInt.class) int p) throws Exception {
        assertEquals(p + "", sut.fizzBuzz(p));
    }

    static class HexToInt extends SimpleArgumentConverter {
        @Override
        protected Object convert(Object o, Class<?> targetType) {
            assertEquals(int.class, targetType, "Can only convert to int");
            return Integer.decode("0x" + o.toString());
        }
    }
}

Nasz konwerter musi implementować interfejs ArgumentConverter. Tu wykorzystuję klasę SimpleArgumentConverter, która wyciąga typ docelowy z parametru ParameterContext. Następnie argument testu należy oznaczyć jako @ConvertWith i już możemy się cieszyć.

Podsumowanie

To co mnie urzekło w obsłudze testów parametryzowanych w JUnit 5, to ogromna elastyczność. Twórcy tego rozwiązania wzięli pod uwagę doświadczenia użytkowników. Wybrali najpopularniejsze sposoby użycia mechanizmu parametrów, po czym zaimplementowali je w module. Brawo.

6 myśli na temat “JUnit 5 – Testy parametryzowane

  1. Szału nie ma. Tym bardziej że wszystko to, dało się zrobić z dataproviderem w TestNG. I nie podoba mi się że trzeba określić że jest to @ParametrizedTest. Skoro została dodana adnotacja która określa sposób dodania parametrów, to z automatu powinno działać ze zwykłym @Test.

    BTW. Wyczerpiesz cały temat przed devcrowd? 🙂

  2. @krzysiek050, SRP 🙂 Adnotacja opisująca sposób podania danych nie powinna służyć do innych rzeczy. Szczególnie, że z punktu widzenia „bebechów” silnika, jej przetwarzane odbywa się w zupełnie innym miejscu niż przetwarzanie @ParametrizedTest.

    Edit:

    Ze zwykłym @Test nie może działać, ponieważ testy parametryzowane powinny być odpalane przez dedykowany silnik/rozszerzenie. Standardowy mechanizm powinien je ignorować.

  3. Nie widzę tutaj złamania SRP. Adnotacja do argumentów, opisuje sposób dostarczenia danych. Z kolei adnotacja @Test służy do oznaczenia po prostu testu. Silnik powinien najpierw wczytać wszystkie dane na temat pojedynczego testu, w tym czy jest parametryzowany, powtarzalny etc., a potem uruchomić odpowiedni silnik który ten test przeprowadzi.

  4. Silników masz wiele i są różne i niezależne. Będę o tym jeszcze pisał, bo to jest jeden z kluczowych elementów całego systemu. Jednak mały przykład. Jeżeli mamy adnotację @Test i metoda przyjmuje parametr, to jak standardowy silnik, który nie obsługuje parametrów ma wiedzieć jak taką metodę wywołać? Olać ją? Wyrzucić błąd?

  5. W części dotyczącej JUnit 4 wykorzystałeś bibliotekę JUnitParams, a nie wbudowanej w JUnit możliwości parametryzowania testów, co było bardzo dobrym wyborem, ale warto to nadmienić.
    Dzięki temu nie jest do końca prawdziwe stwierdzenie że tracimy możliwość łączenia JUnitParams z innymi bibliotekami, w szczególności że springiem. Runner JUnitParams faktycznie musi być jedynym runnerem w danej klasie ale bardzo dużo bibliotek umożliwia wykorzystanie mechanizmu Rule.
    Przypadek że springiem został przez nas opisany jako że, tak jak wspomniałeś, jest najpopularniejszy:
    Przykład oraz opis tego problemu.

  6. @woprzech, dzięki za uzupełnienie informacji 🙂 Choć to kolejny raz jak okazuje się, że nie można nadążyć za bibliotekami.

Napisz odpowiedź

Twój adres email nie zostanie opublikowany. Pola, których wypełnienie jest wymagane, są oznaczone symbolem *

To create code blocks or other preformatted text, indent by four spaces:

    This will be displayed in a monospaced font. The first four 
    spaces will be stripped off, but all other whitespace
    will be preserved.
    
    Markdown is turned off in code blocks:
     [This is not a link](http://example.com)

To create not a block, but an inline code span, use backticks:

Here is some inline `code`.

For more help see http://daringfireball.net/projects/markdown/syntax