Test napisany po kodzie zwykle sprawdza to, co kod robi, a nie to, co miał robić. Programowanie sterowane testami (TDD) naprawia to, odwracając kolejność: piszesz test, patrzysz, jak nie przechodzi, a potem piszesz najmniejszy możliwy kod, który sprawia, że test przechodzi. Ponieważ test istnieje przed kodem, który sprawdza, nie może po cichu uznać za poprawne tego, co akurat zwróci kod. Kent Beck sformalizował ten cykl jako red, green, refactor w swojej książce z 2002 roku Test-Driven Development: By Example. Nazwa się przyjęła.

Co naprawdę robi cykl red-green-refactor

Każde przejście cyklu ma trzy kroki, a pominięcie jednego z nich zmienia go w coś innego.

Red. Napisz test dla zachowania, które jeszcze nie istnieje, i uruchom go. Musi nie przejść. Jeśli przejdzie od razu, albo zachowanie już istnieje, albo test niczego realnie nie sprawdza. Czerwony wynik to dowód, że test potrafi złapać błąd, który ma łapać.

Green. Napisz najmniejszą możliwą ilość kodu produkcyjnego, który sprawia, że test przechodzi. Nie najlepszy kod. Minimalny kod. Zwrócenie zahardkodowanej wartości to legalny krok green, o ile wiesz, że właśnie to zrobiłeś, i nie zatrzymujesz się na tym.

Refactor. Gdy test przechodzi, posprzątaj to, co właśnie napisałeś: usuń duplikację albo wydziel funkcję, jeśli ma to sens. Test musi przez cały ten czas pozostać zielony, bo właśnie tak wiesz, że zachowanie się nie zmieniło, zmieniła się tylko forma kodu.

Potem wybierz kolejne zachowanie i zacznij od nowa. Jedna iteracja zajmuje minuty: jedna gałąź if, jeden przypadek brzegowy, nigdy cała funkcjonalność naraz.

Przykład TDD w Pythonie: kata FizzBuzz

FizzBuzz jest wystarczająco małe, żeby widzieć rytm zamiast problemu. Dla danej liczby zwraca "Fizz", jeśli dzieli się przez 3, "Buzz", jeśli dzieli się przez 5, "FizzBuzz", jeśli dzieli się przez obie, a w pozostałych przypadkach samą liczbę.

Zacznij od przypadku, który nie pasuje do żadnego z nich:

1
2
3
4
5
6
# test_fizzbuzz.py
from fizzbuzz import fizzbuzz


def test_returns_the_number_as_a_string():
    assert fizzbuzz(1) == "1"
1
2
3
4
$ pytest -q
ImportError while importing test module 'test_fizzbuzz.py'.
E   ModuleNotFoundError: No module named 'fizzbuzz'
1 error in 0.01s

Czerwony, i nie musiałeś specjalnie wymuszać tego niepowodzenia: fizzbuzz jeszcze nie istnieje, więc pytest nawet nie potrafi zaimportować modułu testowego. To wciąż jest ważny czerwony wynik: poprawka jest taka sama w obu przypadkach. Najmniejsza rzecz, która sprawia, że test przechodzi:

1
2
3
# fizzbuzz.py
def fizzbuzz(n):
    return str(n)
1
2
3
$ pytest -q
.
1 passed in 0.01s

Zielony. Kolejne zachowanie: wielokrotności 3.

1
2
def test_returns_fizz_for_multiples_of_three():
    assert fizzbuzz(3) == "Fizz"

To nie przechodzi (assert '3' == 'Fizz'), a minimalna poprawka to jedna gałąź:

1
2
3
4
def fizzbuzz(n):
    if n % 3 == 0:
        return "Fizz"
    return str(n)

Wielokrotności 5 mają taki sam kształt, więc dodaj test i odpowiednie elif. Potem napisz przypadek, który złapie niedopatrzenie z pierwszych dwóch kroków: liczbę podzielną przez obie.

1
2
def test_returns_fizzbuzz_for_multiples_of_fifteen():
    assert fizzbuzz(15) == "FizzBuzz"
1
2
3
4
$ pytest -q
...F
E   assert 'Fizz' == 'FizzBuzz'
3 passed, 1 failed in 0.01s

15 % 3 == 0 jest sprawdzane jako pierwsze, więc funkcja zwraca "Fizz", zanim w ogóle spojrzy na 15 % 5. Tutaj kolejność ma znaczenie. Poprawką jest gałąź dla 15, która wykonuje się przed pozostałymi dwiema:

1
2
3
4
5
6
7
8
def fizzbuzz(n):
    if n % 15 == 0:
        return "FizzBuzz"
    if n % 3 == 0:
        return "Fizz"
    if n % 5 == 0:
        return "Buzz"
    return str(n)
1
2
3
$ pytest -q
....
4 passed in 0.01s

Znowu zielony, a plik testowy ma teraz coś, co warto zrefaktoryzować: cztery niemal identyczne funkcje. Połączenie ich w jeden sparametryzowany test liczy się jako refactor tak samo jak wszystko, co zrobiłbyś w fizzbuzz.py, o ile zieleń utrzyma się przez cały czas.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
import pytest

from fizzbuzz import fizzbuzz


@pytest.mark.parametrize(
    "n, expected",
    [
        (1, "1"),
        (3, "Fizz"),
        (5, "Buzz"),
        (15, "FizzBuzz"),
    ],
)
def test_fizzbuzz(n, expected):
    assert fizzbuzz(n) == expected

Błąd kolejności wyszedł na jaw tylko dlatego, że test dla 15 istniał przed poprawką. Napisz kod najpierw i zatrzymaj się na “3 daje Fizz, 5 daje Buzz, wygląda na gotowe”, a fizzbuzz(15) zwróci "Fizz" i nic w zestawie testów tego nie wyłapie.

Dlaczego kod pisany test-first wychodzi słabo powiązany

TDD zmienia projekt kodu, zanim jeszcze uruchomi się jakikolwiek test, bo musisz napisać kod tak, żeby test mógł go wywołać. Weźmy klasę, która sama buduje sobie w środku repozytorium do bazy danych – dokładnie ten wzorzec, od którego zaczyna się Czym Jest Dependency Injection?. W teście nie da się podstawić dla niej test double, bo nic w jej sygnaturze na to nie pozwala. Napisanie testu najpierw wypycha tę zależność do parametru albo argumentu konstruktora, zanim jeszcze powstanie kod produkcyjny, który mógłby się temu opierać. Luźnego powiązania nie dodajesz potem jako bonusu. Bez niego test, który zamierzasz napisać, po prostu się nie uruchomi.

Ta sama presja sprawia, że funkcje robią tylko jedną rzecz. Jeśli test musi przygotować trzy niepowiązane ze sobą elementy stanu, żeby sprawdzić jedno zachowanie, testowana funkcja robi trzy niepowiązane ze sobą rzeczy. Podziel ją, a test stanie się krótszy.

Kiedy nie używać TDD

Cykl potrzebuje kilku warunków: znasz zachowanie, które chcesz uzyskać, zanim je napiszesz, testy uruchamiają się ponownie w kilka sekund, a kod ma miejsce, w które można wstawić test double. Zabraknie któregokolwiek z nich i cykl przestaje się opłacać. Nawet gdy wszystkie są spełnione, ma martwy punkt.

Jeszcze nie znasz kształtu tej rzeczy. Jeśli eksplorujesz nieznane API albo robisz spike, żeby sprawdzić, czy jakieś podejście w ogóle jest wykonalne, napisz najpierw kod do wyrzucenia. Naucz się, jaka jest właściwa abstrakcja, a potem napisz testy do wersji, którą zamierzasz zatrzymać. Testy do kodu, który zaraz usuniesz, to podwójny wysiłek.

Pętla sprzężenia zwrotnego jest wolna. TDD oznacza ponowne uruchamianie testów po każdej małej zmianie. Zestaw testów, który przy każdym uruchomieniu dotyka prawdziwej bazy danych albo sieci, zamienia każde takie powtórzenie w czekanie. Testy Jednostkowe vs Integracyjne vs End-to-End wyjaśnia, skąd bierze się szybkość na poziomie jednostkowym: z zastępowania tych zależności. Jeśli jedyne testy, jakie możesz napisać dla kawałka kodu, to wolne testy integracyjne, stosowanie TDD do tego kodu oznacza czekanie, a ludzie przestają robić rzeczy, które każą im czekać.

Jeszcze nie ma gdzie wpiąć testu. Kodu legacy, z konstruktorami sięgającymi bezpośrednio do prawdziwej infrastruktury i funkcjami wywołującymi się nawzajem wprost, nie da się rozwijać metodą TDD od obecnego stanu. Nie ma gdzie wstawić test double. Czym Jest Spaghetti Code? opisuje drogę do środka: characterization test, który najpierw przypina istniejące zachowanie, napisany dla kodu takiego, jaki jest. Gdy taka siatka bezpieczeństwa już istnieje, red-green-refactor działa przy kolejnej zmianie.

Nadmierny mocking i tak przepuszcza zły projekt. Test, który mockuje każdego współpracownika, może zrobić się zielony na funkcji, która w środku jest bałaganem, o ile jej wywołania pasują do tego, czego oczekują mocki. TDD trzyma twoją uwagę na jednej jednostce, która jest przed tobą. Co jakiś czas i tak trzeba zrobić krok wstecz i zapytać, czy jednostki mają właściwy kształt.

Jak zacząć z TDD: popraw błąd metodą TDD

Na pierwszą próbę wybierz błąd, nie nową funkcjonalność. Napisz test, który go odtwarza: wywołaj funkcję z danymi wejściowymi, które ją psują, sprawdź wynik, który naprawdę chcesz uzyskać, i zobacz, jak test nie przechodzi z właściwego powodu, zanim dotkniesz poprawki. Poprawka staje się krokiem green, a test zostaje w zestawie, żeby błąd nie mógł wrócić niezauważony. Rób commit po każdym przejściu. Ślad małych, zawsze zielonych commitów sprawia, że git bisect jest szybki następnym razem, gdy coś się zepsuje i nikt nie pamięta gdzie.

Powiązane artykuły