Parametryzacja testów pomaga w sprawdzeniu wielu przypadków testowych przy pomocy jednego testu. Możemy przygotować dane odzwierciedlające warunki brzegowe i przepuścić je przez wcześniej zdefiniowany “lejek”. Na pewno przyspiesza to testy i pozwala nam uniknąć zbędnej redundancji. Chciałbym przyjrzeć się 4 frameworkom testowym dostępnych dla Javy i sprawdzić w jaki sposób radzą sobie one z parametryzacją. Będą to kolejno JUnit 4, JUnit 5, TestNG, Spock. Zacznijmy od napisania kawałka kodu, który poddamy testom.

"Lejek" testowy dla warunków brzegowych

Kod do testowania

Utwórzmy sobie klasę Human, która będzie posiadała jedną metodę. Jej zadaniem będzie wygenerowanie powitania w zależności od podanego wieku. Oczywiście będzie odbywała się walidacja tej wartości, aby nie dopuścić do sytuacji, że osoba będzie miała ujemną ilość lat. Na potrzeby naszego zadania załóżmy, że ta walidacja została już przetestowana i nie będziemy podawać ujemnych liczb w teście parametryzowanym.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
package pl.devcezz;

public class Human {

  private final int age;

  public Human(final int age) {
    if (age < 0) {
      throw new IllegalArgumentException("Age cannot be negative");
    }
    this.age = age;
  }

  public String greet() {
    if (age < 1) {
      return "Gugu";
    } else if (age < 5) {
      return "Hi";
    } else if (age < 10) {
      return "What’s up?";
    } else if (age < 18) {
      return "Yo!";
    } else if (age < 26) {
      return "G’day";
    } else {
      return "Good morning";
    }
  }
}

W celu lepszego poglądu na wybrane frameworki specjalnie dodam jeden nieprawidłowy zestaw danych, który spowoduje wywalenie się testu. Dowiemy się dzięki temu w jaki sposób framework poinformuje nas o niepowodzeniu.

Na potrzeby tego artykułu korzystam z Javy 11 oraz Mavena.

JUnit 4

Rozpocznijmy od JUnit4. Oczywiście musimy dodać odpowiednią zależność do naszego pom.xml.

1
2
3
4
5
6
<dependency>
  <groupId>junit</groupId>
  <artifactId>junit</artifactId>
  <version>4.12</version>
  <scope>test</scope>
</dependency>

Teraz napiszemy nasz test weryfikujący klasę Human.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
package pl.devcezz;

import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.Parameterized;
import org.junit.runners.Parameterized.Parameters;

import java.util.Arrays;
import java.util.Collection;

import static org.junit.Assert.assertEquals;

@RunWith(Parameterized.class)
public class HumanTest {

  @Parameters
  public static Collection<Object[]> data() {
    return Arrays.asList(new Object[][] {
        { 0, "Gugu" },
        { 1, "Hi" },
        { 4, "Hi" },
        { 5, "Yo!" },
        { 17, "Yo!" },
        { 18, "G’day" },
        { 25, "G’day" },
        { 26, "Good morning" },
        { 57, "Gugu" },
    });
  }

  private final Human human;
  private final String expectedGreeting;

  public HumanTest(final int age, final String expectedGreeting) {
    this.human = new Human(age);
    this.expectedGreeting = expectedGreeting;
  }

  @Test
  public void should_prepare_proper_greeting_depending_on_human_age() {
    String greeting = human.greet();

    assertEquals(expectedGreeting, greeting);
  }
}

Trzeba naprawdę stworzyć sporo kodu, aby utworzyć sparametryzowany test w JUnit 4, co nie? Zacznijmy od początku. Klasę należy oznaczyć adnotacją @RunWith(Parameterized.class), aby JUnit wiedział, że ta klasa będzie obsługiwała testy sparametryzowane. Następnie konieczne jest utworzenie statycznej metody oznaczonej przez @Parameters zwracającej tablicę jednowymiarową (jeżeli jest tylko jeden parametr) lub wielowymiarową (jeśli potrzebna jest większa ilość parametrów). Kolejnym krokiem jest zdefiniowanie pól, które będą odpowiadały naszym parametrom, oraz publicznego konstruktora, który je zainicjalizuje. Oczywiście kolejność danych w metodzie statycznej musi odpowiadać kolejności parametrów konstruktora (zgodność typów oraz wartości). Na koniec piszemy nasz test wykorzystujący wcześniej zdefiniowane pola klasy.

Wynik dla JUnit 4

Domyślne komunikaty o wynikach testu nie należą do najczytelniejszych. Nie mamy żadnej informacji dla jakich parametrów zostały one uruchomione. Jedynie co można odczytać to, który był to test z kolei. Można to zmienić przez zastosowanie name w adnotacji @Parameters. Podajemy mu String z interesującym nas zdaniem, a tam gdzie chcemy wyświetlić przekazane dane stosujemy konwencję {n} (gdzie n to liczba liczona od 0). Może wyglądać to następująco.

@Parameters(name = "Human of age {0} greets using \"{1}\"")

Wynik parametryzowany dla JUnit 4

Na pewno jest to teraz o wiele przyjemniejsze w czytaniu niż poprzednia wersja. Przynajmniej wiadomo z jakimi danymi mamy do czynienia w konkretnym przypadku.

Wielkim minusem rozwiązania dostarczonego przez JUnit 4 jest ogromna ilość “boilerplate” kodu. Wręcz przytłacza ona i powoduje, że łatwo się pogubić w napisaniu takiego testu. Na pewno będzie też ciężko utrzymywać taki kod. Ograniczeniem jest również fakt, że w takiej klasie testowej nie zdefiniujemy już innego testu. Znaczy możemy, ale będzie on wywoływany taką samą ilość razy jak sparametryzowany test.

Te dwa minusy są na tyle znaczące, że warto porzucić JUnita 4 na rzecz innych frameworków. Przejdźmy zatem do zapoznania się z młodszym bratem JUnita, czyli jego wersji piątej.

JUnit 5

Jak zawsze zacznijmy od dodania zależności do Mavena. Przy okazji zachęcam Cię do przeczytania artykułu na temat porównania JUnita z AssertJ. Z niego możemy dowiedzieć się, że JUnit 5 został podzielony na moduły, które mają swoje specjalne przeznaczenie.

1
2
3
4
5
6
<dependency>
  <groupId>org.junit.jupiter</groupId>
  <artifactId>junit-jupiter-api</artifactId>
  <version>5.7.1</version>
  <scope>test</scope>
</dependency>

Kod klasy testowej wygląda następująco.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
package pl.devcezz;

import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;

import java.util.stream.Stream;

import static org.junit.jupiter.api.Assertions.assertEquals;

public class HumanTest {

  @ParameterizedTest
  @MethodSource("data")
  public void should_prepare_proper_greeting_depending_on_human_age(
      int age, String expectedGreeting
  ) {
    Human human = new Human(age);

    String greeting = human.greet();

    assertEquals(expectedGreeting, greeting);
  }

  private static Stream<Arguments> data() {
    return Stream.of(
        Arguments.of(0, "Gugu"),
        Arguments.of(1, "Hi"),
        Arguments.of(4, "Hi"),
        Arguments.of(5, "Yo!"),
        Arguments.of(17, "Yo!"),
        Arguments.of(18, "G’day"),
        Arguments.of(25, "G’day"),
        Arguments.of(26, "Good morning"),
        Arguments.of(57, "Gugu")
    );
  }
}

Ten zapis jest już o wiele przyjemniejszy w czytaniu niż u poprzednika. Wystarczy, że dany test oznaczymy adnotacją @ParameterizedTest oraz podamy mu źródło danych. W naszym przypadku będzie to statyczna metoda data (warto zwrócić uwagę, że może być ona prywatna). W metodzie testowej trzeba podać parametry odpowiadające przekazanym danym. Oczywiście nie musimy się ograniczać tylko do takiego sposobu dostarczania danych. Możemy wykorzystać następujące adnotacje:

  • @ValueSource - pozwala podać tablicę wszystkich typów prymitywnych, String czy Class;
  • @NullSource - przekazuje pojedynczą wartość null do testu;
  • @EmptySource - przekazuje pusty String do testu;
  • @NullAndEmptySource - połączenie dwóch powyższych adnotacji;
  • @EnumSource - przekazuje wszystkie wartości zawarte w podanym enumie;
  • @CsvSource - obsługuje tablicę String, które podane są w formacie CSV;
  • @CsvFileSource - zamiast podawać dane w kodzie możemy je załadować z pliku CSV;
  • @MethodSource - podejście użyte powyżej, podanie danych przez metodę;
  • @ArgumentsSource - dostarczenie danych z klasy, która implementuje interfejs ArgumentsProvider;
  • istnieje możliwość stworzenia własnej adnotacji dostarczającej dane w wybrany przez nas sposób;

Zobaczmy jak wyglądają nasze testy po uruchomieniu.

Wynik dla JUnit 5

Wygląda to trochę przyjaźniej niż w JUnit 4. Przynajmniej widzimy jakie argumenty zostały użyte w danym teście. Jednak czytelniejszą opcją jest i tak tworzenie własnego komunikatu. Wygląda to praktycznie identycznie jak w JUnit 4.

@ParameterizedTest(name = "Human of age {0} greets using \"{1}\"")

Wynik parametryzowany dla JUnit 5

Uzyskaliśmy naprawdę przyjemne zdania dla ludzkiego oka. Jedynym mankamentem jest nazwa metody, którą także możemy zmienić. Używa się do tego adnotacji @DisplayName, którą zamieszczamy nad metodą testową.

@DisplayName("Should prepare proper greeting depending on human age")

Jedynym problemem jest fakt, że musimy zachować spójność pomiędzy nazwą metody a wartością w adnotacji. Natomiast jeżeli nie będzie pilnował nas żaden automat, typu kompilator, to niemal pewne jest, że w przyszłości się one rozjadą przy pierwszej lepszej modyfikacji.

TestNG

Standardowo dodajemy zależność do Mavena.

1
2
3
4
5
6
<dependency>
  <groupId>org.testng</groupId>
  <artifactId>testng</artifactId>
  <version>7.4.0</version>
  <scope>test</scope>
</dependency>

Napiszmy teraz klasę testową w TestNG.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
package pl.devcezz;

import org.testng.annotations.DataProvider;
import org.testng.annotations.Test;

import static org.testng.Assert.assertEquals;

public class HumanTest {

  @Test(dataProvider = "data")
  public void should_prepare_proper_greeting_depending_on_human_age(
      int age, String expectedGreeting
  ) {
    Human human = new Human(age);

    String greeting = human.greet();

    assertEquals(greeting, expectedGreeting);
  }

  @DataProvider(name = "data")
  public Object[][] dataMethod() {
    return new Object[][] { 
        { 0, "Gugu" }, 
        { 1, "Hi" },
        { 4, "Hi" },
        { 5, "Yo!" },
        { 17, "Yo!" },
        { 18, "G’day" },
        { 25, "G’day" },
        { 26, "Good morning" },
        { 57, "Gugu" }
    };
  }
}

Wygląda to jak połączenie JUnita 4 oraz 5. Nie mamy dedykowanej adnotacji do testów parametryzowanych, wykorzystujemy podstawową adnotację @Test. Jako parametr dataProvider adnotacji @Testpodajemy nazwę przekazaną w @DataProvider. Nazwa metody może mieć dowolną nazwę, ale musi zwracać tablicę jedno albo dwuwymiarową typu Object.

Wynik dla TestNG

Wyniki są dosyć czytelne, ponieważ widzimy całą nazwę metody wraz z przekazanymi do niej argumentami. Jednak zmiana domyślnego nazewnictwa jest dosyć problematyczna. Trzeba zaimplementować interfejs ITest, aby nadpisać nazwę metody. Nie jest to najwygodniejsze rozwiązanie i na pewno nie tak proste jak w JUnit 5.

Samo pisanie testów nie jest najtrudniejsze, jednak trzeba pilnować nazw dostarczanych jako Stringi w adnotacjach. Nie ma tam żadnej wspomagającej walidacji, więc łatwo o pomyłkę. O błędzie dowiemy się dopiero podczas uruchamiania testów.

Spock

Ostatni, ale oczywiście nie najgorszy - Spock. Dopiszmy zależność do pom.xml.

1
2
3
4
5
6
<dependency>
  <groupId>org.spockframework</groupId>
  <artifactId>spock-core</artifactId>
  <version>2.0-M5-groovy-3.0</version>
  <scope>test</scope>
</dependency>

Tutaj musimy posługiwać językiem Groovy. Nie przysporzył mi on wielu problemów na początku nauki, jednak ciężko mi coś więcej powiedzieć, ponieważ wykorzystuję go tylko w testach jednostkowych.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
package pl.devcezz

import spock.lang.Specification

class HumanTest extends Specification {

  def 'Should human greets \"#expectedGreeting\" when he is at age of #age'() {
    given:
      Human human = new Human(age)

    when:
      def greeting = human.greet()

    then:
      greeting == expectedGreeting

    where:
      age || expectedGreeting
      0   || "Gugu"
      1   || "Hi"
      4   || "Hi"
      5   || "Yo!"
      17  || "Yo!"
      18  || "G’day"
      25  || "G’day"
      26  || "Good morning"
      57  || "Gugu"
  }
}

Test naprawdę wygląda przejrzyście, przemawia sam za siebie. Mamy wyodrębnione cztery sekcje: given, when, then i where. Ogromny plusem jest fakt, że nazwa metody testowej może być pisana prozą bez oznaczania jej dodatkowymi adnotacjami. Przy pomocy składni #nazwaZmiennej możemy wstawiać poszczególne dane do nazwy testu. Nasze przypadki nie są oddzielone od metody testowej, znajdują się w jej ciele. Warte uwagi jest, że klasa testowa musi rozszerzać klasę Specification, aby można było ją wykorzystywać do pisania testów jednostkowych. Pisząc przypadki testowe nie jesteśmy zobowiązani, aby pamiętać o napisaniu parametrów w metodzie testowej. Spock zrobi to za nas co może jest detalem, ale cieszy.

Wynik dla Spock

Całość czyta się naprawdę lekko. Jest to ogromna zaleta jeżeli chcielibyśmy podzielić się raportem z testów z biznesem. Myślę, że każdy domyśli się o co chodzi w danym przypadku biznesowym po przeczytaniu takiego raportu.

Podsumowanie

Mam nadzieję, że w wielkim skrócie pokazałem jak można tworzyć testy parametryczne w różnych frameworkach i zainteresowałem Cię tym tematem. Nie da się ukryć, że osobiście jestem wielkim fanem Spocka. Jest on naprawdę wygodny w użyciu, nie trzeba pisać w nim skomplikowanych asercji. Z tego powodu również i Ciebie zachęcam do skorzystania z jego dobrodziejstw. Dodatkowo zajrzyj do źródeł na samym końcu artykułu, aby dowiedzieć się więcej na temat JUnita, TestNG oraz Spocka w kwestii testów parametrycznych. Na koniec zamieszczam link do GitHuba, w którym znajdziesz wszystkie przykłady znajdujące się w tym artykule.

Źródła: