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 5 | 7 | 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
Sztuczna inteligencja w wykrywaniu zagrożeń bezpieczeństwa IT
Cyberbezpieczeństwo to nie tylko zaawansowane technicznie systemy zabezpieczeń w dużych firmach czy wojsku. To także nasze prywatne bezpieczeństwo, walka z zagrożeniami i ich prewencja w codziennym życiu oraz wiedza o bezpiecznym korzystaniu z internetu. Adam Kowalski-Potok, nasz Seurity Engineer, opowiada jak AI i jej rozwój wpływa na wykrywanie zagrożeń w cyber security.
Budowanie systemów biznesowych z zastosowaniem generatywnej sztucznej inteligencji
Generatywne AI ma potencjał do automatyzacji zadań zajmujących dziś do 70% czasu pracowników. Dlaczego platforma OpenAI nie wystarczy do wykorzystania pełni tych możliwości? Przed nami artykuł Łukasza Cesarskiego i Marka Karwowskiego z Onwelo powstały na bazie prezentacji wygłoszonej podczas konferencji „Transformacje cyfrowe dla biznesu”.
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.
Sztuczna inteligencja w wykrywaniu zagrożeń bezpieczeństwa IT
Cyberbezpieczeństwo to nie tylko zaawansowane technicznie systemy zabezpieczeń w dużych firmach czy wojsku. To także nasze prywatne bezpieczeństwo, walka z zagrożeniami i ich prewencja w codziennym życiu oraz wiedza o bezpiecznym korzystaniu z internetu. Adam Kowalski-Potok, nasz Seurity Engineer, opowiada jak AI i jej rozwój wpływa na wykrywanie zagrożeń w cyber security.
Budowanie systemów biznesowych z zastosowaniem generatywnej sztucznej inteligencji
Generatywne AI ma potencjał do automatyzacji zadań zajmujących dziś do 70% czasu pracowników. Dlaczego platforma OpenAI nie wystarczy do wykorzystania pełni tych możliwości? Przed nami artykuł Łukasza Cesarskiego i Marka Karwowskiego z Onwelo powstały na bazie prezentacji wygłoszonej podczas konferencji „Transformacje cyfrowe dla biznesu”.