14 kwietnia 2021

Spock vs JUnit

Testy automatyczne to potężne narzędzie w arsenale programisty. Umożliwiają sprawdzenie zgodności kodu z wymaganiami biznesowymi. Jeżeli są dobrze napisane i pokrywają przewidywane przypadki biznesowe, to pozwalają wychwycić większość błędów już podczas tworzenia. Są również nieocenione w momencie refactoringu, gdy tworzą pewnego rodzaju siatkę ochronną, która zabezpiecza przed wprowadzeniem niepożądanych skutków ubocznych podczas modyfikacji kodu.

Nowoczesne frameworki do testów ułatwiają ich pisanie oraz pozwalają na lepszą utrzymywalność. Jednymi z popularniejszych rozwiązań na rynku służących do testowania kodu napisanego w Javie są Spock i JUnit.

W artykule skupię się na porównaniu obu frameworków, ale nie będę wskazywać konkretnego zwycięzcy. Uważam, że wybór pomiędzy tymi dwoma technologiami to kwestia, która może zależeć od uwarunkowań projektowych, osobistych preferencji itp. Każda organizacja, zespół ma różne potrzeby i wymagania. W podsumowaniu przedstawiam swojego faworyta, ale artykuł nie narzuca żadnej z wymienionych technologii.

 

Ale o co tyle hałasu?

 

Spock

Framework do testowania kodu napisanego w języku Java, Groovy lub Kotlin. Powstał w 2008 roku z inicjatywy Petera Niederwiesera oraz Luke’a Daleya. Obecnie najnowszą stabilną wersją jest 1.3, jednak prawdopodobnie zostanie niedługo zastąpiona przez wersję 2.0, która jest w fazie developmentu. Spock korzysta z Groovy, który jest oparty na JVM. Dla jednych będzie to zaleta, dla innych wada. Z plusów tego rozwiązania warto wymienić np.:

  • łatwe pisanie warunków testowych
  • zwięzłość kodu, testy są krótsze
  • łatwa krzywa wejścia dla osoby programującej w Javie
  • dynamiczne typowanie

Mimo łatwej krzywej wejścia, niektórzy mogą po prostu nie chcieć zagłębiać się w kolejną technologię. Może to stanowić minus. Kontrowersyjne może również wydawać się ignorowanie modyfikatora dostępu. Nie warto tego nadużywać, ale w niektórych sytuacjach może się przydać, np. gdy chcielibyśmy wywołać konstruktor oznaczony jako package private. W przypadku testów JUnit nie uruchomimy takiego testu, ponieważ Java uniemożliwi nam użycie tego konstruktora, jeżeli klasa testowa nie znajduje się w tej samej strukturze pakietów, co kod produkcyjny.

Niektóre osoby uważają, że pisanie testów w innym języku może przynieść więcej szkód niż pożytku, np. przez brak znajomości dobrych praktyk dla języka, który jest używany w testach, ich kod może nie być napisany w optymalny i prawidłowy sposób. Wprowadza to pokusę używania “hacków”.

JUnit

Jest jednym z najpopularniejszych z najpopularniejszych frameworków (o ile nie najpopularniejszym) do tworzenia testów jednostkowych dla języka Java. Historia JUnita sięga 2000 roku. W 2017 roku ukazała się wersja oznaczona numerem 5, która zastąpiła popularnego JUnita 4. Wersja ta przyniosła kilka ciekawych feature’ów, m.in. wsparcie dla Javy w wersji 8 i wyżej czy wsparcie dla testów parametryzowanych. Zainteresowanych szczegółowymi informacjami na temat wprowadzonych zmian odsyłam do dokumentacji. Programista korzystający z tego frameworka, w przeciwieństwie do użytkownika Spocka, nie musi uczyć się nowego języka. Testy w JUnit tworzone są w Javie. Można to traktować jako wadę lub zaletę. Niektóre testy mogą być np. mniej czytelne ze względu na rozbudowane fragmenty kodu Javy.

Organizacja kodu


Na początek przyjrzyjmy się, jak wyglądają przykładowe testy w obu frameworkach.

Oznaczenie testowych metod

Spock

W frameworku Spock, aby metoda była traktowana jako testowa, musi znajdować się w klasie dziedziczącej po Specification. Dodatkowo metoda musi również zawierać co najmniej jeden blok expect lub then.

class ExampleTestClass extends Specification

 

JUnit

W JUnit testy oznaczone są specjalną adnotacją @Test. Metoda, która została tak oznaczona, nie może być statyczna, prywatna oraz nie powinna zwracać żadnej wartości.

 @Test
 void example_test_method() {
 ...
 }

Porównania

Spock

Porównania w Spocku w przeciwieństwie do JUnita to po prostu zwykłe wyrażenia boolean, niewymagające żadnych specjalnych metod. Wszystko to co Groovy jest w stanie przekształcić do takiego wyrażenia, może znajdować się w bloku z porównaniami.

   then:
 result == 10

 

JUnit

JUnit w przeciwieństwie do Spocka używa specjalnych asercji do porównania warunków. Dla niektórych są one nieczytelne i uznawane za jego wadę.

  // then
assertEquals(10, result);

 

Nazwy testów

Dobrze opisane testy mogą stanowić ciekawy dodatek do dokumentacji projektu. W przypadku niektórych projektów częściej zdarza nam się czytać kod, niż go pisać. Czasem natrafiamy na jakiś fragment, który nie jest dla nas czytelny lub na przykład nie pamiętamy, co dana funkcjonalność robiła. Wtedy możemy spojrzeć w testy  (o ile oczywiście wcześniej je posiadamy) i szybciej zrozumieć system.

Spock

Spock posiada opisowe nazwy metod, co poprawia czytelność. Taka forma ułatwia nazywanie testów. Nie musimy zastanawiać się, jaką konwencję nazw metod przyjąć. Dodatkowo stwierdzenie, że testy są swego rodzaju dokumentacją kodu, w tym przypadku jest jeszcze bardziej podkreślone. Przykładowa nazwa:

def "function should return the result of multiplication for given params"()

 

JUnit

W przypadku JUnita opisywanie metod w czytelny sposób stanowi wyzwanie. Brakuje jednej określonej konwencji tak jak w Spocku. Niektórzy np. używają Snake Case’a, inni Camel Case’a, jeszcze inni próbują łączyć style. Co gorsza, czasem jeżeli chcemy dokładnie opisać nasz test, nazwy metod robią się naprawdę długie i ciężkie do odczytania na mniejszych monitorach.

Przykładowa nazwa z użyciem Snake Case’a:

void should_return_result_of_multiplication_for_given_params()

W JUnit 5 wprowadzono adnotacje DisplayName, która pozwala zdefiniować wyświetlaną nazwę dla metody lub klasy w raportach testów. Nie do końca rozwiązuje to problem od strony programisty, ze względu na to, że dalej będziemy widzieć nieczytelne nazwy metod w momencie czytania kodu.

Komunikaty błędów

Jeżeli coś pójdzie nie tak, to chcielibyśmy otrzymać czytelny komunikat błędu, który pozwoli nam szybko zweryfikować, gdzie leży problem i która wartość w teście jest nieprawidłowa.

Spock

W Spocku komunikat błędu prezentuje się następująco:

Condition not satisfied:

result == 25
|      |
10     false

 

JUnit

Komunikat w JUnit dostaniemy w takiej formie:

org.opentest4j.AssertionFailedError: 
Expected :10
Actual   :25

Given When Then

Konwencja Given When Then pozwala utrzymać porządek w kodzie testu, dzięki podzieleniu go na trzy części:

Given: krok, w którym ustawiamy stan początkowy testu

When: krok, w którym wywołujemy akcję, której rezultat chcemy przetestować

Then: krok weryfikujący, tutaj sprawdzamy, czy otrzymaliśmy oczekiwany rezultat po wywołaniu akcji z poprzedniego bloku

Spock

Groovy dostarcza gotowe labelki, które oddzielają poszczególne sekcje testu:

def "function should return the result of multiplication for given params"() {
    given:
    int firstParam = 5
    int secondParam = 5
    when:
    def result = arithmeticOperation.multiply(firstParam, secondParam)
    then:
    result == 25
 }

Dodatkowo do każdej labelki możemy dodać dowolny opis:

given: "example description"
int firstParam = 5
int secondParam = 5

Groovy dba o utrzymanie tej konwencji. Jeżeli w teście zapomnimy o dodaniu when lub then, to test się nie wykona, a my dostaniemy komunikat o błędzie.

 

JUnit

Niestety JUnit nie ma podobnego mechanizmu i musimy sami zadbać o stosowanie tej konwencji w naszych testach za pomocą dodatkowych komentarzy, o których łatwo można zapomnieć.

@Test
 void should_return_result_of_multiplication_for_given_params() {
    // given
    final int firstParam = 5;
    final int secondParam = 5;

    // when
    final int result = arithmeticOperation.multiply(firstParam, secondParam);

    // then
    assertEquals(25, result);
 }

 

Interakcje

Czasem chcemy przetestować nie tylko wynik testowanego fragmentu, ale też sprawdzić, czy jakaś metoda została wywołana oczekiwaną liczbę razy. W tym celu przydadzą nam się interakcje.

 

Spock

W Spocku składnia takiego sprawdzenia wygląda następująco:

then:
1 * exampleClass.exampleMethod()

 

JUnit

Tutaj sprawa jest nieco bardziej skomplikowana, bo do takiego sprawdzenia potrzebujemy dodatkowej zależności jak np. Mockito. Składania z użyciem Mockito prezentuje się następująco:

// then
verify(exampleClass, times(1)).exampleMethod();

 

Mockowanie

 

Spock

Spock posiada wbudowany mechanizm mockowania, więc jego użytkownik nie musi martwić się o organizowanie dodatkowych zależności. W wersji Spocka 1.1 występowały problemy w przypadku testów integracyjnych ze względu na brak wsparcia dla Springa. Zmieniło się to od wersji 1.2. Poniżej przykładowy test z użyciem mockowania:

def "mock example"() {
     given:
     MockService mockService = Mock()
     def mockList = ["mock1", "mock2"]
     mockService.fetchExampleList() >> mockList

     when:
     def result = mockService.fetchExampleList()
     then:
     result.size() == 2
  }

 

JUnit

W przypadku JUnita musimy dodać dodatkową zależność np. Mockito, EasyMock. Przykładowy test z użyciem Mockito:

@Mock
private MockService mockService;
@Test
void mock_example() {
    // given
    List<String> listOfMocks = Arrays.asList("mock1", "mock2");
    when(mockService.fetchExampleList()).thenReturn(listOfMocks);
    // when
    List<String> result = mockService.fetchExampleList();
    // then
    assertEquals(result.size(), 2);
 }

 

Data-Driven Testing

Czasem pisząc testy, chcielibyśmy sprawdzić ten sam kod kilkukrotnie, ale dla różnych danych wejściowych. Z pomocą przychodzą testy data-driven znane również jako testy parametryzowane.

Spock

Testy parametryzowane Spocka to jedna z jego największych zalet. Dla zobrazowania jak wyglądają, weźmy pod uwagę przypadek metody obliczającej pole trapezu. W sekcji where tworzymy tabelkę, gdzie każdy parametr wejściowy jest oddzielony znakiem | lub || . W niektórych testach można zauważyć konwencję rozdzielania poszczególnych parametrów pojedynczym znakiem |, a wynik rozdzielany jest ||, tak jak w poniższym teście.

def "function should calculate #area of trapeze for given #a #b and #h"() {
    when:
    def result = exampleSpockClass.calculateTrapezeArea(a, b, h)
    then:
    result == area
    where:
    a  | b  | h || area
 |  | 3 ||  18
    10 | 12 | 8 ||  88
}

 

JUnit

W wersji 4 JUnit zaczął wspierać testy parametryzowane, ale składnia była dość nieczytelna. Sytuacja poprawiła się w wersji 5. Mechanizm parametryzacji stał się jedną z lepszych rzeczy wprowadzonych w tej wersji. Aby oznaczyć test jako parametryzowany, należy użyć adnotacji “@ParametrizedTest”. W zależności od liczby, typu parametry można przekazać w różny sposób, np. jeśli chcemy przekazać pojedynczy parametr, wtedy możemy użyć anotacji “@ValueSource” np. @ValueSource(numbers = {5, 10, 15, 20}). W przypadku, gdy potrzebujemy podać kilka parametrów, możemy użyć @CsvSource, tak jak poniżej:

@ParameterizedTest
@CsvSource(delimiter='|', value = {"5|7|3|18", "10|12|8|88"})
void should_return_result_of_addition_for_given_params(int a, int b, int h, int area) {
   assertEquals(arthmeticOperation.calculateTrapezeArea(a, b, h), area);
}

 

Testowanie wyjątków

Czasem chcemy sprawdzić, jak testowany przez nas fragment kodu zachowa się w momencie, kiedy np. dostanie błędne dane czy kiedy jakiś zasób nie istnieje w systemie, co skutkuje wyrzuceniem wyjątku. 

Spock

W Spocku sprawa jest rozwiązana bardzo prosto i klarownie . Wystarczy w bloku then użyć thrown + nazwa wyjątku, którego oczekujemy np.:

def "service should throw error if user not found"() {
    given:
    int givenId = 1
    when:
    userService.fetchUserInfo(givenId)
    then:
    thrown UnknownUserException
}

Jeżeli chcielibyśmy wyciągnąć więcej szczegółów z naszego wyjątku, np. komunikat błędu, możemy użyć:

    then:
    UnknownUserException e = thrown()
    e.message == "Unknown User"
    e.code == 123

 

JUnit

W JUnit 5 testowanie wyjątków dzięki wsparciu wyrażeń lambda oraz asercji assertThrows, wygląda w następujący sposób:

@Test
void should_throw_exception_when_user_not_found() {
    // given
    final int userId = 1;
    // when
    Executable e = () -> userService.fetchUserInfo(userId);
    // then
    assertThrows(UnknownUserException.class, e);
}

Jeżeli potrzebujemy wyciągnąć więcej informacji:

    // then
    UnknownUserException exception = assertThrows(UnknownUserException.class, e);
    assertEquals("Unknown User", exception.getMessage());
    assertEquals(123, exception.getCode());

Użytkownicy JUnita w wersji 4. na pewno pamiętają poprzednie podejście, gdy do przetestowania wyjątku wystarczyło dodać oczekiwany przez nas wyjątek w anotacji Test w taki sposób: @Test(expected = UnknownUserException.class). Ten sposób nie jest już wspierany od wersji piątej.

Podsumowanie

JUnit jest zdecydowanie popularniejszy niż Spock. Jeżeli będziemy szukać przykładów lub rozwiązań jakichś problemów, to w sieci znajdziemy więcej takich tematów związanych z JUnitem. Na popularnym Stack Overflow pytań z tagiem junit jest ponad 24 tys. Dla porównania pytań z tagiem spock jest niewiele ponad 2 tys. Patrząc na repozytoria obu projektów umieszczone na GitHubie, również pod względem aktywnych współautorów, ze zdecydowaną przewagą wygrywa JUnit.

Który framework jest lepszy? Ciężko stwierdzić. Wszystko zależy od naszych oczekiwań. Jeżeli nie boimy się nowego języka i jego możliwości, lubimy dobrze opisane, zwięzłe testy oraz nie chcemy zajmować się organizowaniem dodatkowych zależności, to postawmy na Spocka. Jeżeli wolimy sprawdzone rozwiązanie, bez potrzeby nauki niuansów nowego języka, to wybór powinien paść na JUnita. Na początku do Spocka podchodziłem z nieufnością, ale od momentu kiedy zacząłem go używać, pisze mi się w nim lepiej w porównaniu z konkurentem. Zachęcam do spróbowania, nawet jeśli w Twoim projekcie jest już użyty JUnit. Nie ma żadnego problemu, by używać tych dwóch frameworków jednocześnie. Jeżeli jesteśmy w początkowej fazie projektu, takie rozwiązanie może ułatwić decyzję, który z nich powinien zostać główną technologią do pisania testów. Natomiast jeśli projekt jest już w zaawansowanej fazie, to możemy spróbować wprowadzić nową technologię, nie martwiąc się o przepisywanie dotychczasowych testów.

Paweł Kaleciński  – Full Stack Developer w Onwelo. Zainteresowany nowinkami technicznymi, bezpieczeństwem aplikacji internetowych i muzyką rockową.

 

Zostaw komentarz

Polecamy

Data & Analytics – architektura systemów jutra

Data & Analytics – architektura systemów jutra

Jaka jest historia inżynierii danych? Jak przebiegał rozwój technologii i na jakie trendy zwraca obecnie uwagę świat? Marek Kozioł, Data Solution Architect i Arkadiusz Zdanowski, Cloud Data Engineer & Team Leader w Onwelo opowiedzieli o tych zagadnieniach podczas konferencji „Transformacje cyfrowe dla biznesu”. Zapraszamy do lektury artykułu przygotowanego na bazie tego wystąpienia.

Czym jest brand hero i jak może pomóc marce?

Czym jest brand hero i jak może pomóc marce?

Mały Głód, Serce i Rozum, ludzik Michelin – jako brand hero reprezentują oni swoje marki. W tym roku dołączył do nich Onwelek, nasz własny brand hero. Dowiedz się, czym jest brand hero, jakie pełni funkcje i jak przebiega jego kreacja!

Data & Analytics – architektura systemów jutra

Data & Analytics – architektura systemów jutra

Jaka jest historia inżynierii danych? Jak przebiegał rozwój technologii i na jakie trendy zwraca obecnie uwagę świat? Marek Kozioł, Data Solution Architect i Arkadiusz Zdanowski, Cloud Data Engineer & Team Leader w Onwelo opowiedzieli o tych zagadnieniach podczas konferencji „Transformacje cyfrowe dla biznesu”. Zapraszamy do lektury artykułu przygotowanego na bazie tego wystąpienia.

#Udostępnij

strzałka przewiń do góry strony