Zacznę może od oczywistości doskonale znanej wszystkim, którzy mieli okazję pracować nad projektem większym, niż “blog w 15 minut”: wraz z rozwojem aplikacji rośnie liczba błędów, które wkradają się niepostrzeżenie “gdzieś, kiedyś”. Drobna zmiana jednej funkcjonalności potrafi wywalić połowę aplikacji – a to niezalogowany użytkownik dostaje w twarz wyjątek (bo nam, zalogowanym na developmencie przecież działa!), a to mail się nie wyśle, bo nie lubi użytkownika nil. Częstą praktyką jest zostawianie wyławiania takich “smaczków” na moment “tydzień-przed-wdrożeniem”. Często też “tydzień-przed-wdrożeniem” okazuje się być “potrzebujemy-jeszcze-pięciu-kolejnych-dni”, “trzeba-to-przepisać-bo-nie-działa” czy tym podobnymi… Na szczęście można temu zaradzić. Panie i panowie (i programiści), kłania się przed wami Test-driven development.
Zamysł jest taki: zanim napiszemy faktyczny kod – piszemy krótkie “przypadki testowe” (z ang. test cases). Przykłady?
- zanim napiszemy funkcję do autentykacji użytkownika – piszemy test case, który sprawdza, czy logowanie przykładowego użytkownika powiodło się
- zanim napiszemy faktyczne powiązania pomiędzy modelami – opisujemy je w testach
- zanim napiszemy walidację konkretnego modelu – rozpisujemy komplet przypadków testowych, który atakuje model poprawnymi (lub niepoprawnymi) danymi i sprawdzamy, czy się zapisuje (lub też nie)
Po napisaniu test case zabieramy się za pisanie właściwej funkcjonalności, odpalamy testy i cieszymy się, jeśli wszystkie przeszły – a jeśli nie, to poprawiamy kod. Następnie piszemy kolejny test. Piszemy funkcjonalność. Odpalamy testy…
“Po co mam pisać dwa razy to samo?”, “czy to się nie kłóci z agile software development?”. Jeśli nie zadaliście sobie tych pytań, to szkoda. Ja zadałem. Zyskujemy kilka rzeczy:
- Kompletny zestaw testów. Pozwala mocno zaoszczędzić czas (nawet, jeśli z początku wydaje się być inaczej) – poza wspomnianym już problemem debugowania przed wdrożeniem, testy dają nam również pewność, że aplikacja działa w różnych środowiskach (development, staging, production…), które nie zawsze przecież są na tych samych maszynach. Do tego jesteśmy świadomi błędów gdy zmiana jednej funkcjonalności, od której zależne są inne, spowoduje wysypanie się któregoś miejsca aplikacji, nawet bardzo ukrytego (jeśli jest pokryte testami, oczywiście).
- Klarowne spojrzenie na cały projekt. Możemy bardziej skupić się na tym, jak aplikacja powinna działać w środku – pisząc test nie obchodzi nas, jak będzie wyglądała funkcja logująca użytkownika – obchodzi nas za to, w jaki sposób może zalogować się użytkownik. (Prosta myśl, ale trudna do przekazania).
Ok, uwierzcie mi: testy są przydatne. Do tego pisanie testów może być przyjemne i zautomatyzowane. Pisanie testów może uratować życia. Od czego zacząć?
Na start polecam shoulda plugin. Załóżmy, że chcemy mieć dzieci… Co powinno mieć dziecko? Imię, wagę, wzrost. Generujemy model za pomocą script/generate. Dostajemy model w /app/models i test w /test/units. Zacznijmy od testu.
require File.dirname(__FILE__) + '/../test_helper'
class ChildTest < ActiveSupport::TestCase
should_have_db_columns :name, :weight, :height, :gender, :father_id
end
Następnie tworzymy migrację. Uwzględniamy w niej wymagane pola. Odpalamy rake db:migrate. Odpalamy test: rake test:units. Cieszymy się.
Dodajmy przykładowe dziecko do testów. Do pliku /test/fixtures/children.yml wrzućmy Timmy’ego:
timmy:
id: 1
name: Timmy
height: 103
weight: 15
father_id: 1
Sporo waży. Dziecko powinno też mieć ojca:
require File.dirname(__FILE__) + '/../test_helper'
class ChildTest < ActiveSupport::TestCase
should_have_db_columns :name, :weight, :height, :gender, :father_id
should_belong_to :father
end
W modelu dodajemy odpowiednią relację:
belongs_to :father
Odpalamy testy. Cieszymy się. Co jednak, gdy dziecko nie ma ojca? Jest sierotą. Sprawdzimy to. Najpierw testem:
require File.dirname(__FILE__) + '/../test_helper'
class ChildTest < ActiveSupport::TestCase
should_have_db_columns :name, :weight, :height, :gender, :father_id
should_require_attributes :name
should_belong_to :father
context "A child" do
setup do
@child = children(:timmy)
end
should "be an orphan when father_id is null" do
@child.father_id = nil
assert_equal true, @child.orphan?
end
end
end
Następnie metoda w modelu:
def orphan?
return self.father_id.nil?
end
Odpalamy test. Przechodzi. Dziecko jest sierotą. Możemy napisać kolejny test sprawdzający, czy dziecko mające ojca nie jest sierotą. Możemy sprawdzić, czy ojciec ma więcej dzieci i w tym przypadku sprawdzić, czy jest jedynakiem. Możemy sprawdzić walidację dziecka bez wagi (wiadomo, że takie nie istnieją)…
W następnym poście opiszę narzędzia, które pozwalają na zautomatyzowanie testowania czy też sprawdzanie procentowego pokrycia kodu testami – a póki co polecam przejrzeć dokumentację rdocs pluginu shoulda. To najlepsze źródło do zrozumienia idei TDD w railsach, na jakie trafiłem.