Poznaj TDD Od Lepszej Strony

Poznaj TDD Od Lepszej Strony

Gdy wszyscy wokół piszą o testach, Ty wesoło implementujesz kod, bo testy zdążysz napisać później. Ale co w sytuacji, gdy napisany przez nas kod jest tak trudny w testowaniu, że potrzebujemy dwa razy więcej czasu poświęcić na pisanie testów? Zaczynamy przerabiać nasz kod, a potem znowu testy, i znowu kod. Aby temu zaradzić, możemy skorzystać z pewnej techniki, którą skrótowo nazywamy TDD.

Definicja

TDD, czyli Test Driven Development, to technika, metoda oraz metodyka (nie metodologia) tworzenia oprogramowania. Jak sama nazwa wskazuje, programowanie czy też rozwój oprogramowania napędzany jest testami. Oznacza to tylko, i aż tyle, że pisanie kolejnych elementów naszej aplikacji powinien poprzedzać odpowiedni test. Dzięki temu wiemy, jak chcemy używać danego kodu oraz co chcemy otrzymać, zanim napiszemy faktyczny kod!

Jeżeli korzystałeś już z platform takich jak codewars, to miałeś do czynienia z namiastką tej techniki. Na platformach jak ta, tworzymy kod do już istniejących testów, które narzucają nam odpowiedni interfejs oraz oczekiwany wynik działania funkcji. Ta technika na początku Cię spowolni, ale z czasem będziesz pisać dzięki niej szybciej.

Zalety

  • Nie piszemy nadmiarowego kodu, ponieważ technika wymaga od nas pisania minimum wymaganego do spełnienia założeń testu (wedle zasady YAGNI).
  • Pisanie prostych testów skłania do korzystania z dobrych wzorców projektowych oraz odpowiedniej kompozycji przy implementacji funkcjonalności.
  • Automagicznie tworzymy kod, który jest w dużym stopniu pokryty testami. A to oznacza, że bez większego strachu możemy się wdrażać do takiego kodu, pracować na nim czy też aktualizować masowo zależności. Dodatkowo staje się on bazą do testów regresji, oraz CI/CD.
  • Testy w pewnym stopniu stają się dokumentacją zachowań, których obsługi możemy się spodziewać po naszym kodzie.
  • Uruchomienie testów jest szybsze, od ponownego uruchomienia interpretera i użycia nowo implementowanej funkcjonalności.
  • Oszczędzamy czas przy debugowaniu kodu. Nasze testy są w stanie wykazać, dokładnie w którym miejscu i w jakiej sytuacji generowany jest błąd.

Trzeba jednak mieć świadomość, że dostrzeżenie pewnych zalet wymagać od nas będzie nabrania doświadczenia. Nie od razu praca w taką techniką będzie pozwalać nam osiągnąć zamierzone efekty. Jak wszystko, także i to wymaga czasu i praktyki.

Wady

Niektórzy by nie uwierzyli, gdyby taka sekcja nie powstała. Zwłaszcza że wciąż jest wielu ludzi, którzy TDD nie akceptują oraz w jego wartość nie wierzą.

  • Spowolnienie - Pisanie testów przed implementacją spowalnia implementacje samego kodu, zwłaszcza gdy wiemy, co chcemy osiągnąć.
  • Adaptery – TDD to także adaptery, czyli obiekty klas, które swoją implementacją zastępują klasy z zewnętrznymi zależnościami. O ile jest to bardzo dobry wzorzec, o tyle znajdą się osoby, które uważają taki kod za niepotrzebny, gdyż wymaga on pisania dodatkowych testów.

Tylko że ja tych wad zauważanych przez innych nie akceptuje. Spowolnienie występuje tylko, gdy uczymy się narzędzia. Z czasem zaczynamy robić to szybciej, tak jak z czasem zaczynaliśmy szybciej pisać kod. Atrapy natomiast są pewnym elementem pisania czystej architektury, i zdecydowanie wolę to niż pisanie wielu łatek na metody, których nie chcę wywołać. I trzeba je testować, ponieważ kod testowany kodem, który nie jest testowany, możemy uznać za nieprzetestowany.

Etapy TDD

Technika TDD to trzy etapy wykonywane iteracyjnie w pętli. Tymi etapami są:

  • Czerwony - to etap kiedy piszemy test, który nie przechodzi, stąd etap czerwony. Test powinien być krótki, i skupiać się na pewnym zachowaniu systemu, obiektu, funkcji. W ten sposób weryfikujemy, czy coś nie dzieje się przypadkowo w naszym kodzie.
  • Zielony - piszemy minimalną ilość kodu potrzebną do zaliczenia testu.
  • Refactor - zwany czasem etapem niebieskim. To moment gdy możemy poprawić implementacje naszego kodu lub/oraz testów. Jest to też etap, który możemy pomijać gdy nie ma potrzeby zmian. Ponieważ nasz aktualny kod jest pokryty testami, to też moment gdy możemy przebudować jego architekturę bez obaw, ponieważ testy zapewnią nam, że nie popsujemy w ten sposób żadnej funkcjonalności.

I to tak naprawdę wszystko, czego potrzeba, aby zacząć pracować metodą TDD.

Kiedy używać

Najchętniej bym powiedział, że zawsze. Jednak szczególnie powinniśmy korzystać z tej techniki, gdy:

  • Jesteśmy pewni tego, co potrzebujemy zaimplementować.
  • Naprawiamy błędy w kodzie.
  • W naszym projekcie nie ma testera lub QA, który będzie testować nasze rozwiązanie przed dostarczeniem na produkcje.
  • Nasz projekt ma ciągłą integrację oraz dostarczenia, i wprowadzenie błędu na produkcje będzie nas dużo kosztować.

Kiedy nie używać

Mimo plusów, jakie daje nam ta technika, to uważam, że nie zawsze warto z niej korzystać. TDD może nie być najlepszą opcją w sytuacji gdy:

  • Piszemy testy po implementacji, ale tylko wtedy gdy implementujemy odpowiednio małe elementy. Tak twierdzą badania naukowe.
  • Tworzymy prototyp pewnej implementacji, który i tak zostanie usunięty.
  • Nie wiemy jak rozwiązać problem, nie mamy specyfikacji ani konkretnych oczekiwań.

Przykład

Abyś lepiej zrozumiał powyższą teorię, to w duchu TDD zaimplementujemy funkcje, która zachowywać się będzie tak samo jako metoda str.find(). W dokumentacji Pythona mamy pełną specyfikację wymaganych argumentów, oraz zachowań metody co oznacza, że mamy wszystko co potrzeba by zacząć pracę.

Baza

Tak będzie wyglądać nasz plik bazowy. Do testów będę korzystać z biblioteki unittest. Dodatkowo dodałem wywołanie testów na końcu. Na środku mamy utworzoną pustą klasę TestStrFind, która dziedziczy po klasie unittest.TestCase.

import unittest
class TestStrFind(unittest.TestCase):
...
if __name__ == "__main__":
unittest.main()
Terminal window
----------------------------------------------------------------------
Ran 0 tests in 0.000s
OK

Czerwony

W fazie czerwonej piszemy test. Będzie to sprawdzenie najbardziej podstawowej wartości, czyli czy pierwsza litera słowa znajduje się w nim. Wynik będziemy porównywać do wywołania faktycznej metody find, aby nasze wyniki były dokładnie takie same co do oryginału.

class TestStrFind(unittest.TestCase):
def test_find_letter_in_text(self):
text = "Python"
sub = "P"
self.assertEqual(str_find(sub, text), text.find(sub))
Terminal window
E
======================================================================
ERROR: test_find_letter_in_text (__main__.TestStrFind)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/home/jarek/Playground/akademia_python_code/2020-05/tdd/01_test.py", line 10, in test_find_letter_in_text
self.assertEqual(str_find(sub, text), text.find(sub))
NameError: name 'str_find' is not defined
----------------------------------------------------------------------
Ran 1 test in 0.000s
FAILED (errors=1)

Tak jak oczekiwaliśmy w tej fazie, test nie przeszedł pomyślnie. Błąd to informacja, że nie istnieje w ogóle taka funkcja, nie doszło jeszcze nawet do sprawdzenia, czy wartości zwracane są takie same. Ale od tego jest etap drugi.

Zielony

Teraz musimy zaimplementować logikę! Tworzymy zatem funkcje z dwoma argumentami. Następnie iteruje po kolejnych znakach argumentu text i zwracam index szukanego przez nas znaku.

def str_find(text, sub):
for i, c in enumerate(text):
if c == sub:
return i
Terminal window
.
----------------------------------------------------------------------
Ran 1 test in 0.000s
OK

Test przeszedł na zielono, udało się! Pewnie widzisz, że brakuje nam tu już czegoś, ale od tego jest TDD, aby implementować kolejne rzeczy na podstawie testów.

Niebieski

Nie ma potrzeby, wygląda to wszystko na ten moment ok.

Czerwony drugi

A co gdy nie ma takiej litery? Napiszmy test, który to sprawdzi!

class TestStrFind(unittest.TestCase):
def test_find_letter_in_text(self):
text = "Python"
sub = "P"
self.assertEqual(str_find(text, sub), text.find(sub))
def test_letter_not_in_text(self):
text = "Python"
sub = "m"
self.assertEqual(str_find(text, sub), text.find(sub))
Terminal window
.F
======================================================================
FAIL: test_letter_not_in_text (__main__.TestStrFind)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/home/jarek/Playground/akademia_python_code/2020-05/tdd/03_test.py", line 22, in test_letter_not_in_text
self.assertEqual(str_find(text, sub), text.find(sub))
AssertionError: None != -1
----------------------------------------------------------------------
Ran 2 tests in 0.001s
FAILED (failures=1)

Tak jak się spodziewałem, nie zaimplementowałem jeszcze takiej opcji, dlatego test nie przeszedł.

Zielony drugi

Więc patrzymy w dokumentacje, i wynika z niej, że w przeciwnym wypadku powinniśmy zwrócić -1. Dokładnie to teraz uczynimy!

def str_find(text, sub):
for i, c in enumerate(text):
if c == sub:
return i
return -1
Terminal window
..
----------------------------------------------------------------------
Ran 2 tests in 0.000s
OK

Niebieski drugi

Znów nie uważam, aby była potrzeba zmian w kodzie oraz testach, etap pomijamy.

Czerwony trzeci

Ok, mamy pełną obsługę jednego znaku, co z całymi słowami? Sprawdźmy to poprzez test!

class TestStrFind(unittest.TestCase):
def test_find_letter_in_text(self):
text = "Python"
sub = "P"
self.assertEqual(str_find(text, sub), text.find(sub))
def test_letter_not_in_text(self):
text = "Python"
sub = "m"
self.assertEqual(str_find(text, sub), text.find(sub))
def test_find_substring_in_text(self):
text = "Python"
sub = "hon"
self.assertEqual(str_find(text, sub), text.find(sub))
Terminal window
.F.
======================================================================
FAIL: test_find_substring_in_text (__main__.TestStrFind)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/home/jarek/Playground/akademia_python_code/2020-05/tdd/05_test.py", line 27, in test_find_substring_in_text
self.assertEqual(str_find(text, sub), text.find(sub))
AssertionError: -1 != 3
----------------------------------------------------------------------
Ran 3 tests in 0.001s
FAILED (failures=1)

Nasza aktualna funkcja nie znalazła tego skrawka w tekście. No cóż, tego się spodziewałem, czas na etap zielony.

Zielony trzeci

Musimy trochę przebudować działanie naszej funkcji. Dzięki wcześniej napisanym testom będziemy mieli pewność, ze niczego nie popsuliśmy po drodze. Teraz będziemy dla odpowiedniego zakresu sprawdzać wycinki o długości naszego testowanego skrawka.

def str_find(text, sub):
sub_len = len(sub)
for i in range(len(text)):
if text[i : i + sub_len] == sub:
return i
return -1
Terminal window
...
----------------------------------------------------------------------
Ran 3 tests in 0.000s
OK

Udało się powiększyć funkcjonalność naszego kodu, przebudować, i nie musimy ręcznie sprawdzać, czy poprzednie funkcje działają, ponieważ pokryliśmy je już testami!

Niebieski trzeci

Podjąłem decyzje, aby zmniejszyć ilość pętli gdy wyszukiwany element jest dłuższy niż jeden znak. Dzięki testom mam pewność, że dotychczasowe wymagania wciąż są spełnione i działają poprawnie.

def str_find(text, sub):
sub_len = len(sub)
for i in range(len(text) - sub_len + 1):
if text[i : i + sub_len] == sub:
return i
return -1
Terminal window
...
----------------------------------------------------------------------
Ran 3 tests in 0.000s
OK

Dodatkowo zaimplementowałem dwa testy, które zapewnią mnie, że dotychczasowa implementacja jest poprawna i zadziała w kilku kolejnych przypadkach, gdy szukane wyrażenie jest długości tekstu, oraz gdy jest dłuższe.

class TestStrFind(unittest.TestCase):
def test_find_letter_in_text(self):
text = "Python"
sub = "P"
self.assertEqual(str_find(text, sub), text.find(sub))
def test_letter_not_in_text(self):
text = "Python"
sub = "m"
self.assertEqual(str_find(text, sub), text.find(sub))
def test_find_substring_in_text(self):
text = "Python"
sub = "hon"
self.assertEqual(str_find(text, sub), text.find(sub))
def test_find_text_in_text(self):
text = "Python"
sub = "Python"
self.assertEqual(str_find(text, sub), text.find(sub))
def test_find_sub_bigger_than_text(self):
text = "Python"
sub = "Pythonistas"
self.assertEqual(str_find(text, sub), text.find(sub))
Terminal window
.....
----------------------------------------------------------------------
Ran 5 tests in 0.001s
OK

Wszystko wciąż działa poprawnie. Dobra robota! Kilka kroków przed nami, więc polecam dokończyć zadanie w ramach praktyki! Należy obsłużyć jeszcze brakujące argumenty - start oraz stop. W ramach tego etapu dodałbym także test z parametrami, ponieważ widać powtórzenia w testach, których chcielibyśmy uniknąć.

Dodatkowe narzędzia

Coś, co pomoże jeszcze lepiej i szybciej pracować w tej metodyce, to plugin do pytest o nazwie pytest-watch. Powoduje on uruchomienie testów po każdorazowym zapisie zmian w naszym projekcie. Dzięki temu na bieżąco otrzymujemy informacje o tym, czy nasz kod zalicza na zielono wszystkie z naszych testów!

Jak zacząć?

Na pewno nie rzucać się na głęboką wodę. Do poprawnej pracy z TDD potrzeba nam dwóch umiejętności - programowania oraz pisania testów. Dopiero znając je, powinniśmy spróbować popracować z tą techniką, aby zobaczyć jej pełną wartość.

Jeżeli pracujesz w projekcie, spróbuj w pierwszej kolejności korzystać z tej techniki przy naprawie błędów. Reprodukuj błędy testem, a następnie implementuj rozwiązanie problemu. Następnie możesz w ten sposób zacząć implementować nowe funkcjonalności. Kolejnych kroków już nie będziesz potrzebować :)

Podsumowanie

Poznałeś w tym artykule, czym jest TDD, oraz jak za jego pomocą tworzyć kod. Chciałbym, abyś nie traktował tego jak religie, a po prostu jako dobrą praktykę w programowaniu. Moje początki z TDD też nie były łatwe, i ostatecznie przekonałem się do nich dopiero gdy trafiłem do projektu z dobrej jakości środowiskiem testowym, dzięki czemu mogłem szybko tworzyć nowe testy, a następnie zajmować się implementacją.

Natomiast osoby, które nie zostały przekonane, ponieważ “istnieją pewne badania” polecam przeczytać artykuł Wujka Boba - TDD Doesn’t Work.