AlcoTest czyli Spock, alternatywa dla JUnit i (być może) dla Gherkina

W ramach zajęć rozwijających w moim (skądinąd bardzo fajnym) korpo, dostałem zadanie rozpoznania BDD (Behavior-driven development) na przykładzie Gherkina i Cucumbera.

Pierwszy kontakt z tymi narzędziami nie jest przyjemny i oczywisty, zacząłem więc pytać mentorów w korpo, JUGowe community i siebie o sens stosowania tego narzędzia.

Odpowiedź i zrozumienie przyszły szybciej niż się spodziewałem (głównie, na świetnych warsztatach zorganizowanych przez Wrocław JUG i Colibrę z Michałem Michalukiem jako mistrzem ceremonii. W konsekwencji warsztatów pojawił się pomysł, żebym opisał jak DDD i Event Storming rozumie, w miarę zaawansowany junior, który jeszcze ani razu nie przeczytał Erica Evansa a wiedzę czerpie z licznych konferencji. I ja ten temat kupuję, wpisuję do kolejki i mam nadzieję, że przy tej okazji pojawi się również temat BDD).

Wracając do meritum. W czasie rozmów o BDD kilka osób stwierdziło, że produkcyjnie używają Spocka i są bardzo zadowoleni. Musiałem sam spróbować, zwłaszcza, że nigdy nie używałem ani Grooviego ani Spocka. Poniżej opis tego doświadczenia.

Wyobraźmy sobie prosty alkomat odpowiadający na pytanie, czy w danej chwili możemy prowadzić auto.

Opieramy się na tzw. standardowej jednostce alkoholu (SJA).  Założenie jest takie – w bardzo dużym uproszczeniu – że przeciętny organizm spala SJA w ciągu godziny. Zgadza się to mniej więcej z doświadczeniem, że po piwie lub lampce wina możemy prowadzić po dwóch godzinach a po pół litra z kolegą na pół po minimum ośmiu godzinach (patrz: Wikipedia).

Skonstruujmy model. Załóżmy, że mamy chwilę zero. Spożycie alkoholu w najbliższym czasie będzie wyglądało następująco:

  1. za 2 godziny wypijemy butelkę piwa – 2 SJA
  2. za następne 2h setkę wódki – 4 SJA
  3. za następne 2h kieliszek wina – 2SJA
        //        4            |\  |\
        //        3            |  \|  \
        //        2       | \  |        \
        //        1   _ _ |   \|          \
        //        0   0 1 2 3 4 5 6 7 8 9 0

Prowadzić auto możemy więc w 0, 1 i potem poczynając od 10 godziny. W międzyczasie na zmianę pijemy i trzeźwiejemy.

Załóżmy, że mamy następujący interfejs:

public interface CalculateAlcohol {

  void drink(int time, StandardDrink alco);
  int drunkAlcohol(int time);
  boolean canIDrive(int time);

  enum StandardDrink {
    ONEBEERBOTTLE(2), ONEWINEGLASS(2), ONESHOT(1);

    private int standardAlco;

    StandardDrink(int i) {
      this.standardAlco = i;
    }

    public int getStandardAlco() {
      return standardAlco;
    }
  }
}

Czyli możemy jedynie pić w porcjach: 0,5l piwa, kieliszek wina po 2 SJA lub małego sznapsa (25ml) 1 SJA.

Możemy zapytać ile łącznie alkoholu wypiliśmy w zadanym okresie i czy w zadanym momencie możemy prowadzić samochód. Jednostką czasu jest godzina.

Dla tych ustaleń korzystając z dokumentacji Spocka (i Grooviego) możemy napisać testy. Same testy są bardzo proste i przygotowanie ich, osobie nigdy nie programującej w Groovym nie przysporzyło najmniejszego problemu. Od razu przejdę więc do przykładów, zaznazczając jedynie, że proces znacznie usprawnia zainstalowanie dwóch wtyczek do IntelliJ: GMavenPlus IntelliJ Plugin i Spock Framework Enhancements, choć ta druga, imho nie działa perfekcyjnie. Zaprezentuję dwa pliki z testami.

import spock.lang.Specification

import static robert.trening.CalculateAlcohol.StandardDrink.ONEWINEGLASS

class CalculateAlcoholImplTest extends Specification {

    def "drunkAlcoholTest"() {
        setup:
        def calculateAlcohol = new CalculateAlcoholImpl()

        when:
        calculateAlcohol.drink(6, ONEWINEGLASS)

        def drunkStandardDrink = calculateAlcohol.drunkAlcohol(7)

        then:
        drunkStandardDrink == 2
    }

    def "canIDriveTest"() {
        setup:
        def calculateAlcohol = new CalculateAlcoholImpl()

        when:
        calculateAlcohol.drink(6, ONEWINEGLASS)

        def canIDrive = calculateAlcohol.canIDrive(7)

        then:
        canIDrive == false
    }

    def "calculateAlcoholImplTestBoundaryConditions"() {
        setup:
        def calculateAlcohol = new CalculateAlcoholImpl()

        when:
        def drunkStandardDrink = calculateAlcohol.drunkAlcohol(1)
        def canIDrive = calculateAlcohol.canIDrive(2)

        then:
        drunkStandardDrink == 0
        canIDrive == true
    }
}

Czyli mamy trzy testy, dwa sprawdzające kluczowe metody. Trzeci test sprawdza zachowanie dla pustej listy wypitego alkoholu.

Drugi plik testowy:

import spock.lang.Specification
import spock.lang.Unroll

import static robert.trening.CalculateAlcohol.StandardDrink.ONEBEERBOTTLE
import static robert.trening.CalculateAlcohol.StandardDrink.ONESHOT
import static robert.trening.CalculateAlcohol.StandardDrink.ONEWINEGLASS

class CanIDriveTest extends Specification {
    @Unroll
    def "canIDriveTest"() {
        setup:
        def calculateAlcohol = new CalculateAlcoholImpl()

        // drinking chart:
        // beer + 100g vodka + wine
        //        4            |\  |\
        //        3            |  \|  \
        //        2       | \  |        \
        //        1   _ _ |   \|          \
        //        0   0 1 2 3 4 5 6 7 8 9 0
        calculateAlcohol.drink(6, ONEWINEGLASS)
        calculateAlcohol.drink(4, ONESHOT)
        calculateAlcohol.drink(4, ONESHOT)
        calculateAlcohol.drink(2, ONEBEERBOTTLE)
        calculateAlcohol.drink(4, ONESHOT)
        calculateAlcohol.drink(4, ONESHOT)

        expect:
        calculateAlcohol.canIDrive(time) == driveOrNot

        where:
        time | driveOrNot
        0    | true
        1    | true
        2    | false
        3    | false
        4    | false
        5    | false
        6    | false
        7    | false
        8    | false
        9    | false
        10   | true
        11   | false  // For error visualization
    }
}

który sprawdza poprawność kluczowej odpowiedzi, czy możemy prowadzić samochód w całym przedziale interesującego nas czasu.

Czas skopiować podany wyżej kod do IDE i zobaczyć co się stanie.

I tutaj dochodzimy do powodu powstania niniejszego wpisu. Matriały dotyczące konfiguracji pliku POM.xml okazały się tyleż nieprecyzyjne co sprzeczne z sobą (łącznie z przykładami zamieszczonymi na stronie Spock). Pomocna okazało się dopiero strona opisu modułu GMavenPlus na Githubowej stronie Groovy. Odpowiednio użycie i przykłady. Na końcu znajdują się linki do innych materiałów, które przeczytałem ale okazały się (przynajmniej dla mnie) niepomocne. Wydaje mi się, że udało mi się stworzyć optymalną konfigurację w oparciu o najnowsze wersje artefaktów.

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>

  <groupId>robert.trening</groupId>
  <artifactId>alcotestbyspock</artifactId>
  <version>1.0-SNAPSHOT</version>


  <properties>
    <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <maven.compiler.source>1.8</maven.compiler.source>
    <maven.compiler.target>1.8</maven.compiler.target>
    <java.version>1.8</java.version>
  </properties>

  <build>
    <plugins>
      <plugin>
        <groupId>org.codehaus.gmavenplus</groupId>
        <artifactId>gmavenplus-plugin</artifactId>
        <version>1.6.1</version>
        <executions>
          <execution>
            <goals>
              <goal>generateStubs</goal>
              <goal>compile</goal>
              <goal>generateTestStubs</goal>
              <goal>compileTests</goal>
            </goals>
          </execution>
        </executions>
        <dependencies>
          <dependency>
            <groupId>org.codehaus.groovy</groupId>
            <artifactId>groovy-all</artifactId>
            <version>2.5.0</version>
            <type>pom</type>
            <scope>runtime</scope>
          </dependency>
        </dependencies>
      </plugin>
    </plugins>
  </build>

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

gdzie odpowiednio:

  • properties – w dużej mierze wynikają z tego, że pracuję z wersją 10.1 SDK, a Spock jest kompatybilny z maksymalnie 1.8. Dla wersji JDK 1.8 chodzi bezbłędnie. Poczynając od 9 downgradowanej do 1.8 generuje stadko ostrzeżeń wraz z propozycją, żeby je wyłączyć. Nie zastosowałem się do tej propozycji.
  • plugin gmavenplus-plugin – odpowiada za integrację modułów Groovy w projekcie Mavenowym. Dodatkowo cele (goal) generateStubs i generateTestStubs pozwalają integrować w jednym projekcie pliki napisane w Javie i w Groovym (dokładnie, przekładają kod z Groovy na kod w Javie, kompilowany później przez standardowy kompilator). Plugin ten korzysta z groovy-all, który wspomaga kompilację plików Groovy.
  • dependency – spock-core, to jedyna zależność zawierająca obsługę Spocka

Bez żadnych dodatkowych jawnych zależności, podobnie jak to ma miejsce dla czystej Javy, uruchamiane są testy poprzez maven-surefire-plugin (2.12.4) czyli w silniku JUnit4. Prób dla JUnit5 nie prowadziłem ale prawdę mówiąc patrząc na rozwój biblioteki mam mieszane uczucia.

Po automatycznym wygenerowaniu implementacji przedstawionego interfejsu, puszczamy testy mvn test i otrzymujemy wynik jak niżej.

Automatyczna implementacja zwraca 0(zero) dla metod zwracających int i false dla zwracających boolean. Wynik testu wygląda więc dobrze, zgodnie z przewidywaniami.

Uwaga: powyższą implementację znajdziemy w commicie First step TDD process. Code without implementation.

Można by uznać, że cel osiągnięto i skończyć ale zaimplementujmy nasze metody, cel jest szczytny i może kogoś powstrzymamy od siadania za kierownicą :).

Pamięć wypitego alkoholu zaimplementowano jako TreeMap, gdzie czas jest indeksem a ilość SJA wartością (wstępne porządkowanie, na tym etapie nie przydaje się w dalszej implementacji).  Oczywiście, gdybyśmy chcieli zapisać cały alkohol wypity w życiu, zastosowano by mniej ulotną formę przechowywania danych, dla prostego alkomatu nie ma takiej potrzeby. Wygląda to następująco:

  private Map<Integer, Integer> timeTable = new TreeMap<>();

  @Override
  public void drink(int time, StandardDrink alco) {
    if (timeTable.get(time) != null) {
      timeTable.replace(time, alco.getStandardAlco()
          + timeTable.get(time));
    } else {
      timeTable.put(time, alco.getStandardAlco());
    }
  }

Zliczanie wypitego alkoholu:

  @Override
  public int drunkAlcohol(int time) {
    return timeTable.entrySet().stream()
        .filter(x -> x.getKey() <= time) 
        .mapToInt(x -> x.getValue())
        .sum();
  }

nie użyłbym kodu w tej postaci na produkcji, ale na szczęście nie jesteśmy na produkcji tylko na blogu. Jak Spock dogoni Javę to zamienię .filter na .takeWhile (po to była mi ta TreeMap). Niemniej na razie jest jak jest a zewnętrznych bibliotek ani własnej implementacji takeWhile (np. wg Koziołka) nie chcę robić dla przejrzystości kodu.

I wreszcie odpowiedź czy mogę jechać:

  @Override
  public boolean canIDrive(int time) {
    if (timeTable.size() == 0) {
      return true;
    }

    return Stream
        .iterate(new int[]{0, timeTable.get(0) == null ? 0
                : timeTable.get(0).intValue()},
            p -> new int[]{p[0] + 1,
                p[1]
                    + (p[1] > 0 ? -1 : 0)
                    + (timeTable.get(p[0] + 1) == null ? 0
                        : timeTable.get(p[0] + 1).intValue())
            })
        .limit((long) time + 1)
        .skip((long) time)
        .findFirst()
        .get()[1] == 0;
  }

Jak widać konsekwentnie nie używam na blogu pętli zastępując je bardziej lub mniej niezrozumiałymi strumieniami. W tym wypadku nie widzę przeciwwskazań przed użyciem tej implementacji, dla danych o rozsądnej wielkości – to zwykle daje się precyzyjnie przewidzieć, na produkcji.

Ostatecznie wynik testów wygląda następująco:

Kompletny kod: https://github.com/RobertPod/AlcoTestBySpock. Mam nadzieję, że komuś się przyda i skróci walkę z budowaniem POMa. Proszę o ewentualne uwagi.

Materiały według kolejności pojawiania się w tekście:

http://spockframework.org/

https://plugins.jetbrains.com/plugin/7442-gmavenplus-intellij-plugin

https://plugins.jetbrains.com/plugin/7114-spock-framework-enhancements

https://github.com/groovy/GMavenPlus/wiki

https://koziolekweb.pl/2016/05/03/jak-napisac-takewhile-w-javie-8/

https://github.com/RobertPod/AlcoTestBySpock

I inne, czasami wprowadzające w błąd, jeżeli chodzi o konfiguracje POMa:

https://pl.wikipedia.org/wiki/Standardowa_dawka_alkoholu

http://meetspock.appspot.com/

https://touk.pl/blog/2013/08/08/spock-java-and-maven/

https://geowarin.github.io/test-your-java-application-with-groovy/

 

Dodaj komentarz

Twój adres email nie zostanie opublikowany. Pola, których wypełnienie jest wymagane, są oznaczone symbolem *

This site uses Akismet to reduce spam. Learn how your comment data is processed.