Używałeś już Spocka?

JUnity, JUnity, i jeszcze raz JUnity. Tak do jakiegoś czasu wyglądało moje testowanie. Na konferencjach coraz częściej pojawiał się skrót BDD, czyli Behavior Driven Development oraz kojarzące się ze Star Trekiem słowo Spock. Spock jest frameworkiem do testowania kodu w języku Java jak i Groovy, który został wydany w wersji 1.0 w roku 2015. Znacznie różni się on od testów jednostkowych, które korzystają z biblioteki JUnit. Framework ten jest zbiorem wszystkich dobrych bibliotek stosowanych dotychczas razem!

Given When Then

Parę lat temu usłyszałem o konwencji Given When Then (można stosować zamiennie z Arrange Act Assert), który dzieli kod testu na trzy logiczne sekcje:

  • Given – tutaj znajdują się wartości, dla których będzie wykonywany test
  • When – tutaj znajduje się akcja, której wynik będziemy testować
  • Then – w tej sekcji, testujemy wynik akcji z sekcji When

Następnie postanowiłem stosować tę konwencję w swoich testach JUnitowych, niestety są to tylko komentarze. Stosowanie ich poprawia czytelność dla kogoś kto czyta ten test po raz pierwszy oraz zna konwencję GivenWhenThen:

@Test
public void shouldReturnFizzWhenNumberIsDividedByThree(){
    //Given
    int number = 3
    //When
    String result = fizzBuzz.check(number)
    //Then
    assertThat(result).contains("Fizz")
}

Następnie pojawił się problem konwencji nazewniczej testów. Każdy programista ma swój styl nazywania testów i czasami ciężko było zrozumieć co tak naprawdę ten test testuje. Jednym ze sposobów rozwiązania problemu sztucznych komentarzy oraz różnych stylów nazewnictwa jest Spock. Jak pisałem we wstępie, Spock jest frameworkiem do testowania, w którym piszemy używając Grooviego. Jak będzie wyglądał nasz test w Spocku:

def "'check' method should return 'Fizz' for number which is divided by three" () {
    given: "The customer gave us the number"
        def number = 3
    when: "The customer runs 'check' method"
        def result = fizzBuzz.check(number)
    then: "result should be 'Fizz'"
        result == "Fizz"
}

Od razu w oczy rzuca się to, iż nazwa metody może być opisowa (mamy tę możliwość dzięki Grooviemu), dzięki temu nie musimy się zastanawiać jak najlepiej nazwać test. Nazwa metody może być opisana językiem naturalnym co dla nas jest bardziej czytelne niż camelCase. Sekcje GWT są także integralną częścią Spocka, przez co udało nam się wyeliminować nadmiarowe komentarze. Testy w Spocku są bardziej deskryptywne, musimy pamiętać o zasadzie pareto 20/80 – 20% kodu piszemy, 80% czytamy. Tutaj czytelność jest bardzo dobra!

Od czego zacząć?

Aby móc zacząć testować w Spocku wystarczy dodać jedną zależność do Mavena lub Gradle, w zależności  od tego jakiego narzędzia do budowania używamy:

<dependency>
   <groupId>org.spockframework</groupId>
   <artifactId>spock-core</artifactId>
   <version>0.7-groovy-2.0</version>
</dependency>

Wszystkie testy w Spocku, piszemy w Groovym. Klasy testowe są specyfikacją w rozumieniu Spocka, ponieważ testujemy specyfikację, która opisuje featury związane z systemem. Wszystkie nasze klasy testowe muszą dziedziczyć po klasie Specification (w tej klasie znajdują się też różne użyteczne metody) :

import spock.lang.Specification

class TodoSpec extends Specification{

}

Każda specyfikacja może mieć cztery elementy:

  • fields
  • fixture methods
  • feature methods
  • helper methods

Fields

W tej sekcji definiujemy pola, które będą używane w naszych testach. Podobnie jak w JUnitach, pole zainicjalizowane w klasie będzie przed każdym testem ustawiane na nowo. Jest to równoważne z tym jakbyśmy umieścili inicjalizowanie w metodzie setup. Jeśli jednak potrzebujemy współdzielić zmienną pomiędzy testami, możemy wykorzystać adnotację @Shared.

class MyFirstSpecification extends Specification {
    @Shared
    veryExpensive = new SomeClass() // shared between tests
    reNew = new SomeSecondClass() // renew between tests
}

Fixture methods

Podobnie jak w JUnitach, dostępne są metody do ustawienia elementów przed oraz po testowaniu.

  • setup – metoda uruchamiana przed każdym testem (@Before w JUnitach)
  • cleanup – metoda uruchamiana po każdym teście (@After w JUnitach)
  • setupSpec – metoda uruchamiana przed wszystkim testami (@BeforeClass w JUnitach)
  • cleanupSpec – metoda uruchamiana po wszystkich testach (@AfterClass w JUnitach)

Feature methods

“Feature methods”, pod taką nazwą w dokumentacji występują nasze metody testowe. Koncepcyjnie podzielone są one na cztery fazy:

  • Setup – ustawiamy nasz test
  • Stimulus – definiujemy akcje
  • Response – sprawdzamy wynik
  • Cleanup – sprzątamy po teście

Wewnątrz tych faz, korzystamy z blocków, które najlepiej przedstawia ten obrazek (oryginalny z dokumentacji):

Setup

W bloku setup ustawiamy wartości do naszego testu. Dodatkowo istnieje blok given zwiększający czytelność, który jest ekwiwalenty do bloku setup.

When i Then

Blok when zawsze występuję z blokiem then. W when definiujemy nasze akcje, których wyniki będziemy sprawdzać w teście. then natomiast jest blokiem, w którym sprawdzamy wynik. Ale jak to, bez asercji? Tak! Wystarczy napisać warunek logiczny zwracający boolean.

Expect

Te dwa bloki mogą być zastąpione jednym blokiem expect.

Cleanup

cleanup powinien służyć do zamykania plików, strumieni i innych elementów.

Where

W ostatnim bloku, czyli where ustawiamy parametry do testu.

And

Oprócz bloku given zwiększającego czytelność istnieje blok and. Możemy go używać w połączeniu z innymi, aby zwiększyć czytelność.

Helper methods

Gdy nasze testy zaczynają “puchnąć”, czasem warto coś wyciągnąć do innej metody.

def "helper method example" () {
    when: "Todo is created"
        def todo = todoFactory.createTodo()
    then: "Todo contains default fields"
        checkDefaultFields(todo)
}

def checkDefaultFields(todo) {
    assert todo.title == "default title"
    assert todo.description == "default description"
}

Mockowanie

Jak napisałem we wstępie, Spock jest połączeniem wielu bardzo dobrych bibliotek. Dobre testy jednostkowe, a raczej te, które pisane są poprawnie powinny być wykonywane w kompletnej izolacji. Unit testy nie mogą zależeć od innych elementów. Do zapewniania izolacji w testach użyjemy mockowania. Nie musimy ściągać dodatkowo Mockito, czy innych bibliotek do mockowania, ponieważ mechanizm ten jest wbudowany w Spocka.

Testy parametryzowane

W JUnitach 4 testy parametryzowane były w niezbyt przyjemny dla developerów sposób. Powstało kilka bibliotek ułatwiających parametryzację testów, jedną z nich jest propozycja Pragmatists. W Spocku testy parametryzować można na dwa sposoby:

  • data tables
  • data pipes

Data tables

Data tables jest mechanizmem preferowanym przeze mnie jeśli chodzi o testy parametryzowane. Przygotowujemy tabelę, w której nagłówki to pola, które chcemy uzupełnić, natomiast pod nagłówkami umieszczamy wartości tych pól. Pola rozdzielamy pipem |. Dla lepszej czytelności możemy także rozdzielić dane wejściowe od oczekiwanego wyniku podwójnym pipem ||.

def "data table example" () {
    when: "The customer runs 'check' method"
        def result = fizzBuzz.check(number)
    then: "result should be as #expectedResult"
        result == expectedResult
    where:
        number || expectedResult
        1      || ""
        3      || "Fizz"
        5      || "Buzz"
        15     || "FizzBuzz"
}

Data pipes

Drugi sposób rzadziej przeze mnie używany to Data Pipes:

def "data pipes example" () {
    when: "The customer runs 'check' method"
        def result = fizzBuzz.check(number)
    then: "result should be as #expectedResult"
        result == expectedResult
    where:
        number << [1, 3, 5, 15]
        expectedResult << ["", "Fizz", "Buzz", "FizzBuzz"]
}

Wyjątki

JUnity nie do końca radziły sobie z wyjątkami w poprawny sposób. Gdy korzystaliśmy z ExpectedException musieliśmy definiować jakiego typu wyjątek ma wystąpić przed wywołaniem sekcji when, co traciło na czytelności. W Spocku jest to już zaimplementowane w lepszy sposób. Aby sprawdzić czy wyjątek wystąpił, korzystamy z metody thrown(YourExceptionClass).

def "exception example" (){
    when: "user get a Todo which not exists"
        def todo = todoFacade.get(10)
    then: "exception TodoNotFound should be thrown"
        thrown(TodoNotFound)
}

Możemy także przypisać wyjątek do zmiennej, jeśli chcemy sprawdzić message lub inne właściwości tego wyjątku:

def "exception example" (){
    when: "user get a Todo which not exists"
        def todo = todoFacade.get(10)
    then: "exception TodoNotFound should be thrown"
        def exception = thrown(TodoNotFound)
        exception.message == "Todo not found"
}

Więcej

W tym wpisie opisałem tylko część featurów występujących w Spocku. Bardzo mocno zachęcam do przeczytania dokumentacji, aby sięgnąć po więcej!

  • de

    Czy nadaje się także do pisania bardziej wysokopoziomowych testów? Połączonych z Selenium na przykład?

  • Łukasz Kuczyński

    czy jest może tutaj jakiś projekt na jakimś Githabopodobnym portalu? chętnie spojrzałbym na to z większej perspektywy
    Bardzo obiecująco to wygląda.
    Wygląda to jak pisanie dokumentacji i testów biznesowych w .. kodzie. Ekstra

  • mszarl

    Ej, serio jest jeszcze ktoś, kto nie słyszał o Spocku? 🙂

    • CodeCouple.pl

      Też pisząc artykuł zawsze się nad tym zastanawiam, ale podczas meetupów czy innych rozmów często znajdzie się ktoś kto nie słyszał o danej technologii 🙂

  • Używam Spocka na moim projekcie i szczerze mówiąc nie widziałem lepszego frameworku do testów w Javie/Groovy. Spock i Gradle sprawia ze Groovy tak szybko nie zniknie!

    • CodeCouple.pl

      Dokładnie, pojawił się co prawda JUnit w wersji 5, który dostarczył wiele usprawnień, jednakże możliwość “nadawa takich nazwa testów” jest killer featurem.

  • Arek

    Końcówka 2021 a ja dopiero trafiłem do projektu w którym jest Spock i… Cholernie mi się podoba 😉