Spock Framework w Javie – testy jednostkowe i integracyjne krok po kroku

Czym jest Spock Framework?

Czym jest Spock Framework?

Spock Framework to alternatywna dla JUnit biblioteka służąca do tworzenia testów jednostkowych. Jest on szczególnie popularny w projektach opartych o języka Java. Zaskoczę Cię jednak, bo sam framework nie korzysta ze składni Java, lecz opiera się o inny język JVM – Groovy. Oznacza to, że kod Twojej aplikacji może pozostać napisany w Javie, a Groovy wykorzystywany jest wyłącznie w testach. Groovy ma znacznie bardziej zwięzłą składnię niż Java – pozwala m.in. pomijać średniki, często nie wymaga jawnego deklarowania typów dzięki def (odpowiednik var w Java), upraszcza tworzenie kolekcji i obiektów oraz oferuje bardziej elastyczną składnię. Spock wykorzystuję tzw. BDD (Behavior Driven Development), czyli tworzenie testów w oparciu o bloki takich sekcji jak given, when, then, expect czy where. Dzęki nim tety często przypominają opis zachowania aplikacji, a nie tylko zestaw wywołań metod i asercji.

Konfiguracja Spock Framework w projekcie Java i Maven

Aby korzystać ze Spock Framework w projekcie Java opartym na Mavenie, należy przede wszystkim dodać odpowiednie zależności do pliku pom.xml. Kod samej aplikacji nadal może być napisany wyłącznie w Javie – Groovy będzie wykorzystywane tylko do kompilowania i uruchamiania testów.

Najpierw dodaj zależności do swojego projektu (groovy – język który używa Spock oraz samą bibliotekę):

<dependency>
    <groupId>org.apache.groovy</groupId>
    <artifactId>groovy</artifactId>
    <version>4.0.28</version>
    <scope>test</scope>
</dependency>

<dependency>
    <groupId>org.spockframework</groupId>
    <artifactId>spock-core</artifactId>
    <version>2.4-groovy-4.0</version>
    <scope>test</scope>
</dependency>

Ważną zmianą w stosunku do JUnit jest struktura katalogów w projekcie. Zamiast standardowej ścieżki testów napisanych w Javie src/test/java, tutaj zastosujesz osobny katalog: src/test/groovy.

Struktura projektu, który korzysta ze Spock:

src/
├── main/
│   └── java/
│       └── com/
│           └── example/
│               └── Calculator.java
│
└── test/
    ├── java/
    │   └── com/
    │       └── example/
    │
    └── groovy/
        └── com/
            └── example/
                └── CalculatorSpec.groovy

Pozostaje jeszcze skonfigurowanie kompilacji kodu Groovy. Można do tego wykorzystać GMavenPlus Plugin:

<plugin>
    <groupId>org.codehaus.gmavenplus</groupId>
    <artifactId>gmavenplus-plugin</artifactId>
    <version>4.2.1</version>
    <executions>
        <execution>
            <goals>
                <goal>compileTests</goal>
            </goals>
        </execution>
    </executions>
</plugin>

GMavenPlus jest tutaj istotny, ponieważ Maven domyślnie kompiluje testy Java z src/test/java, ale nie kompiluje automatycznie plików .groovy znajdujących się w src/test/groovy.

Po takiej konfiguracji mogę umieszczać testy Spock w katalogu src/test/groovy i uruchamiać je razem z pozostałymi testami projektu za pomocą standardowego polecenia Maven: maven test. Warto również stosować charakterystyczną dla Spocka konwencję nazewnictwa klas testowych z końcówką Spec lub Specification, np. CalculatorSpec.groovy. Dzięki temu od razu widać, że dana klasa pochodzi z frameworka Spock.

Pierwszy test jednostkowy w Spock Framework

Po skonfigurowaniu projektu możesz przejść do napisania swojego pierwszego testu. Na potrzeby przykładu napiszę prostą klasę Calculator:

public class Calculator {

    public int add(int a, int b) {
        return a + b;
    }
}

Aby test został poprawnie zlokalizowany przez bibliotekę, klasa testowa musi rozszerzać klasę Specification oraz zawierać się w src/test/groove:

import spock.lang.Specification

class CalculatorSpec extends Specification {

    def "should add two numbers"() {
        given:
        def calculator = new Calculator()

        when:
        def result = calculator.add(2, 3)

        then:
        result == 5
    }
}

Już na tym prostym przykładzie widać charakterystyczną składnię Spocka. Metoda testowa nie musi posiadać adnotacji @Test, a jej nazwa jest zwykłym tekstem opisującym sprawdzany scenariusz. Każdy test jest podzielony na czytelne bloki określające kolejne etapy jego wykonania.

O to główne z nich:

  • Blok given, który służy do definiowania danych potrzebnych do wykonania testu
  • Sekcja when gdzie wykonujesz operację, które chcesz przetestować
  • oraz then w której sprawdzasz czy zaszły odpowiednie warunki do powodzenia testu

Zwrócić uwagę na brak znanego z JUnit wywołania asercji. W bloku then Spock automatycznie traktuje wyrażenia logiczne jako warunki testu. Jeżeli result == 5 zwróci false, test zakończy się niepowodzeniem.

Najważniejsze bloki w testach Spock

Blok given, który służy do definiowania danych potrzebnych do wykonania testu

Sekcja when gdzie wykonujesz operację, które chcesz przetestować

W then sprawdzasz czy zaszły odpowiednie warunki do powodzenia testu

Czasami możesz skorzystać z krótszego zapisu w swoim teście i pominąć klasyczną strukturę given – when – then i skorzystać z bloku expect. Sekcja ta pozwala połączyć wykonanie operacji oraz sprawdzenie jej rezultatu. Dobrze sprawdza się w przypadku prostych testów, w których rozdzielenie when i then nie poprawiłoby czytelności.

Zamiast:

when:
def result = calculator.add(10, 5)

then:
result == 15

Możesz użyć:

expect:
calculator.add(10, 5) == 15

Testy parametryzowane w Spock – blok where

Jedną z największych zalet Spock Framework jest bardzo proste tworzenie testów parametryzowanych, określanych również jako data-driven tests. Zamiast tworzyć kilka niemal identycznych metod testowych dla różnych danych wejściowych, mogę zdefiniować jeden test i uruchomić go wielokrotnie dla różnych zestawów danych za pomocą bloku where.

Wykorzystując wcześniejszą klasę Calculator, test metody add() może wyglądać następująco:

def "should add two numbers"() {
    expect:
    calculator.add(a, b) == expectedResult

    where:
    a | b || expectedResult
    1 | 2 || 3
    2 | 3 || 5
    5 | 5 || 10
}

Mockowanie zależności w Spock Framework

Spock Framework posiada własny mechanizm mockowania, alternatywny do bibliotek takich jak Mockito. Spock sam pozwala tworzyć mocki, definiować zwracane przez nie wartości oraz sprawdzać liczbę i sposób wywołań metod zależności.

Omówmy tą funkcje na przykładzie poniższego kodu:

public class OrderService {

    private final OrderRepository orderRepository;

    public OrderService(OrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }

    public Order findOrder(Long id) {
        return orderRepository.findById(id);
    }
}

Utworzenie mocka OrderRepository:

def orderRepository = Mock(OrderRepository)
def orderService = new OrderService(orderRepository)

Zdefiniowanie wartości zwracanej przez mock:

orderRepository.findById(1L) >> new Order(1L, "Laptop")

Pełen przykład:

def "should return order"() {
    given:
    def orderRepository = Mock(OrderRepository)
    def orderService = new OrderService(orderRepository)
    def order = new Order(1L, "Laptop")

    orderRepository.findById(1L) >> order

    when:
    def result = orderService.findOrder(1L)

    then:
    result == order
}

Sprawdzenie, czy metoda została wywołana dokładnie raz:

1 * orderRepository.findById(1L)

Sprawdzenie, czy metoda nie została wywołana – _ oznacza dowolny argument:

0 * orderRepository.delete(_)

Utworzenie Stub:

def orderRepository = Stub(OrderRepository)

orderRepository.findById(1L) >> new Order(1L, "Laptop")

Testowanie wyjątków w Spock Framework

Spock pozwala w czytelny sposób sprawdzać, czy dana metoda wyrzuciła oczekiwany wyjątek. Wykorzystuje się do tego metodę thrown(), którą umieszcza się w bloku then.

Przykładowy kod do testowania:

public int divide(int a, int b) {
    if (b == 0) {
        throw new IllegalArgumentException("Divider cannot be zero");
    }

    return a / b;
}

Sprawdzenie typu wyjątku i wiadomości:

when:
calculator.divide(10, 0)

then:
def exception = thrown(IllegalArgumentException)
exception.message == "Divider cannot be zero"

Sprawdzenie, że wyjątek nie został rzucony:

when:
calculator.divide(10, 2)

then:
notThrown(IllegalArgumentException)

Spock i Spring Boot

Spock można również wykorzystać do testowania aplikacji opartych na Spring Boot. Integrację Spocka ze Springiem zapewnia moduł spock-spring, który należy dodać do zależności testowych projektu.

<dependency>
    <groupId>org.spockframework</groupId>
    <artifactId>spock-spring</artifactId>
    <version>2.4-groovy-4.0</version>
    <scope>test</scope>
</dependency>

Załóżmy, że aplikacja posiada prosty serwis napisany w Javie:

@Service
public class CalculatorService {

    public int add(int a, int b) {
        return a + b;
    }
}

Prosty test @SpringBootTest:

import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import spock.lang.Specification

@SpringBootTest
class CalculatorServiceSpec extends Specification {

    @Autowired
    CalculatorService calculatorService

    def "should add two numbers"() {
        expect:
        calculatorService.add(10, 5) == 15
    }
}

Mock beana Springa za pomocą @SpringBean:

import org.spockframework.spring.SpringBean
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import spock.lang.Specification

@SpringBootTest
class OrderServiceSpec extends Specification {

    @SpringBean
    OrderRepository orderRepository = Mock()

    @Autowired
    OrderService orderService

    def "should return order from repository"() {
        given:
        def order = new Order(1L, "Laptop")

        when:
        def result = orderService.findOrder(1L)

        then:
        1 * orderRepository.findById(1L) >> Optional.of(order)
        result == order
    }
}

Wady i zalety Spock

Spock Framework jest ciekawą alternatywą dla klasycznego zestawu JUnit i Mockito, szczególnie gdy zależy mi na czytelnych testach oraz prostym testowaniu wielu zestawów danych. Nie oznacza to jednak, że będzie najlepszym rozwiązaniem w każdym projekcie Java.

Zalety Spock FrameworkWady Spock Framework
Czytelna struktura testów dzięki given, when, then, expectKonieczność znajomości podstaw Groovy
Bardzo zwięzła składnia testówWprowadzenie dodatkowego języka do projektu Java
Świetne wsparcie dla testów parametryzowanych przez whereDodatkowa konfiguracja projektu Maven
Wbudowane mocki i stubyMniejsza popularność niż JUnit
Prosta weryfikacja wywołań metod mockówDodatkowy próg wejścia dla programistów znających tylko Javę
Czytelne komunikaty przy nieudanych asercjachDynamiczne typowanie w Groovy może utrudniać wykrywanie niektórych błędów
Dobra integracja ze Spring BootKonieczność pilnowania kompatybilności wersji Spock i Groovy
Możliwość testowania kodu napisanego w JavieMniej naturalny wybór dla projektu, który ma pozostać w 100% javowy
Nie wymaga Mockito w typowych przypadkachJUnit ma większy ekosystem i jest bardziej rozpowszechniony

Podsumowanie

Spock Framework to ciekawa alternatywa dla klasycznego połączenia JUnit i Mockito, szczególnie w projektach, w których zależy mi na czytelnych i zwięzłych testach. Bloki given, when, then i where, wbudowane mockowanie oraz bardzo wygodne testy parametryzowane pozwalają opisywać nawet bardziej złożone scenariusze w przejrzysty sposób. Spock dobrze współpracuje również z Javą, Mavenem i Spring Boot, dlatego nie wymaga zmiany języka wykorzystywanego w kodzie produkcyjnym. Największym minusem pozostaje konieczność wprowadzenia Groovy do projektu, jednak w zamian otrzymuję narzędzie, które w wielu przypadkach pozwala pisać testy krócej i czytelniej.

Dodaj komentarz