niedziela, 17 lutego 2013

Porównanie tradycyjnych asercji, Hamcrest'a i FEST'a

Po tygodniowym szaleństwie w Alpach i niewielkim odmrożeniu, czas wrócić do pracy aby odpocząć ;) Temat może nie będzie zbytnio odkrywczy, ale akurat prezentowałem kolegom w projekcie jak działa FEST Asserts 2.X, więc niewielkim nakładem pracy można coś na blogu o tym skrobnąć. Jak ktoś już zna i używa, to nie musi dalej czytać. A kto nie zna jeszcze FEST’a to niech się przyjrzy przykładom przygotowanym przeze mnie i upiększy swoje nowe testy.

O wyższości FEST’a nad Hamcrest'em słyszałem po raz pierwszy na prezentacji Bartosza Bańkowskiego i Szczepana Fabera pt. Pokochaj swoje testy na Wroc JUG. I to już było wiadome w 2009 roku. W międzyczasie Hamcrest został wciągnięty do JUnit'a i Mockito, ale to z innych względów. A tym czasem FEST Asserts został trochę odświeżony i podbito jego numerek wersji. Dalej jest bardzo dobry i pozwala lepiej definiować fachowe asercje.

Czas na przykład. Jest on całkiem życiowy, aczkolwiek domena problemu została zmieniona i trochę uproszczona dla przejrzystości przykładu. Ja po zobaczeniu podobnego kodu u siebie w projekcie, podczas review, gdzie pewien proces zwracał sporo skomplikowanych wyników, stwierdziłem, że muszę natychmiast zastosować FEST’a. Trochę nawijki z architektem i dostałem zielone światło. Test w pierwszej, typowej JUnit’owej formie poniżej:

@Test
public void shouldGenerateInnerPlanets() {
 // given
 SolarSystem solarSystem = new SolarSystem();

 // when
 Set<Planet> innerPlanets = solarSystem.getInnerPlanets();

 // then
 assertEquals(4, innerPlanets.size());
 for (Planet innerPlanet : innerPlanets) {
  if (innerPlanet.getName().equals("Mercury")) {
   List<Gases> mercuryGases = asList(Gases.OXYGEN, Gases.SODIUM, Gases.HYDROGEN);
   assertPlanet(innerPlanet, RotationDirection.LEFT, 4_879_400, 87.96935, 3.701, mercuryGases);
  } else if (innerPlanet.getName().equals("Venus")) {
   List<Gases> venusGases = asList(Gases.CARBON_DIOXIDE, Gases.NITROGEN);
   assertPlanet(innerPlanet, RotationDirection.RIGHT, 12_103_700, 224.700_96, 8.87, venusGases);
  } else if (innerPlanet.getName().equals("Earth")) {
   List<Gases> earthGases = asList(Gases.NITROGEN, Gases.OXYGEN);
   assertPlanet(innerPlanet, RotationDirection.LEFT, 12_756_273, 365.256_363_004, 9.806_65, earthGases);
  } else if (innerPlanet.getName().equals("Mars")) {
   List<Gases> marsGases = asList(Gases.CARBON_DIOXIDE, Gases.NITROGEN);
   assertPlanet(innerPlanet, RotationDirection.LEFT, 6_804_900, 686.960_1, 3.69, marsGases);
  } else {
   throw new AssertionError("Undefined Planet Name: " + innerPlanet.getName()
     + " in result\n" + innerPlanet.toString());
  }
 }
}

private void assertPlanet(Planet planet, RotationDirection direction, long diameterInMeter,
        double yearInEarthDays, double acceleration, List<Gases> atmosphereGases) {
 assertEquals(direction, planet.getRotationDirection());
 assertEquals(diameterInMeter, planet.getDiameter());
 assertEquals(yearInEarthDays, planet.getSiderealYear().inEarthDays(), 0.01);
 assertEquals(acceleration, planet.getAcceleration(), 0.01);
 for (Gases gas : atmosphereGases) {
  assertTrue("Planet " + planet.getName() + " doesn't contains " + gas,
    planet.getAtmosphereGases().contains(gas));
 }
}  

Testujemy metodę generującą zbiór planet wewnętrznych, czyli 4ch pierwszych z naszego układu słonecznego. Pierwsza asercja w linii 10 sprawdza, czy metoda zwróciła odpowiednią ilość planet. Co się stanie, gdy w wyniku będzie nieprawidłowa ilość elementów? Będziemy o tym wiedzieć, ale co dokładnie jest, a czego nie ma, nie będzie wyszczególnione w komunikacie błędów zepsutego testu. Można do pracy wtedy za prząść debugera, ale ja jego nienawidzę.

Kolejny problem to konieczność odszukania właściwego obiektu. Nasza testowana metoda zwraca zbiór planet, przez co nie możemy być pewni, co do kolejności obiektów w wyniku. Dzięki temu dostaliśmy brzydki blok if-else. W takim każdym if’ie definiowana jest najpierw lista gazów, jakie ma zawierać dana planeta. Można by było to co prawda zrobić inline w wywołaniu assertPlanet(), ale wtedy w ogóle łańcuszek argumentów byłby nieczytelny.

Drugim elementem każdego if’a jest właśnie wywołanie assertPlanet() z jakimiś argumentami. Co one oznaczają? Trzeba skoczyć do ciała metody i sprawdzić. Jaka jest ich kolejność? Przy takiej ich liczbie na pewno jej nie zapamiętamy na dłużej niż 5 minut. Można ewentualnie zrezygnować z metody assertPlanet() i ją zinline’nować, ale wtedy mamy sporo duplikacji w kodzie, niskiej czytelności i słabej reużywalności.

Czas na wersję tego samego kodu z Hamcrestem.

@Test
public void shouldGenerateInnerPlanets() {
 // given
 SolarSystem solarSystem = new SolarSystem();

 // when
 Set<Planet> innerPlanets = solarSystem.getInnerPlanets();

 // then
 assertThat(innerPlanets, hasItem(withName("Mercury")));
 Planet mercury = findPlanetByName(innerPlanets, "Mercury");
 assertThat(mercury, is(rotation(RotationDirection.LEFT)));
 assertThat(mercury, is(diameterInMeter(4_879_400)));
 assertThat(mercury, is(yearLongInEarthDays(87.96935)));
 assertThat(mercury, is(acceleration(3.701)));
 assertThat(mercury, hasGas(Gases.OXYGEN));
 assertThat(mercury, hasGas(Gases.SODIUM));
 assertThat(mercury, hasGas(Gases.HYDROGEN));

 assertThat(innerPlanets, hasItem(withName("Venus")));
 Planet venus = findPlanetByName(innerPlanets, "Venus");
 assertThat(venus, is(rotation(RotationDirection.RIGHT)));
 assertThat(venus, is(diameterInMeter(12_103_700)));
 assertThat(venus, is(yearLongInEarthDays(224.700_96)));
 assertThat(venus, is(acceleration(8.87)));
 assertThat(venus, hasOnlyGases(Gases.CARBON_DIOXIDE, Gases.NITROGEN));

 assertThat(innerPlanets, hasItem(withName("Earth")));
 Planet earth = findPlanetByName(innerPlanets, "Earth");
 assertThat(earth, is(rotation(RotationDirection.LEFT)));
 assertThat(earth, is(diameterInMeter(12_756_273)));
 assertThat(earth, is(yearLongInEarthDays(365.256_363_004)));
 assertThat(earth, is(acceleration(9.806_65)));
 assertThat(earth, hasGases(Gases.NITROGEN, Gases.OXYGEN));
 assertThat(earth, hasNotGases(Gases.SODIUM));

 assertThat(innerPlanets, hasItem(withName("Mars")));
 Planet mars = findPlanetByName(innerPlanets, "Mars");
 assertThat(mars, is(rotation(RotationDirection.LEFT)));
 assertThat(mars, is(diameterInMeter(6_804_900)));
 assertThat(mars, is(yearLongInEarthDays(686.960_1)));
 assertThat(mars, is(acceleration(3.69)));
 assertThat(mars, hasGases(Gases.CARBON_DIOXIDE, Gases.NITROGEN));
 assertThat(mars, hasNotGases(Gases.OXYGEN));

 assertThat(innerPlanets, hasSize(4));
}


Kod wygląd już bardziej czytelnie, ale musiałem do tego dopisać 8 klas Matcher’ów. Właściwie na każdą asercję potrzeba oddzielnego Matcher’a. Mimo wszystko nie pozbyliśmy się problemu wyszukiwania potrzebnego obiektu, co widać w linii 11. Może jest jakieś lepsze obejście na to, ale ja nie znalazłem. Na koniec testu, warto jeszcze sprawdzić, czy testowany zbiór posiada żądaną ilość elementów. Jednak tutaj i tak ewentualny komunikat błędu będzie mało czytelny.

Dobra czas na gwiazdę wieczoru, czyli na test napisany z wykorzystaniem FEST’a.

@Test
public void shouldGenerateInnerPlanets() {
 // given
 SolarSystem service = new SolarSystem();

 // when
 Set<Planet> innerPlanets = service.getInnerPlanets();

 // then
 assertThat(innerPlanets)
   .containsPlanetWithName3("Mercury")
   .withRotation(RotationDirection.LEFT)
   .withDiameterInMeter(4_879_400)
   .withYearInEarthDays(87.96935)
   .withAcceleration(3.701)
   .withGas(Gases.OXYGEN)
   .withGas(Gases.SODIUM)
   .withGas(Gases.HYDROGEN);

 assertThat(innerPlanets)
   .containsPlanetWithName3("Venus")
   .withRotation(RotationDirection.RIGHT)
   .withDiameterInMeter(12_103_700)
   .withYearInEarthDays(224.700_96)
   .withAcceleration(8.87)
   .containsOnlyGases(Gases.CARBON_DIOXIDE, Gases.NITROGEN);

 assertThat(innerPlanets)
   .containsPlanetWithName3("Earth")
   .withRotation(RotationDirection.LEFT)
   .withDiameterInMeter(12_756_273)
   .withYearInEarthDays(365.256_363_004)
   .withAcceleration(9.806_65)
   .containsGases(Gases.NITROGEN, Gases.OXYGEN)
   .doesNotContainGases(Gases.SODIUM);

 assertThat(innerPlanets)
   .containsPlanetWithName3("Mars")
   .withRotation(RotationDirection.LEFT)
   .withDiameterInMeter(6_804_900)
   .withYearInEarthDays(686.960_1)
   .withAcceleration(3.69)
   .containsGases(Gases.CARBON_DIOXIDE, Gases.NITROGEN)
   .doesNotContainGases(Gases.OXYGEN);

 assertThat(innerPlanets)
   .containsOnlyPlanets("Mercury", "Venus", "Earth", "Mars");
} 

Początek testu jest taki jak w poprzednich przypadkach, ale od sekcji  //then  robi się ciekawie. Widać wyraźnie 4 logicznie oddzielone od siebie bloki, po jednym na sprawdzenie każdej z planet. Można też łatwo zauważyć łańcuch wywołań, który powinien nam być przynajmniej ze wzorca Bulder znany, a który można ogólniej zdefiniować, jako fluent interface. Hamcrest stara się również iść w tym kierunku, ale jak dla mnie te zapis jest bardziej zwięzły i przejrzysty. Mamy po prostu nazwę właściwości, którą weryfikujemy, konkretną wartość i zazwyczaj jakiś prefix w nazwie metody. Ja preferuję dla kolekcji prefix contains, a dla konkretnych właściwości with lub has. Ale jest to już kwestia umowna, co komu lepiej leży.

Jak więc tworzyć takie asercje?

public class PlanetSetAssert extends AbstractAssert<PlanetSetAssert, Set<Planet>> {

    private PlanetSetAssert(Set<Planet> actual) {
        super(actual, PlanetSetAssert.class);
    }

    public static PlanetSetAssert assertThat(Set<Planet> actual) {
        Assertions.assertThat(actual)
                .isNotNull();
        return new PlanetSetAssert(actual);
    }

    public PlanetSetAssert containsPlanetWithName(String expectedPlanetName) {
        for (Planet planet : actual) {
            if(planet.getName().equals(expectedPlanetName)) {
                return this;
            }
        }
        throw new AssertionError("Actual Set doesn't contains Planet with name: " + expectedPlanetName);
    } 

Aby stworzyć własną, fachową asercję, należy rozszerzyć klasę AbstractAssert. Ta przyjmuje za dwa typy generyczne kolejno samą siebie i typ który będziemy obsługiwać, w tym przypadku Set<Planet>. Później definiujemy konstruktor, który jako argument bierze badany obiekt. Konstruktora nie będziemy wywoływać poza obrębem tej klasy, więc można go uczynić prywatnym. Korzystać natomiast będziemy ze statycznej metody fabrykującej assertThat(), która to jako jedyna ze zdefiniowanego wcześniej konstruktora korzysta. Ja preferuję w tym miejscu od razu sprawdzić, czy przekazany obiekt nie jest null’em. Oficjalny tutoral (swoją drogą bardzo dobry, z licznymi komentarzami) preferuje wykonywać to sprawdzenie w każdej metodzie, ale jak dla mnie to jest niepotrzebna redundancja.

Na koniec jest przykładowa implementacja metody, sprawdzającej, czy w danym zbiorze znajduje się planeta o podanej nazwie. W tym przypadku metoda zwraca this, co pozwala wywoływać łańcuchowo dalsze asercje na zbiorze planet. Minusem takiego podejścia jest konieczność definiowania własnego komunikatu błędu, w przypadku niepowodzenia – linia 19. W przypadku gdy zależy nam na sprawdzeniu, czy dany zbiór zawiera szukany element, można tą samą funkcjonalność zaimplementować troszkę inaczej.

public PlanetSetAssert containsPlanetWithName2(String expectedPlanetName) {
 Planet expectedPlanet = new Planet(expectedPlanetName);

 Assertions.assertThat(actual)
   .usingElementComparator(new PlanetNameComparator())
   .contains(expectedPlanet);
 return this;
}

private class PlanetNameComparator implements Comparator<Planet> {
 @Override
 public int compare(Planet p1, Planet p2) {
  return p1.getName().compareTo(p2.getName());
 }
} 


W tym przypadku, za pomocą metody usingElementComparator(), podmieniamy sposób porównywania elementów w kolekcji. Bardzo użyteczny mechanizm. Nadpisujemy tylko typowego porównywacza (komparatora) i gotowe. I co fajniejsze, to dostajemy bardzo przejrzysty komunikat błędu:

java.lang.AssertionError: expecting:
<[Venus, Earth, Mars]>
 to contain:
<[Mercury]>
 but could not find:
<[Mercury]>
 according to 'PlanetNameComparator' comparator
 at com.blogspot.mstachniuk.hamcrestvsfest.fest.PlanetSetAssert.containsPlanetWithName2(PlanetSetAssert.java:34)
  at com.blogspot.mstachniuk.hamcrestvsfest.fest.SolarSystemFestTest.shouldGenerateInnerPlanets(SolarSystemFestTest.java:24)

Właśnie przejrzyste komunikaty błędów (zwłaszcza wśród kolekcji) to siła tego narzędzia.

Teraz czas na moją ulubioną implementację tego typu metody, która to pozwala nam budować jeszcze bardziej płynące interfejsy:

public PlanetAssert containsPlanetWithName3(String expectedPlanetName) {
 Planet expectedPlanet = new Planet(expectedPlanetName);

 PlanetNameComparator comparator = new PlanetNameComparator();
 Assertions.assertThat(actual)
   .usingElementComparator(comparator)
   .contains(expectedPlanet);

 for (Planet planet : actual) {
  if(comparator.compare(planet, expectedPlanet) == 0) {
   return PlanetAssert.assertThat(planet);
  }
 }
 return null;
} 

Początek metody jest analogiczny jak w poprzednim przykładzie. Natomiast zamiast zwracać obiekt this, wyszukuję żądany element i zwracam już asercję, dla tej znalezionej planety. Dzięki temu mogę już sprawdzić inne jej właściwości. Właściwie, mógłbym w pętli wywołać zwykłą metodę equals() do wyszukania elementu, ale skoro mam już napisanego komparatora…

Wrcając jeszcze to całego kodu z listingu 3, to warto na końcu metody testowej jeszcze sprawdzić, czy w zwracanym wyniku, są tylko te planety, których się spodziewamy (jeśli tak mamy w wymaganiach). Przykładowa implementacja, jak to można zrobić:

public PlanetSetAssert containsOnlyPlanets(String... planets) {
 List<Planet> expectedPlanets = new ArrayList<Planet>();
 for (String planet : planets) {
  Planet p = new Planet(planet);
  expectedPlanets.add(p);
 }
 Assertions.assertThat(actual)
   .usingElementComparator(new PlanetNameComparator())
   .containsOnly(expectedPlanets.toArray(new Planet[expectedPlanets.size()]));
 return this;
}

Co ogólnie jest jeszcze fajnego w takich asercjach? To to, że sprawdzamy dokładnie to co chcemy sprawdzić, zamiast sprawdzać zawsze wszystko, jak było to w pierwszej wersji testu. Również zdefiniowałem sobie różne metody odnośnie sprawdzania występujących na danej planecie gazów atmosferycznych: withGas(Gases.OXYGEN), containsGases(Gases.NITROGEN, Gases.OXYGEN), containsOnlyGases(Gases.CARBON_DIOXIDE, Gases.NITROGEN), doesNotContainGases(Gases.SODIUM). Do wyboru do koloru / potrzeby. Po więcej szczegółów odsyłam do wiki projektu.

Z ciekawostek, warto jeszcze zaprezentować generator FEST asercji. Można sobie to samemu przetestować, ściągając mój projekt z github’a i odpalając assertion-generator\planet.bat. Skrypt automatycznie kopiuje wygenerowaną klasę do podkatalogu fest2 gdzieś w hierarchii testowej, gdzie jednocześnie znajduje się test niej korzystający. Wystarczy zmienić nazwę pakietu i doimportować potrzebne klasy, aby test z powrotem działał.

Tak wygląda kod, który korzysta z przykładowo wygenerowanych asercji:

@Test
public void shouldGenerateInnerPlanets() {
 // given
 SolarSystem service = new SolarSystem();

 // when
 Set<Planet> innerPlanets = service.getInnerPlanets();

 // then
 Planet mercury = findPlanetByName(innerPlanets, "Mercury");
 assertThat(mercury)
   .hasRotationDirection(RotationDirection.LEFT)
   .hasDiameter(4_879_400)
   .hasSiderealYear(new SiderealYear(87.96935))
   .hasAcceleration(3.701)
   .hasAtmosphereGases(Gases.OXYGEN, Gases.SODIUM, Gases.HYDROGEN);

 Planet venus = findPlanetByName(innerPlanets, "Venus");
 assertThat(venus)
   .hasRotationDirection(RotationDirection.RIGHT)
   .hasDiameter(12_103_700)
   .hasSiderealYear(new SiderealYear(224.700_96))
   .hasAcceleration(8.87)
   .hasAtmosphereGases(Gases.CARBON_DIOXIDE, Gases.NITROGEN);

 Planet earth = findPlanetByName(innerPlanets, "Earth");
 assertThat(earth)
   .hasRotationDirection(RotationDirection.LEFT)
   .hasDiameter(12_756_273)
   .hasSiderealYear(new SiderealYear(365.256_363_004))
   .hasAcceleration(9.806_65)
   .hasAtmosphereGases(Gases.NITROGEN, Gases.OXYGEN);

 Planet mars = findPlanetByName(innerPlanets, "Mars");
 assertThat(mars)
   .hasRotationDirection(RotationDirection.LEFT)
   .hasDiameter(6_804_900)
   .hasSiderealYear(new SiderealYear(686.960_1))
   .hasAcceleration(3.69)
   .hasAtmosphereGases(Gases.CARBON_DIOXIDE, Gases.NITROGEN);
}


Niestety musimy sami sobie dopisać metodę szukającą odpowiednią planetę, albo asercję dla Set<Planet>. W wygenerowanym kodzie jest jednoznaczna konwencja, że wszystkie metody sprawdzające nasze własności, dostają prefix has. Można sobie oczywiście to i sporo innych rzeczy zmienić w szablonach generowanego kodu.

A jak komuś mało takie generowanie kodu, to może sobie ten kod generować podczas builda maven’owego, dzięki Maven plugin for fest-assertion-generator. Można to również w moim przykładowym projekcie przetestować, usuwając pliki *Assert.java z katalogu: com\blogspot\mstachniuk\hamcrestvsfest\. Wówczas po odpaleniu mvn install, lub mvn test lub mvn generate-test-sources zostaną wygenerowane na nowo pliki z asercjami. Wówczas test SolarSystemFest3Test, który z nich korzysta, będzie nadal działał.

Ostatnio pojawił się jeszcze plugin do Eclipse’a: FEST eclipse plugin generujący prosto z tegoż środowiska nasze asercje. Zapowiada się ciekawie, więc zachęcam do skorzystania.

Pomyślnych asercji!

5 komentarzy:

  1. Hej, fajny artykuł, dzięki :) Kiedyś na Craftsmanship Community po spotkaniu o FEST też ktoś wpadł na pomysł automatycznego generowania asercji dla propertiesów, ale ostatecznie go nie zrobiliśmy. Na szczęście ktoś miał więcej zapału, dzięki.

    Co do zalet Hamcresta ja bym jednak jeszcze mocniej podkreślił, że to, że jest od razu w JUnit to wielki plus. Dzięki temu wprowadzenie do projektu jest zupełnie bezbolesne i natychmiastowe, a FEST wymaga dodatkowych uzgodnień, bo to nowa biblioteka i czasem się nie da. Sam to przeszedłem kilka razy.

    Oczywiście pisanie własnych Matcherów w Hamcrest nie jest tak wygodne jak asercji w FEST, ale da się żyć.

    Pozdrawiam,
    Andrzej

    OdpowiedzUsuń
  2. Jak się nie da nowej takiej biblioteczki do projektu wprowadzić, to jest to sytuacja patologiczna! Albo zespół jest leniwy i ma to gdzieś, albo osoba decyzyjna jest uparta. Jest to biblioteka wspomagająca testowanie, więc w kodzie produkcyjnym nie nabrudzi. To nie jest przecież wymiana bazy danych, czy migracja ze Springa na JEE6 czy Google Guice.

    OdpowiedzUsuń
  3. Używam FESTa już od dłuższego czasu i moim zdaniem świetnie się sprawdza. Testy są o wiele czytelniejsze niż w przypadku Hamcresta. Ostatnio w projekcie wprowadzamy Spocka i to dopiero jest fajna zabawka... a przy okazji prosty i pożyteczny sposób na przemycenie Grooviego do projektu :). Bardzo poprawia ekspresywność.

    OdpowiedzUsuń
  4. My ostatnio zaczęliśmy używać AssertJ - to klon FEST-a, ale dynamicznej rozwijany, bardzo ambitny autor, szybko odpowiada na zgłoszenia, więcej wbudowanych asercji, polecam.
    p.s.
    Marcin, mój blog leży odłogiem, ale Twój miał być wzorem! A tu zaledwie 3 wpisy w tym roku, co się dzieje?

    OdpowiedzUsuń
  5. @AB Andrzej, bardzo mi schlebiasz, ale jak potrzebujesz dobrej inspiracji to zawsze można spojrzeć na blog Jacka Laskowskiego. A co do częstotliwości wpisów u mnie, to wiadomo czas, który ostanio jest bardziej inwestowany w inne aktywności. Pomysłów na treść mam sporo, tylko do tego trzeba przygotować porządny kawałek kodu i to trwa.
    Ale postanawiam się poprawić, komentarze takie jak twój mnie nakręcają.

    OdpowiedzUsuń