Given-When-Then to ściema? O BDD w testach

Pisanie testów jednostkowych w manierze TDD już od dawna było dla mnie czymś oczywistym. Tego dnia mogę powiedzieć, że byłem naprawdę zadowolony z kodu oraz z testów, które napisałem.

git commit, git push i wystawiam pull requesta. Czekam na review od kolegi.

Dostaję komentarz:

Wszystko spoko, fajnie, testy mi się podobają, tylko dopisz komentarze given when then.

Pierwsza reakcja: „A no tak, rzeczywiście, zapomniałem”. Pisanie testów w takim stylu było u nas niepisaną regułą. Już miałem odpisać, że zaraz poprawię, jednak w tym momencie zatrzymałem się… i zapytałem sam siebie: „Zaraz, ale właściwie… to po co?”

Mimo popularności, jest kilka rzeczy, o których zwolennicy tej metody nie wspominają. O nich właśnie jest ten artykuł.

Właśnie, po co to Given When Then?

Given-When-Then to podejście do pisania testów wywodzący się z ruchu Behaviour-Driven Development. Bardziej konkretnie związane jest to z ideą Specyfikacji przez przykład. Zapoznanie się z linkami może pomóc zrozumieć to podejście, jednak co musisz wiedzieć, to sprowadza się to do zapisywania testów bardziej „ludzkim” językiem – takim, aby był zrozumiały też dla osób nietechnicznych.

Są narzędzia, które wspierają pisanie testów (z założenia głównie akceptacyjnych) w takiej manierze. Wiele osób kojarzy pewnie takie narzędzia jak Cucumber lub JBehave. Taki sposób pisania testów wyciekł również do naszych codziennych testów jednostkowych i najczęściej realizuje się go za pomocą komentarzy sygnalizujących każdą sekcję.

Rozważmy prościutki test unitowy:

@Test
void shouldRejectOrderGivenNotEnoughItems() {
  //given
  Store store = new Store();
  store.addItems("pencil", 100);

  //when
  Order order = store.makeOrder("pencil", 150);

  //then
  assertThat(order.getResult()).isFalse();
}

Co tu jest nie tak? Na pewno nie jest to BDD! I prawda jest też taka, że konwencja given-when-then już zdążyła się od BDD „odkleić” i żyje teraz własnym życiem. Wiele osób stosuje to trochę bezrefleksyjnie. Wydawałoby się nawet, że traktują tę formułę jako pewnego rodzaju zaklęcie, które ma w magiczny sposób poprawić design ich testów.

Jest given-when-then, jest approve!

Potrafi być to niestety podejście zgubne. Fakt, że given when then może wprowadzić nieco przejrzystości, wskazując, które części za co odpowiadają. Potrafi to być jednak zdradliwe.

Problemy z komentarzami w kodzie

Pewnego razu miałem wątpliwą przyjemność oglądać kod, gdzie komentarze given when then były poumieszczane w taki sposób, że jedynie zaciemniały obraz sytuacji. Pierwsze co chciałem zrobić, to poprzesuwać kod testowy, aby miało to wszystko sens. Zauważyłem jednak, że nie jest to możliwe przez to, jak powiązany ze sobą cały kod był. Co więc zrobiłem zamiast tego, to ich zwyczajnie te komentarze usunąłem.

Dlaczego ta historia tak się zakończyła? Odpowiedź podpowiada dość znana zasada czystego kodu:

Komentarze kłamią!

Każdy programista świeżo po przeczytaniu książki „Clean code” Uncle Boba

Nawet nie musi to być zła wola czy też brak staranności lub wiedzy osoby piszącej test. Możliwe, że sposób, w jaki Ty umieścisz te komentarze, będzie zupełnie w porządku. Jednak pamiętaj, że z chwilę wpisania komendy git push kod przestaje być Twoją wyłączną własnością. Od tej pory każdy ma do niego dostęp i niekoniecznie będziesz w pobliżu, gdy ktoś postanowi go zmienić, abyś mógł go powstrzymać. 

Ktoś może się oburzyć, że przecież nie możemy wziąć odpowiedzialności za przyszłe cudze błędy. Czy na pewno? Jeśli ułatwiamy komuś pomylenie się, część odpowiedzialności spada na nas. Tak samo, jeśli nie zrobiliśmy wszystkiego, co możliwe, by to utrudnić.

Zgodnie z ideą poka-yoke za pomyłki nie należy winić ludzi, ale procesy. Odpowiedzią jest czynienie tego co robimy błędoodpornego lub, jak kto woli, idiotoodpornego.  Kiedy ktoś próbuje zmienić Twój kod, mamy pewne mechanizmy bezpieczeństwa. Kompilator oraz zestaw testów powinien powstrzymać innych (i nas samych po kilku latach) do robienia przynajmniej tych bardziej oczywistych głupot. Rzecz jasna, jeśli ktoś chce sobie zrobić krzywdę, to znajdzie sposób, jednak po co to ułatwiać?

Przenosimy given when then na nowy poziom

Rozwińmy zasadę, którą przytoczyliśmy wcześniej:

Komentarze kłamią, kod nigdy!

My nie będziemy w okolicy, gdy ktoś zechce zrobić coś głupiego z naszymi testami (na przykład dodać kod przygotowujący środowisko już w sekcji When). Natomiast kompilator raczej tam będzie. Więc spróbujmy zrobić z tego użytek i przeróbmy nasze testy.

Przypomnijmy sobie kod z wcześniejszego przykładu:

@Test
void shouldRejectOrderGivenNotEnoughItems() {
  //given
  Store store = new Store();
  store.addItems("pencil", 100);

  //when
  Order order = store.makeOrder("pencil", 150);

  //then
  assertThat(order.getResult()).isFalse();
}

Jak już mówiliśmy, nie są to testy w stylu BDD. Kod nie byłby czytelny dla osoby nietechnicznej. Do tego komentarze zawsze są problematyczne, bo kompilator nigdy ich nie sprawdza. Możemy pozbyć się obu tych problemów za jednym zamachem.

Wyjdźmy z koncepcji specyfikacji przez przykład i spróbujmy „opowiedzieć” sobie, co sprawdza dany test. Wraz z tą opowieścią pojawi się nam w głowie automatycznie odpowiedź na pytanie, co tak naprawdę jest ważne dla danego test case’a. Właśnie te istotne szczegóły chcemy jak najlepiej wyostrzyć. W naszym przykładzie widzę następującą historię:

  • Zakładając że w sklepie jest 100 ołówków
  • Kiedy składam zamówienie na 150 ołówków
  • To zamówienie nie może być zrealizowane

Więc po prostu przenieśmy tę opowieść jeden do jednego do naszego testu.

Poprawiamy test

@Test
void shouldRejectOrderGivenNotEnoughItems() {
  givenItemsAdded("pencil", 100);

  whenOrderingItems("pencil", 150);

  thenOrderCannotBeExecuted();
}

Pozostaje nam jedynie zdefiniowanie, co oznaczają poszczególne elementy opowieści:

private Store store = new Store();
private Order order;

// test cases

public void givenItemsAdded(String item, int quantity) {
  store.addItems(items, quantity);
}

public void whenOrderingItems(String item, int quantity) {
  order = store.makeOrder(items, quantity);
}

public void thenOrderCannotBeExecuted() {
  assertThat(order.getResult()).isFalse();
}

Taki kod posiada tę zaletę, że jest napisany jak specyfikacja. Metody ukrywają przed nami szczegóły implementacji, powodując, że bardzo łatwo na pierwszy rzut oka zrozumieć, jaki jest cel danego testu. Skupiamy się na aspektach biznesowych, na tym co robi nasz kod, a nie – jak.

Do tego zauważmy, że nowoutworzone metody tworzą bardzo zgrabny zestaw elementów, za pomocą których możemy łatwo dopisywać kolejne test case’y:

@Test
void shouldAcceptOrderGivenEnoughItems() {
  givenItemsAdded("pencil", 200);

  whenOrderingItems("pencil", 150);

  thenOrderIsExecuted();
}

public void thenOrderIsExecuted() {
  assertThat(order.getResult()).isTrue();
}

Poczekaj! To jeszcze nie koniec zalet!

Kolejną zaletą jest to, że ponieważ szczegóły implementacyjne są enkapsulowane. Wyobraźmy sobie, że chcemy pobierać informację o dostępności produktu z zewnętrznego systemu zamiast ustawiać to w obiekcie.

Tymczasem, dzięki enkapsulacji, przy zmianie sposobu działania testowanej metody musimy dokonać zmiany tylko w jednym miejscu. Same test case’y pozostają bez zmian. Co logiczne, ponieważ tak naprawdę wymagania biznesowe pozostały te same. Z punktu widzenia użytkownika system wciąż działa tak samo. Zobaczmy jak mogłaby wyglądać zmieniona metoda do ustawiania warunków początkowych testów, jeśli do sprawdzania dostępności produktów używalibyśmy zewnętrznego systemu.

public void givenItemsAdded(String item, int quantity) {
  when(externalSystem.checkAvailability(item)).thenReturn(quantity);
}

I tyle. Reszta kodu testów pozostaje co do znaku identyczna.

Zastosowanie w bardziej skomplikowanym teście

Powyższy przykład jest banalnie prosty i można stawiać tezę, że zysk z zastosowania tej techniki nie przewyższa poniesionego wysiłku. Tak naprawdę jednak prawdziwa moc tego podejścia pokazuje się, gdy nasza logika jest bardziej złożona. Zobaczmy kolejny przykład, gdzie składanie zamówienia staje się dużo bardziej skomplikowane.

@Test
void shouldGiveCustomerADiscountIfHeHasEnoughLoyaltyPoints() {
  //given
  when(availabilitySystem.checkAvailability("pencil"))
    .thenReturn(200);
  when(customerSystem.getUserData("User1").thenReturn(USER_DATA);
  when(loyaltyProgramSystem.getLoyaltyPoints("User1").thenReturn(1050)
  
  store.loadAvailabilityInformation();

  //when
  Order order = store.makeOrder("pencil", 150);

  //then
  assertThat(order.getPrice()).isEqualTo(60);
}

Zwróćmy najpierw uwagę na linijkę nr 5. Wygląda to na setup, który prawdopodobnie będzie wspólny dla wszystkich test case’ów. Po zweryfikowaniu, że tak będzie, przenosimy więc to do osobnej metody setupowej. Pamiętajmy, że jedną z dewiz nam przyświęcających jest to, aby w teście znajdował się jedynie kod, który pomoże zrozumieć, na czym polega dany use case, więc korzystajmy z takich okazji, by nasze testy uprościć.

@BeforeEach
void setup() {
  when(customerSystem.getUserData("User1").thenReturn(USER_DATA);
}

Idąc dalej, spróbujmy wydzielić pozostałe klauzule when() w postaci metod, które opisują ustawienia testu w sposób bardziej biznesowy

@Test
void shouldGiveCustomerADiscountIfHeHasEnoughLoyaltyPoints() {
  //given
  givenItemAvailability("pencil", 200);
  givenLoyaltyPoints(1050);

  //when
  Order order = store.makeOrder("pencil", 150);

  //then
  assertThat(order.getPrice()).isEqualTo(60);
}

void givenItemAvailability(String item, int quantity) {
  when(availabilitySystem.checkAvailability(item)).thenReturn(quantity);
  store.loadAvailabilityInformation();
}

void givenLoyaltyPoints(int points) {
  when(loyaltyProgramSystem.getLoyaltyPoints("User1").thenReturn(points)
}

Możemy również dodać metody enkapsulujące asercje oraz samą akcje, podobnie jak we wcześniejszym przykładzie. W tym momencie dochodzimy do wniosku, że komentarze given when then są w zasadzie zbędne, więc możemy je usunąć. Zobaczmy, jak wyglądałby ostateczny kształt naszego testu. Zwróćmy uwagę, jak udało się przy okazji stworzyć cegiełki, z których można łatwo budować następne test case’y.

@Test
void shouldGiveCustomerADiscountIfHeHasEnoughLoyaltyPoints() {
  givenItemAvailability("pencil", 200);
  givenLoyaltyPoints(1050);

  whenOrderingItems("pencil", 150);

  thenPriceIs(60);
}

@Test
void shouldGiveCustomerAStandardPriceIfHeHasNotEnoughLoyaltyPoints() {
  givenItemAvailability("pencil", 200);
  givenLoyaltyPoints(950);

  whenOrderingItems("pencil", 150);

  thenPriceIs(75);
}

@Test
void shouldNotExecuteOrderIfNotEnoughItems() {
  givenItemAvailability("pencil", 100);

  whenOrderingItems("pencil", 150);  

  thenOrderIsNotExecuted();
}

public void thenOrderIsNotExecuted() {
  assertThat(order.getResult()).isFalse();
}

Dopisywanie kolejnych test case’ów, z których każdy byłby równie dobrze zrozumiały dla osoby nietechnicznej jest teraz prostsze niż kiedykolwiek. Działa to też w drugą stronę. Dzięki przetłumaczeniu konceptów biznesowych na techniczne możemy łatwo tłumaczyć wymagania na nowe testy, ułatwiając sobie pisanie kodu.

Podsumowując

Koncepcja pisania testów według maniery given when then nie jest pozbawiona sensu. Chciałem jednak pokazać, że można pójść dalej, aby móc wycisnąć z tego podejścia więcej i pozbyć się pewnych problemów z nim związanych. Oparcie się na kodzie zamiast komentarzach sprawia, że kod naszych testów staje się bardziej skupiony na problemie biznesowym, a mniej na technicznej implementacji oraz jest mniej podatny na rozjazd testów z kodem produkcyjnym w przyszłości. To wszystko jest kolejną małą cegiełką czyniącą nasze testy bardziej użytecznymi.

Dodaj komentarz

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *