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

#Testy

Given-When-Then. Każdy, kto pisał testy w stylu BDD, zna tę strukturę. Ale czy używamy jej naprawdę, czy tylko ją symulujemy przez dodawanie komentarzy?

Problem z komentarzami

Najczęstszy sposób implementacji Given-When-Then w testach jednostkowych wygląda tak:

@Test
void shouldRejectOrderWhenNotEnoughItems() {
    // given
    inventory.add("pencil", 100);

    // when
    Order order = orderService.createOrder("pencil", 150);

    // then
    assertThat(order.isRejected()).isTrue();
}

Na pierwszy rzut oka wygląda to przejrzyście. Ale komentarze kłamią.

Komentarze nie są weryfikowane przez kompilator. Możesz napisać // given i umieścić tam kod, który należy do fazy when. Kompilator nie zaprotestuje. Testy przejdą. Komentarz będzie mijał się z rzeczywistością.

Co więcej, komentarze łatwo stają się przestarzałe. Refaktoryng kodu testu nie obejmuje zazwyczaj aktualizacji komentarzy. Po kilku miesiącach komentarz mówi jedno, a kod robi drugie.

Poka-yoke w testach

Poka-yoke to japońska technika "mistake-proofing" — projektowania systemów tak, żeby błąd był niemożliwy lub natychmiastowo widoczny. W produkcji: jig, który uniemożliwia włożenie części w złej orientacji. W kodzie: typy, które uniemożliwiają błędne użycie API.

Jak zastosować poka-yoke do struktury Given-When-Then?

Zamiast komentarzy, wyciągamy każdą fazę do osobnej metody.

Wyciąganie logiki do metod givenXxx/whenXxx/thenXxx

Oto ta sama logika, ale z metodami zamiast komentarzy:

@Test
void shouldRejectOrderGivenNotEnoughItems() {
    givenItemsAdded("pencil", 100);
    whenOrderingItems("pencil", 150);
    thenOrderCannotBeExecuted();
}

private void givenItemsAdded(String item, int quantity) {
    inventory.add(item, quantity);
}

private void whenOrderingItems(String item, int quantity) {
    result = orderService.createOrder(item, quantity);
}

private void thenOrderCannotBeExecuted() {
    assertThat(result.isRejected()).isTrue();
}

Różnica jest subtelna, ale fundamentalna.

Zalety metod Given/When/Then

1. Enkapsulacja

Metody givenXxx i thenXxx ukrywają szczegóły implementacji. Jeśli zmieni się sposób dodawania produktów do magazynu, zmieniasz tylko metodę givenItemsAdded — nie każdy test z osobna.

2. Reużywalność

Ta sama metoda givenItemsAdded może być użyta w dziesiątkach testów. Gdy API produkcyjne się zmienia, poprawiasz jeden punkt, a wszystkie testy nadal kompilują się i działają.

@Test
void shouldAcceptOrderWhenEnoughItems() {
    givenItemsAdded("pencil", 100);
    whenOrderingItems("pencil", 50);
    thenOrderIsAccepted();
}

@Test
void shouldRejectOrderGivenNotEnoughItems() {
    givenItemsAdded("pencil", 100);
    whenOrderingItems("pencil", 150);
    thenOrderCannotBeExecuted();
}

@Test
void shouldRejectOrderForUnknownItem() {
    givenItemsAdded("pencil", 100);
    whenOrderingItems("pen", 10);
    thenOrderCannotBeExecuted();
}

3. Czytelność nazw testów

Gdy testy używają metod given/when/then, nazwy testów mogą być bardziej opisowe i biznesowe. Czytasz metodę testową i rozumiesz scenariusz bez wchodzenia w szczegóły implementacji.

4. Kompilator jako strażnik

To kluczowa zaleta. Jeśli metoda givenItemsAdded zmienia sygnaturę (np. zaczyna wymagać dodatkowego parametru), kompilator wskaże wszystkie miejsca, które wymagają aktualizacji. Komentarze tego nie zapewnią nigdy.

Hierarchia klas testowych

Wzorzec ten sprawdza się szczególnie dobrze w połączeniu z hierarchią klas:

abstract class OrderTestBase {

    protected Inventory inventory;
    protected OrderService orderService;
    protected OrderResult result;

    @BeforeEach
    void setUp() {
        inventory = new InMemoryInventory();
        orderService = new OrderService(inventory);
    }

    protected void givenItemsAdded(String item, int quantity) {
        inventory.add(item, quantity);
    }

    protected void whenOrderingItems(String item, int quantity) {
        result = orderService.createOrder(item, quantity);
    }

    protected void thenOrderIsAccepted() {
        assertThat(result.isAccepted()).isTrue();
    }

    protected void thenOrderCannotBeExecuted() {
        assertThat(result.isRejected()).isTrue();
    }
}

class OrderRejectionTest extends OrderTestBase {

    @Test
    void shouldRejectOrderGivenNotEnoughItems() {
        givenItemsAdded("pencil", 100);
        whenOrderingItems("pencil", 150);
        thenOrderCannotBeExecuted();
    }
}

Klasa bazowa zawiera wspólne metody setup i helpery. Klasy pochodne koncentrują się na konkretnych scenariuszach.

Czy to jest prawdziwe BDD?

Przyznajmy szczerze: opisany wzorzec to nie jest pełne BDD w rozumieniu Behaviour-Driven Development z narzędziami jak Cucumber czy JBehave, gdzie scenariusze piszą analitycy i testerzy biznesowi w języku naturalnym (Gherkin).

To jest wzorzec organizacji kodu testowego inspirowany Given-When-Then. I jako taki jest bardzo użyteczny, niezależnie od tego, czy piszesz testy w pełnym BDD czy klasycznym TDD.

Kiedy nie stosować tego wzorca?

Nie każdy test wymaga tej struktury. Dla prostych, jednolinijkowych asercji metody given/when/then to przerost formy nad treścią:

@Test
void shouldReturnZeroForEmptyList() {
    assertThat(calculator.sum(List.of())).isZero();
}

Wzorzec najlepiej sprawdza się przy testach z:

  • nietrywialnym setupem (wiele kroków przygotowania stanu)
  • złożonym flow (kilka kroków akcji)
  • wieloma wariantami scenariusza (reużywalne helpery)

Podsumowanie

Given-When-Then jako komentarze to czysto kosmetyczne rozwiązanie — daje złudzenie struktury bez jej realnych korzyści. Wyciąganie logiki do metod givenXxx/whenXxx/thenXxx zamienia strukturę z dekoracji w mechanizm enkapsulacji i reużywalności.

Kompilator staje się strażnikiem spójności testów. To właśnie różni kod od komentarza.