TDD - Wprowadzenie

Test-Driven Development dla początkujących

Gdy chodzi o programowanie zawsze stawiam na jakość. A jak mowa o jakości, to trzeba też wspomnieć o testowaniu. Testowanie oprogramowania jest dla mnie czymś tak naturalnym, tak zakorzenionym w procesie dewelopowania, że prawie oczywistym. To, że nie jest to tak oczywiste dla innych uświadomiłam sobie dopiero podczas jednej z moich prezentacji, gdy zadałam widowni pytanie dotyczące testowania właśnie. Kilka nieśmiałych rąk w górze. To wszystko. A gdy zapytałam o pisanie testów przed kodem. No cóż… To ciekawe, ponieważ w normalnym życiu testujemy wszystko już jakby z automatu. Buty, ubranie, sprzęt komputerowy czy telefon. Jest to dla nas normalne. Dlaczego nie robimy tego przy pisaniu oprogramowania? Gdy zapraszamy znajomych na kolację, to zanim ją podamy, sprawdzamy czy wszystko jest upieczone, czy ładnie wygląda, no i najważniejsze czy dobrze smakuje. Nie podajemy przecież niedogotowanych ziemniaków. To dlaczego pozwalamy sobie na wypuszczenie na produkcję nie przetestowanego kodu?

Testy manualne są nudne

Jesteśmy programistkami i programistami. Jak chcemy wiedzieć, że to co przed chwilą napisałyśmy działa poprawnie? Oczywiście testujemy! Często zdarza się, że są to testy manualne. Pytanie tylko czy jest to efektywne? Za każdym razem, gdy zmienimy kod, powinnyśmy jeszcze raz wykonać te samem testy. Sprawdzić czy to, co dodałyśmy do aplikacji nie psuje wcześniejszej funkcjonalności. Z mojego doświadczenia wiem, że to bardzo trudne. Bo niby jak mamy zapamiętać dokładnie te wszystkie kroki i przypadki do testowania. Druga sprawa, to jest to okropnie nudne. Jak długo można powtarzać te same kroki bez uczucia bezsensowności swojego działania? Uwierz mi, nie zbyt długo.

Praca w projekcie bez testów automatycznych

W jednym z projektów, w którym pracowałam, każdy programista miał obowiązek uczestniczyć w piątkowej sesji testowania. Był to czas, gdy wszyscy programiści w zespole siadali przed komputerami i robili testy manualne. Mieliśmy swój arkusz kalkulacyjny, w którym wypisane były wszystkie przypadki testowe. Braliśmy je i krok po kroku klikając po aplikacji sprawdzaliśmy czy wszystko działa. Pierwszy raz, gdy robiłam sesje testowania było to ekscytujące. Naprawdę! Miałam możliwość poznania całej aplikacji lepiej. Za drugim razem było OK. Mogłam dokładniej zrozumieć cały proces, jaki użytkownik przechodzi w aplikacji. Za trzecim razem, no cóż jakoś poszło. Za każdym kolejnym razem były to najgorsze godziny w całym tygodniu. Na nasze nieszczęście kod nie był przygotowany by w łatwy sposób przenieść te testy manualne na automatyczne. To też była udręka. Jak możesz się domyślić, bez tych testów manualnych byliśmy jak dzieci we mgle. Nie byliśmy w stanie określić czy aplikacja dalej działa poprawnie. Dodatkowo sytuacja ta nie pomagała w szybkim dostarczaniu nowych wersji oprogramowania. Pomyśl sobie, że za każdym razem gdy chcesz wprowadzić zmiany na produkcję do czasu potrzebnego do napisania nowej funkcjonalności trzeba doliczyć tą manualną sesję testowania. Za każdym razem boisz się, że coś pójdzie nie tak, że coś zostanie przeoczone, niedopilnowane. I niestety w większości przypadków tak właśnie było. Kod miał tak wiele zależności, że każda drobna zmiana wpływała na inne teoretycznie niezwiązane ze sobą części aplikacji. Jeżeli założymy, że na tej sesji testowania jedna osoba w zespole spędzała 2h, a zespół liczy 6 osób to sprawdzenie nowej wersji aplikacji zajmowało mniej więcej 1.5 dnia pracy jednej osoby lub dłużej.

TDD sposób na testy automatyczne w aplikacji

Sytuacja, w której był mój zespół nie była kolorowa. Można było temu zapobiec robiąc testy, używając TDD od samego początku projektu. Tak się jednak nie stało i efekty można było zobaczyć powyżej. Jeżeli chodzi o TDD to pewnie usłyszysz lub już usłyszałaś, że TDD to nie jest sposób na tworzenie testów i będzie to prawda. TDD jest sposobem na tworzenie oprogramowania, a testowanie to tylko jeden element tego procesu. No dobrze w takim razie sprecyzujmy czym jest TDD.

Co to jest TDD?

TDD - Test-Driven Development, czyli tworzenie oprogramowania sterowanego testami. To znaczy, że za każdym razem, gdy chcemy stworzyć nową funkcjonalność w aplikacji, piszemy test który tą funkcjonalność zweryfikuje zanim ona w ogóle powstanie. Dopiero później bierzemy się za implementację. Wiedząc czego oczekujemy możemy rozważyć wiele różnych dróg prowadzących do rozwiązania stawianego problemu. Możemy też zrobić bezpieczną refaktoryzację. Nasze testy to nasza straż, nasi ochroniarze. Teraz wystarczy powtarzać te kroki aż do uzyskania całej potrzebnej funkcjonalności.

TDD krok po kroku

Przechodzimy do tej trudniejszej części. Jak używać TDD w codziennej pracy? Na początek podzielimy proces TDD na trzy mniejsze kroki.

Cylk TDD
Etapy procesu TDD
  1. CZERWONY

    Piszemy test, który ma nas przybliżyć do oczekiwanego rozwiązania. Uruchamiamy test. Test powinien nie przechodzić. To bardzo ważne, by tak się właśnie stało. Jeżeli test od razu przejdzie, to znaczy, że jest bezużyteczny. Nie wprowadza żadnego nowego wymagania do aplikacji. Wszystkie potrzebne zachowania są już zaimplementowane w kodzie.

  2. ZIELONY

    Piszemy kod spełniający test. To powinno być pierwsze rozwiązanie jakie przychodzi nam do głowy. Szybkie i proste. Nawet trywialne. Nasz kod ma sprawić, że test przejdzie. To wszystko.

  3. REFAKTORYZACJA

    Teraz jest czas by przyjrzeć się temu, co napisałyśmy z szerszej perspektywy. Czy mogę coś uprościć? Czy mogę coś ulepszyć? Czy mogę coś usunąć? To jest czas na poprawienie kodu, który już mamy. Nie dodajemy w tym kroku żadnej nowej funkcjonalności.

Skoro mamy już podstawy teoretyczne, to czas na przykład. Skupimy się na zaimplementowaniu jednej metody przy użyciu TDD. Będzie to metoda sprawdzająca czy w ręce pokerowej mamy jeden kolor. Na tą chwilę przyjmiemy, że każda karta (w zasadzie każdy kolor) będzie reprezentowana przez jedną liczbę a ręka pokerowa przez zwykłą tablicę w języku Ruby.

Krok 1 - CZERWONY - Tworzymy pierwszy test

Zaczynamy od napisania naszego pierwszego testu:

require 'spec_helper'
describe 'flush?' do
  it 'checks if array has one color' do
    flush_rule = flush?([1, 1, 1, 1])
    expect(flush_rule).to eq(true)
  end
end

Test będzie sprawdzał czy w naszej ręce pokerowej znajduje się jeden kolor. Zatem uruchommy nasz test:

$ rspec spec/lib/flush_spec.rb

Randomized with seed 35317
F

Failures:

  1) flush? checks if array has one color
     Failure/Error: flush_rule = flush?([1, 1, 1, 1])

     NoMethodError:
       undefined method `flush?' for #<RSpec::ExampleGroups::Flush:0x00000002a73d50>
     # ./spec/lib/flush_spec.rb:5:in `block (2 levels) in <top (required)>'

Finished in 0.01294 seconds (files took 0.75094 seconds to load)
1 example, 1 failure

Test nie przeszedł. Tym sposobem zakończyłyśmy pierwszy krok TDD. Teraz możemy sprawdzić dlaczego test się nie powiódł i jak możemy to naprawić.

Krok 2 - ZIELONY - Piszemy kod spełniający test

Widzimy informację undefined method flush?. Brakuje nam metody flush?. Zacznijmy od jej napisania.

def flush?
end

Uruchamiamy test jeszcze raz.

$ rspec spec/lib/flush_spec.rb

Randomized with seed 28476
F

Failures:

  1) flush? checks if array has one color
     Failure/Error:
       def flush?
       end

     ArgumentError:
       wrong number of arguments (given 1, expected 0)
     # ./spec/lib/flush_spec.rb:3:in `flush?'
     # ./spec/lib/flush_spec.rb:9:in `block (2 levels) in <top (required)>'

Finished in 0.01024 seconds (files took 0.66129 seconds to load)
1 example, 1 failure

Test dalej nie przechodzi. Tym razem dostajemy jednak informację wrong number of arguments (given 1, expected 0). Brakuje nam argumentu w metodzie flush?. Dodamy go:

def flush?(array)
end

Uruchamiamy test jeszcze raz.

$ rspec spec/lib/flush_spec.rb

Randomized with seed 34173
F

Failures:

  1) flush? checks if array has one color
     Failure/Error: expect(flush_rule).to eq(true)

       expected: true
            got: nil

       (compared using ==)
     # ./spec/lib/flush_spec.rb:10:in `block (2 levels) in <top (required)>'

Finished in 0.05983 seconds (files took 0.83267 seconds to load)
1 example, 1 failure

Dalej nie przechodzi. Jesteśmy jednak coraz bliżej rozwiązania. Tym razem widzimy informację, że oczekujemy wartości true a dostajemy nil. Możemy to bardzo łatwo naprawić. Musimy tylko zwrócić z naszej metody flush? wartość true.

def flush?(array)
  true
end

Gdy uruchamiamy test, widzimy sukces.

$ rspec spec/lib/flush_spec.rb

Randomized with seed 40116
.

Finished in 0.01189 seconds (files took 0.65796 seconds to load)
1 example, 0 failures

Nasz test przeszedł. Ukończyłyśmy krok drugi. Tak, ale to jeszcze nie jest rozwiązanie którego oczekujemy. Spokojnie, dojdziemy do tego. Patrząc na to, co do tej pory zrobiłyśmy można zapytać czy za każdym razem musimy dochodzić do rozwiązania takimi małymi kroczkami? Nie, możemy te kroki dostosować do naszych potrzeb. Jeżeli chcesz zrobić większy krok i po napisaniu testu od razu dostarczyć rozwiązanie, to droga wolna. Musisz jednak czuć się z tą decyzją komfortowo. Jeżeli pojawia się jakaś niepewność, co do poprawności rozwiązania lub test dalej nie przechodzi, a masz już implementację, to znak by wrócić do mniejszych kroczków. Najważniejsze w całym procesie jest to poczucie pewności, że nasz kod działa poprawnie, a nasze testy naprawdę to pokazują.

Krok 3 - REFAKTORYZACJA

Jak widzisz nasz kod jest na tą chwilę bardzo prosty. Prawdopodobnie nie uwierzysz mi gdy Ci powiem, że mamy tu duplikację. Pewnie myślisz o duplikacji tylko z perspektywy samego kodu. Chciałabym byś spojrzała na tą duplikację trochę szerzej. Spójrz zarówno na kod jaki i na test. W obu tych miejscach pojawia się true i to jest nasza duplikacja. Tak wiem, jest bardzo niewinna. Prędzej czy później i tak doprowadzimy do jej zniknięcia. Jednak na tą chwilę istnieje. W tym momencie mogłybyśmy od razu usunąć naszą duplikację, wpisując logikę w metodę. Dało by to nam ostateczne rozwiązanie. Jeżeli podoba Ci się ten pomysł, zachęcam do spróbowania swoich sił. Więcej informacji na temat usuwania takich duplikacji z kodu możesz znaleźć w pierwszym rozdziale książki Kenta Beck’a TDD - sztuka tworzenia dobrego kodu. Natomiast jeżeli jesteś bardziej jak ja, to przejdźmy dalej i napiszmy jeszcze jeden test.

Triangulacja

Dlaczego ten test jest dla mnie ważny? Nie czuję się zbyt dobrze zmieniając kod i posiadając tylko jeden test strzegący poprawności mojego rozwiązania. Jest to dla mnie za duży krok. By zmienić tą sytuację użyję nowego podejścia - triangulacji.

Co to jest triangulacja?

W świecie matematyki to sposób na odnalezienie położenia punktu używając do tego znanych wierzchołków trójkąta. W naszym przypadku szukanym położeniem jest poprawna implementacja w kodzie, a znanymi wierzchołkami są testy. Na przecięciu różnych wymagań wyspecyfikowanych w testach pojawia się kod rozwiązania.

Krok 1 - CZERWONY - Stworzenie drugiego testu

Jak wynika z powyższego opisu użycie triangulacji sprowadza się do napisania drugiego testu dla naszej metody flush?. Na razie mamy test sprawdzający, co się stanie, gdy w ręce pokerowej jest tylko jeden kolor. Czas napisać test weryfikujący posiadanie w ręce pokerowej więcej niż jednego koloru.

describe 'flush?' do
  it 'checks if array has one color'

  it 'checks if array has more then one color' do
    flush_rule = flush?([1, 1, 2, 1])
    expect(flush_rule).to eq(false)
  end
end

Tym razem oczekujemy, że gdy w ręce będzie więcej niż jeden kolor, metoda flush? zwróci nam false. Uruchommy testy:

$ rspec spec/lib/flush_spec.rb

Randomized with seed 6606
.F

Failures:

  1) flush? checks if array has more then one color
     Failure/Error: expect(flush_rule).to eq(false)

       expected: false
            got: true

       (compared using ==)
     # ./spec/lib/flush_spec.rb:15:in `block (2 levels) in <top (required)>'

Finished in 0.04907 seconds (files took 0.63654 seconds to load)
2 examples, 1 failure

Widzimy, że nasz nowy test nie przechodzi, co kończy krok pierwszy naszej drugiej iteracji.

Krok 2 - ZIELONY - Implementacja prawdziwej logiki

Oczekiwania drugiego testu są inne. Zamiast zwracać true powinnyśmy zwrócić false, co w naszym przypadku oznacza, że mamy więcej niż jeden kolor w ręce pokerowej. Teraz możemy się zastanowić nad prawdziwym rozwiązaniem. Sprawdźmy ile unikalnych kolorów istnieje w naszej tablicy. Jeżeli jest tylko jeden kolor to powinnyśmy zwrócić true w przeciwnym wypadku false. Tak właśnie działa poniższa implementacja metody flush?.

def flush?(array)
  array.uniq.size == 1
end

Gdy uruchomimy teraz testy oba przejdą.

$ rspec spec/lib/flush_spec.rb

Randomized with seed 33907
..

Finished in 0.01092 seconds (files took 0.57891 seconds to load)
2 examples, 0 failures

Krok 3 - REFAKTORYZACJA

Mogłybyśmy się teraz zastanowić nad refaktoryzacją tego kodu, ale jest on bardzo prosty a my nie znamy kontekstu jego użycia. Na tym etapie bez dodatkowych informacji będzie nam trudno podjąć właściwą decyzję co dalej. Jest jednak jedno uproszczenie, jakie możemy zrobić:

def flush?(array)
  array.uniq.one?
end

Nieoczekiwane użycie

Zanim przejdę do podsumowania tego, co udało nam się osiągnąć przyjrzyjmy się bliżej naszej metodzie. Jest ona bardzo prosta. Ze względu na jej prostotę i trochę też ze względu na magię języka Ruby możemy naszej metody użyć na różne sposoby. Możemy to zrobić tak, jak w zamieszczonym powyżej przykładzie dla bardzo prostej liczbowej reprezentacji kolorów.

flush?([1, 2, 1])
# => false

Możemy też użyć metody flush? na reprezentacji kolorów w postaci RGB.

flush?(['#fff', '#fff', '#fff'])
# => true

Przy niewielkim wysiłku to samo można by zrobić dla reprezentacji kolorów w postaci obiektów. To jest właśnie wspaniałe w prostym kodzie. Można go wielokrotnie wykorzystać w nietrywialny sposób.

Podsumowanie

Mam nadzieję, że na tym prostym przykładzie udało mi się pokazać Ci jak wygląda proces TDD. Teraz czas na podsumowanie tego co dzięki TDD dostajemy.

  1. Flow - Jest to jedno z najważniejszych uczuć jakich możemy doświadczyć podczas kreatywnych zadań. A za takie właśnie uważam programowanie. By udało nam się wejść w stan flow potrzebujemy się skupić na jednej małej rzeczy naraz. To właśnie daje nam TDD. Skupiamy się na jednym małym kroku w danym momencie. Nie musimy od razu ogarniać całości, podążamy za tym, co mówią do nas testy. Krok po kroku dążymy do celu - ostatecznego rozwiązania postawionego problemu. Dodatkowo możemy sobie ten krok dostosować do własnego tempa. Raz większy, a raz mniejszy.

  2. Pewność - Zapewnienie jakości. Chcemy być jak najbardziej pewne, że nasz kod robi dokładnie to, czego od niego oczekujemy. Chcemy mieć pewność, że przewidziałyśmy wszystkie możliwe problemy, a dobre testy mogą nam w tym pomóc.

  3. Bezpieczna refaktoryzacja - Ile razy miałaś styczność z kodem, który powinnaś zmienić, ale się boisz, czy czegoś nie zepsujesz? Gdy masz dobre testy, nie ma się czego obawiać. Będziesz wiedzieć od razu, bez zbędnego stresu, czy logika została naruszona.

  4. Możliwość eksperymentowania - Masz nowy pomysł jak podejść do problemu? Czemu nie spróbować od razu? Mamy strażników w postaci testów, którzy poinformują nas czy eksperyment się udał czy nie. Dzięki TDD pętla zwrotna, czyli czas od zmiany do uzyskania informacji o powodzeniu eksperymentu, jest bardzo krótka. Tę informację masz niemal natychmiast.

  5. Ciągły postęp - Nawet jeżeli poruszamy się bardzo wolno, tak jak w pokazanym przykładzie, to ciągle się poruszamy. Nie ma tu przestojów, nie trzeba od razu myśleć o całym rozwiązaniu. Skupiamy się tylko na małym fragmencie i doprowadzamy do jego działania. Widzimy postęp naszych prac. To daje nam motywację. Dodatkowo dzięki dobrym testom mamy mniej błędów w już działającej aplikacji, co też pozwala nam iść dalej i nie zatrzymywać się nad niedziałającymi funkcjonalnościami.

  6. Komunikacja - Dobrze napisane testy mogą być prawdziwą aktualną dokumentacją naszego kodu. Dzięki nim możemy dzielić się wiedzą z resztą zespołu nawet wtedy, gdy już nad projektem nie pracujemy.

Na koniec jeszcze jedna sprawa. Jeżeli przeczytałaś ten artykuł i w Twojej głowie pojawia się taka myśl: “No, ale mój projekt jest inny. Wprowadzenie TDD nie jest tam możliwe.” Postaraj się spojrzeć na tę sprawę trochę inaczej. Zacznij myśleć pozytywnie. Jeżeli czujesz, że w tym momencie nie da się w łatwy sposób wprowadzić TDD do Twojej aplikacji, to pewnie masz rację. Zacznij od czegoś małego. Zacznij od nauczenia się TDD na prostym przykładzie, takim oderwanym od Twojej codziennej projektowej rzeczywistości. Możesz spróbować napisać grę w kółko i krzyżyk lub grę w życie wykorzystując z TDD. Możesz też pobawić się katami, czyli takimi specjalnie przygotowanymi ćwiczeniami do wykonania, jak na przykład Gilded Rose Kata. Najpierw naucz się TDD, poczuj się w nim pewnie, a dopiero później postaraj się wprowadzić je do swojego projektu. Zaczniesz patrzeć na kod w inny sposób. Twój kod będzie prostszy i łatwiejszy do testowania, bo to testy będą sterować kodem, nie odwrotnie. To nie jest proste zadanie. To plan długofalowy, trochę jak oszczędzanie na emeryturę.

Bibliografia

Najpopularniejsze narzędzia do testowania w języku Ruby

  • Minitest - Kompletny zestaw narzędzi do testowanie wspierający TDD i BDD, mockowanie oraz benchmarking
  • RSpec - Narzędzie do BDD dla języka Ruby, ale można go używać również do TDD
  • Capybara - Narzędzie do testów akceptacyjnych aplikacji internetowych
  • Cucumber - Narzędzie do uruchamiania automatycznych testów pisanych zwykłym językiem angielskim

Books

Prezentacje angielskojęzyczne