Test-Driven Development (TDD), czyli wytwarzanie oprogramowania sterowane testami, to podejście, w którym test powstaje przed kodem realizującym daną funkcjonalność. Programista najpierw określa oczekiwane zachowanie za pomocą automatycznego testu, następnie pisze minimalny kod potrzebny do jego przejścia, a na końcu poprawia strukturę rozwiązania bez zmiany jego działania.
Praca odbywa się w krótkim, wielokrotnie powtarzanym cyklu Red → Green → Refactor. TDD nie oznacza więc po prostu „dużo testów jednostkowych”. Test jest tutaj narzędziem prowadzącym proces projektowania i implementacji kodu.
Martin Fowler opisuje TDD jako technikę tworzenia oprogramowania sterowaną pisaniem testów i wskazuje jej korzenie w Extreme Programming rozwijanym przez Kenta Becka pod koniec lat 90.
Co to jest TDD?
W klasycznym podejściu programista może najpierw napisać funkcjonalność, a dopiero później przygotować testy sprawdzające jej działanie.
W Test-Driven Development kolejność jest odwrócona.
Najpierw trzeba odpowiedzieć na pytanie:
Jak powinien zachować się kod, którego jeszcze nie ma?
Odpowiedź zostaje zapisana jako test. Dopiero później powstaje implementacja spełniająca określone wymaganie.
Proces wygląda w uproszczeniu tak:
oczekiwane zachowanie → test → niepowodzenie testu → implementacja → przejście testu → refaktoryzacja → następny przypadek
IBM opisuje TDD podobnie: test dla określonej funkcjonalności powstaje przed odpowiadającym mu kodem, następnie dodawana jest implementacja wystarczająca do przejścia testu, a później kod i test mogą być udoskonalane przed przejściem do kolejnego zachowania.
TDD nie jest tym samym co testy jednostkowe
To ważne rozróżnienie.
Można mieć bardzo dobre pokrycie testami jednostkowymi i nie stosować TDD, jeżeli testy powstają dopiero po napisaniu kodu.
Można również stosować TDD bez próby przetestowania każdej pojedynczej metody lub uzyskania 100% code coverage.
Różnica dotyczy przede wszystkim procesu:
| Testy pisane po implementacji | Test-Driven Development |
|---|---|
| Najpierw powstaje kod | Najpierw powstaje test |
| Test głównie weryfikuje implementację | Test pomaga określić oczekiwane zachowanie |
| Projekt kodu może powstać przed testami | Projekt rozwija się razem z kolejnymi testami |
| Refaktoryzacja jest oddzielną czynnością | Refaktoryzacja jest częścią cyklu |
Agile Alliance określa TDD jako styl programowania, w którym kodowanie, testowanie i projektowanie są ze sobą ściśle połączone.
Jak działa TDD? Red-Green-Refactor
Podstawą Test-Driven Development jest krótki cykl:
Red → Green → Refactor
Zamiast przygotowywać od razu dziesiątki testów i całą funkcjonalność, wykonuje się kolejne niewielkie kroki.
Red – napisz test, który nie przechodzi
Najpierw powstaje test opisujący najmniejszy następny fragment oczekiwanego zachowania.
Następnie test trzeba uruchomić.
Powinien zakończyć się niepowodzeniem.
To ważne, ponieważ pozwala sprawdzić, czy test faktycznie wykrywa brak funkcjonalności. Jeżeli nowy test od początku jest zielony, warto ustalić dlaczego — być może funkcjonalność już istnieje albo test nie sprawdza tego, co miał sprawdzać.
Green – napisz minimalny kod potrzebny do przejścia testu
Kolejnym celem nie jest stworzenie od razu idealnej architektury.
Programista implementuje najmniejszą zmianę potrzebną do spełnienia określonego wymagania.
Następnie uruchamia test ponownie.
Jeżeli test nadal nie przechodzi, poprawia implementację. Jeżeli jest zielony, można przejść dalej.
Taki sposób pracy ogranicza tworzenie kodu „na przyszłość”, który nie odpowiada jeszcze żadnej zweryfikowanej potrzebie.
Refactor – popraw strukturę bez zmiany zachowania
Kiedy testy są zielone, można poprawić kod.
Refaktoryzacja może obejmować między innymi:
- usunięcie duplikacji,
- poprawienie nazw,
- uproszczenie warunków,
- wydzielenie odpowiedzialności,
- uporządkowanie zależności,
- podział zbyt dużych elementów kodu.
Refaktoryzacja nie oznacza po prostu optymalizacji wydajności. Jej celem jest zmiana wewnętrznej struktury oprogramowania bez zmiany obserwowalnego zachowania. Tak właśnie definiuje ją Martin Fowler.
Po refaktoryzacji ponownie uruchamia się testy. Jeżeli nadal przechodzą, można rozpocząć kolejny cykl.
Fowler zwraca również uwagę, że praktyczne TDD często rozpoczyna się od przygotowania listy przypadków, które chcemy obsłużyć. Następnie wybiera się jeden z nich i przechodzi przez Red-Green-Refactor, zamiast próbować implementować wszystko jednocześnie.
Przykład TDD krok po kroku
Załóżmy, że tworzymy w Pythonie funkcję obliczającą cenę produktu po rabacie.
Wymaganie:
dla ceny 100 i rabatu 20% funkcja powinna zwrócić 80.
1. Red – najpierw test
Nie mamy jeszcze implementacji.
Tworzymy test:
from pricing import calculate_discount
def test_calculates_percentage_discount():
assert calculate_discount(100, 20) == 80
Uruchamiamy test.
Powinien się nie powieść, ponieważ funkcja calculate_discount jeszcze nie istnieje.
To właśnie faza Red.
2. Green – minimalna implementacja
Teraz tworzymy kod:
def calculate_discount(price, discount_percent):
return price * (1 - discount_percent / 100)
Uruchamiamy test ponownie.
Jeżeli wynik wynosi 80, test przechodzi.
Mamy Green.
3. Refactor
Sprawdzamy kod pod kątem czytelności, duplikacji i struktury.
W tak prostym przypadku nie ma jeszcze czego refaktoryzować. Nie zmieniamy kodu tylko po to, aby wykonać obowiązkowy trzeci krok.
Zamiast tego przechodzimy do kolejnego wymagania.
Na przykład:
rabat nie może być większy niż 100%.
Najpierw powstaje kolejny test:
import pytest
from pricing import calculate_discount
def test_rejects_discount_above_100_percent():
with pytest.raises(ValueError):
calculate_discount(100, 120)
Test jest czerwony.
Dopiero wtedy zmieniamy implementację:
def calculate_discount(price, discount_percent):
if discount_percent > 100:
raise ValueError("Discount cannot exceed 100%")
return price * (1 - discount_percent / 100)
W taki sposób funkcjonalność rozwija się przypadek po przypadku, a każdy kolejny test opisuje nowe oczekiwane zachowanie.
Pytest pozwala tworzyć takie automatyczne testy w Pythonie przy wykorzystaniu zwykłych instrukcji assert oraz mechanizmów sprawdzających między innymi oczekiwane wyjątki.
Co daje Test-Driven Development?
Największa wartość TDD nie wynika z samego faktu posiadania większej liczby testów.
Korzyść pojawia się poprzez krótszą pętlę informacji zwrotnej podczas programowania.
Precyzyjniejsze określanie zachowania kodu
Przed implementacją trzeba określić:
- jakie dane wchodzą do komponentu,
- jaki wynik jest oczekiwany,
- które przypadki brzegowe trzeba obsłużyć,
- jakie błędy powinny wystąpić,
- co właściwie oznacza „gotowe”.
Test staje się więc wykonywalnym przykładem oczekiwanego zachowania.
Szybsze wykrywanie regresji
Każdy kolejny test pozostaje w zestawie testowym.
Jeżeli późniejsza zmiana przypadkowo zepsuje wcześniej zaimplementowane zachowanie, test może natychmiast to wykryć.
Dzięki temu programista otrzymuje informację podczas pracy, zamiast dopiero po ręcznych testach lub wdrożeniu aplikacji.
Bezpieczniejsza refaktoryzacja
Rozbudowany zestaw szybkich testów daje informację zwrotną podczas zmiany struktury kodu.
Nie gwarantuje, że refaktoryzacja jest bezpieczna, ale zmniejsza niepewność: jeżeli zmienione zachowanie jest objęte dobrymi testami, regresja może zostać zauważona wcześniej.
Presja na testowalny projekt
Jeżeli element kodu bardzo trudno przetestować w izolacji, często wskazuje to na silne sprzężenie z innymi komponentami, dużą liczbę odpowiedzialności albo trudno kontrolowane zależności.
TDD może więc ujawniać problemy projektowe wcześniej, ponieważ testowalność staje się częścią procesu tworzenia kodu, a nie wymaganiem dodawanym na końcu.
Małe zmiany zamiast dużych skoków
TDD zachęca do implementowania niewielkich fragmentów zachowania i częstego uruchamiania testów.
Badanie analizujące sposób pracy w TDD wskazało, że pozytywne efekty mogą być związane nie tylko z kolejnością „test przed kodem”, ale również z małą granularnością i regularnością kolejnych kroków.
Czy TDD rzeczywiście poprawia jakość kodu?
Tutaj warto unikać prostych obietnic.
Nie można powiedzieć, że samo zastosowanie TDD automatycznie:
- eliminuje błędy,
- skraca development,
- poprawia architekturę,
- zwiększa produktywność.
Wyniki badań są bardziej złożone.
Przeglądy badań nad TDD wskazują na pewne pozytywne wyniki związane między innymi z redukcją defektów i utrzymywalnością, ale wpływ na produktywność oraz część cech jakościowych pozostaje zależny od kontekstu i w wielu badaniach jest niejednoznaczny.
To ważny praktyczny wniosek:
TDD jest techniką pracy, a nie gwarancją dobrej jakości.
Źle zaprojektowany test napisany przed kodem nadal pozostaje złym testem.
TDD a testy jednostkowe
TDD jest bardzo mocno związane z automatycznymi testami na niskim poziomie, ale nie warto utożsamiać tych dwóch pojęć.
Test jednostkowy określa rodzaj testu.
TDD określa sposób prowadzenia developmentu.
Można więc:
- pisać testy jednostkowe po implementacji — bez TDD,
- pisać testy jednostkowe przed implementacją — zgodnie z TDD,
- wykorzystywać również testy na innych poziomach do prowadzenia developmentu.
Najważniejsze jest to, czy test wpływa na powstającą implementację, zamiast wyłącznie sprawdzać kod po zakończeniu pracy.
TDD, BDD i ATDD – czym się różnią?
TDD nie jest jedynym podejściem wykorzystującym testy przed implementacją.
TDD – Test-Driven Development
Koncentruje się przede wszystkim na developmentcie i projektowaniu kodu poprzez kolejne automatyczne testy.
Pytanie:
Jak powinien zachowywać się ten fragment systemu?
ATDD – Acceptance Test-Driven Development
Acceptance Test-Driven Development przenosi uwagę wyżej.
Przed implementacją przedstawiciele różnych perspektyw — np. biznesu, developmentu i testów — wspólnie określają kryteria akceptacji oraz przykłady zachowania systemu.
Agile Alliance definiuje ATDD właśnie jako współpracę różnych członków zespołu przy tworzeniu testów akceptacyjnych przed implementacją funkcjonalności.
BDD – Behavior-Driven Development
Behavior-Driven Development rozwija idee TDD i ATDD, kładąc większy nacisk na zachowanie systemu oraz wspólny język zrozumiały dla osób technicznych i biznesowych.
Agile Alliance opisuje BDD jako podejście wywodzące się z TDD i ATDD, wykorzystujące opis zachowania do poprawy komunikacji pomiędzy uczestnikami developmentu.
W praktyce te podejścia mogą się uzupełniać.
TDD a Agile i Extreme Programming
TDD jest silnie związane z Extreme Programming (XP).
XP łączy TDD z innymi praktykami technicznymi, takimi jak:
- Continuous Integration,
- refactoring,
- pair programming,
- małe wydania,
- wspólna odpowiedzialność za kod.
Agile Alliance wymienia Test-Driven Development i Continuous Integration wśród technicznych praktyk charakterystycznych dla XP.
To pokazuje ważną zależność: Agile nie oznacza tylko Sprintów, backlogów i spotkań.
Jeżeli zespół chce często zmieniać oprogramowanie, potrzebuje również technicznej zdolności do bezpiecznego wykonywania tych zmian. TDD może być jednym z mechanizmów wspierających tę zdolność.
TDD a CI/CD
TDD i CI/CD rozwiązują problemy na różnych etapach procesu, ale bardzo dobrze się uzupełniają.
TDD daje programiście szybką informację podczas tworzenia kodu.
CI daje zespołowi automatyczną informację po zintegrowaniu zmiany z repozytorium.
Przepływ może wyglądać tak:
TDD lokalnie → commit → CI → build → zestaw testów → dalsze kontrole → przygotowanie do wdrożenia
Testy powstające podczas TDD mogą być następnie automatycznie uruchamiane przez pipeline CI.
Dzięki temu raz zdefiniowane zachowanie jest sprawdzane również przy kolejnych zmianach wprowadzanych przez innych programistów.
Agile Alliance opisuje praktyczne wykorzystanie zestawu testów TDD jako części środowiska Continuous Integration, gdzie wcześniejsze testy tworzą automatyczny zestaw regresyjny.
Jakie narzędzia wspierają TDD?
TDD nie wymaga specjalnego „narzędzia TDD”.
Potrzebny jest przede wszystkim framework pozwalający szybko pisać i uruchamiać automatyczne testy.
Przykłady:
| Technologia | Przykładowy framework |
| Java / JVM | JUnit |
| Python | pytest |
| .NET | NUnit, xUnit.net, MSTest |
| JavaScript / TypeScript | Jest, Vitest i inne test runnery |
JUnit jest platformą i zestawem modułów umożliwiających uruchamianie testów w środowisku JVM oraz integrację m.in. z IDE i narzędziami buildowymi.
Pytest obsługuje zarówno proste testy wykorzystujące assert, jak i bardziej rozbudowane scenariusze oparte na fixtures, parametryzacji i pluginach.
W praktyce ważniejsza od wyboru frameworka jest szybkość pętli feedbacku. Jeżeli uruchomienie podstawowych testów trwa kilkanaście minut, codzienna praca w krótkich cyklach TDD staje się trudniejsza.
Najczęstsze błędy w TDD
Pisanie zbyt dużego testu
Jeżeli jeden test wymaga zaimplementowania kilku klas, integracji z bazą i wielu reguł biznesowych, cykl przestaje być krótki.
Lepiej wybierać najmniejszy przypadek, który wnosi nowe zachowanie.
Pisanie całego zestawu testów przed implementacją
TDD nie oznacza:
najpierw wszystkie testy → później cały kod.
To raczej:
jeden mały test → mała implementacja → refaktoryzacja → następny test.
Kod i testy rozwijają się razem.
Pomijanie fazy Red
Jeżeli nie widzimy testu zakończonego niepowodzeniem, nie mamy pewności, czy nowy test rzeczywiście potrafi wykryć brak implementacji.
Zielony test od pierwszego uruchomienia może sprawdzać coś, co już działa albo nie testować właściwego zachowania.
Brak refaktoryzacji
Model:
Red → Green → Red → Green → Red → Green
to tylko część TDD.
Bez regularnego poprawiania struktury kod może stopniowo gromadzić duplikację i przypadkową złożoność.
Testowanie implementacji zamiast zachowania
Bardzo kruche testy mogą być silnie związane ze szczegółami wewnętrznej implementacji.
W rezultacie poprawna refaktoryzacja wymaga zmiany wielu testów, mimo że zachowanie systemu się nie zmieniło.
Test powinien w miarę możliwości określać istotny kontrakt lub zachowanie, a nie każdy detal implementacyjny.
Nadmierne mockowanie
Mocki i stuby są przydatne przy kontrolowaniu zależności, ale ich nadmiar może prowadzić do testów sprawdzających głównie interakcje pomiędzy mockami.
Jeżeli niemal każdy element trzeba zastąpić atrapą, warto sprawdzić również projekt zależności w kodzie.
Kiedy TDD może nie być najlepszym wyborem?
TDD nie trzeba stosować mechanicznie do każdej linijki kodu.
Mniej naturalne może być w sytuacjach, w których głównym celem jest szybka eksploracja rozwiązania, a kod zostanie odrzucony po eksperymencie.
Trudności mogą pojawić się również przy:
- eksperymentalnym prototypowaniu UI,
- kodzie silnie zależnym od trudnych do kontrolowania systemów zewnętrznych,
- części zastosowań data science i eksploracji danych,
- starszym kodzie z bardzo silnymi zależnościami,
- fragmentach, dla których koszt stworzenia odpowiedniego harnessu testowego jest nieproporcjonalny do ryzyka.
Nie oznacza to, że takich systemów nie należy testować. Może po prostu istnieć bardziej odpowiedni poziom testów lub inna kolejność pracy.
Szczególnym przypadkiem jest legacy code. Dodanie testów przed zmianą może być bardzo wartościowe, ale kod napisany bez testowalności często wymaga wcześniejszego stworzenia punktów pozwalających obserwować jego zachowanie.
Jak zacząć stosować TDD w zespole?
Nie zaczynałbym od wymagania:
„Od jutra cały kod musi powstawać w TDD”.
Lepszym miejscem na start jest niewielki fragment logiki biznesowej z jasnymi regułami.
Przykładowo:
- obliczenie rabatu,
- walidacja zamówienia,
- wyliczenie prowizji,
- reguła kwalifikująca transakcję,
- przeliczanie danych,
- obsługa konkretnego przypadku domenowego.
Dla pierwszego wymagania napisz jeden test. Uruchom go i upewnij się, że jest czerwony. Dodaj najmniejszą implementację. Doprowadź testy do zielonego stanu. Popraw strukturę kodu i dopiero wtedy przejdź do kolejnego przypadku.
Po kilku takich cyklach można ocenić, czy TDD rzeczywiście pomaga zespołowi w danym rodzaju pracy.
Najważniejszym celem nie jest pisanie testów przed kodem dla samej zasady. Chodzi o stworzenie krótkiej pętli: wymaganie → test → implementacja → informacja zwrotna → poprawa projektu.
Jeżeli ta pętla pomaga zespołowi szybciej rozumieć zachowanie systemu i bezpieczniej rozwijać kod, TDD spełnia swoją funkcję.
