TDD z użyciem Test::Unit
Jakiś czas temu przetoczyło się w blogosferze dyskusja o wyższości test/unit nad rspekiem. Uważam, że to tak naprawdę kwestia gustu, ale ponieważ dużo osób jest zmęczonych rspekiem, dziś pora na pokazanie przykładowej sesji TDD właśnie w oparciu o test/unit.
Teoria TDD w wielkim skrócie
Dla tych którzy nie wiedzą na czym polega Test Driven Development (a są tacy), przypominam, że to proces produkcji oprogramowania, który składa się z trzech etapów:
- Napisz test, który sprawdza funkcję, którą piszesz. Test powinien zakończyć się błędem.
- Napisz kod, który sprawi, że test zacznie działać.
- Zrefaktoryzuj otrzymany kod, posiłkując się przygotowanym testem.
Po wykonaniu takiej iteracji otrzymuje sensowny, otestowany kod i możemy zająć się pisaniem kolejnej funkcji.
Praktyka testowania
W tym przykładzie napiszemy metodę average
dla klasy Array
. Zacznijmy od przygotowania testu, który sprawdzi dwa proste przypadki.
Przygotujemy dwa pliki: lib/average.rb
- który będzie zawierał kod (na razie pusty) oraz test/test_average.rb
, w którym umieścimy nasz test:
require 'lib/average' require 'test/unit' class TestAverage < Test::Unit::TestCase def test_simple assert_equal '4,5', [4,5].average assert_equal '3', [2,2,3,5].average end end
Na potrzeby testu ładujemy plik z naszym kodem i bibliotekę test/unit
. Klasa testowa musi dziedziczyć po Test::Unit::TestCase
. Metoda testowa powinna zaczynać się od słowa test
, dzięki czemu zostanie ona wywołana. Wszystkie metody bez tego przedrostka traktowane są jako pomocnicze i nie są automatycznie wywoływane przy uruchomienia. Tak przygotowany test uruchamiamy poleceniem:
ruby test/test_average.rb
Jeśli dobrze napisaliśmy test, to powinniśmy otrzymać błąd. Jeśli test jest spełniony bez pisania kodu, to znaczy, że albo gdzieś już mamy odpowiednie metody, albo (co bardziej prawdopodobne), test został napisany błędnie.
Loaded suite test/test_average Started E Finished in 0.000278 seconds. 1) Error: test_simple(TestAverage): NoMethodError: undefined method `average' for [4, 5]:Array test/test_average.rb:5:in `test_simple' 1 tests, 0 assertions, 0 failures, 1 errors
Test klarownie mówi nam, że nie posiadamy metody average
w klasie Array
. Aby ją dodać należy otworzyć klasę Array
. Istnieją bardziej eleganckie sposoby jej dodania, my wykorzystamy najbardziej prymitywny z nich, to jest artykuł o TDD i kropka. W pliku lib/average.rb
zdefiniujmy naszą metodę.
class Array def average end end
Ponownie odpalamy test i widzimy, że nasza metoda zwraca niewłaściwy wynik:
Loaded suite test/test_average Started F Finished in 0.024466 seconds. 1) Failure: test_simple(TestAverage) [test/test_average.rb:6]: <"4,5"> expected but was. 1 tests, 1 assertions, 1 failures, 0 errors
Pora zatem zaimplementować średnią w najbardziej prosty i prymitywny sposób:
class Array def average sum = 0 count = 0 self.each do |element| sum += element count += 1 end sum/count end end
Loaded suite test/test_average Started F Finished in 0.004176 seconds. 1) Failure: test_simple(TestAverage) [test/test_average.rb:7]: <"4,5"> expected but was <4>.
Prawie zadziałało. Niestety ponieważ sum
jest liczbą całkowitą, otrzymany wynik, też jest całkowity. Pora to naprawić.
class Array def average sum = 0 count = 0 self.each do |element| sum += element count += 1 end sum.to_f/count end end
Loaded suite test/test_average Started F Finished in 0.004232 seconds. 1) Failure: test_simple(TestAverage) [test/test_average.rb:7]: <"4,5"> expected but was <4.5>. 1 tests, 1 assertions, 1 failures, 0 errors
Ponieważ jesteśmy gapy, w teście podaliśmy, że wynik ma być łańcuchem znaków, a przecież również powinien być liczbą. Poprawiamy test, usuwając cudzysłowy.
require 'lib/average' require 'test/unit' class TestAverage < Test::Unit::TestCase def test_simple assert_equal 4.5, [4,5].average assert_equal 3, [2,2,3,5].average end end
Loaded suite test/test_average Started . Finished in 0.000239 seconds. 1 tests, 2 assertions, 0 failures, 0 errors
Hurra! Udało się, teraz warto zastanowić się nad ciekawszym przypadkiem: np. co się stanie, gdy tablica będzie pusta. W takim przypadku wynik powinien być nilem, dodajemy zatem drugi test.
require 'lib/average' require 'test/unit' class TestAverage < Test::Unit::TestCase def test_simple assert_equal 4.5, [4,5].average assert_equal 3, [2,2,3,5].average end def test_empty_array assert_nil [].average end end
Loaded suite test/test_average Started F. Finished in 0.00441 seconds. 1) Failure: test_empty_array(TestAverage) [test/test_average.rb:12]:expected but was . 2 tests, 3 assertions, 1 failures, 0 errors
Test nam przypomniał o wierszyku: pamiętaj cholero, nie dziel przez zero. Musimy obsłużyć taki przypadek.
class Array def average sum = 0 count = 0 each do |element| sum += element count += 1 end sum.to_f/count if count != 0 end end
Loaded suite test/test_average Started .. Finished in 0.000383 seconds. 2 tests, 3 assertions, 0 failures, 0 errors
Jest dobrze; mamy test, mamy działający kod, to jest ten moment, w którym należy na kod spojrzeć krytycznie. Nie musimy ręcznie liczyć elementów tablicy, możemy skorzystać z metody size
.
class Array def average sum = 0 each do |element| sum += element end sum.to_f/size unless size == 0 end end
O dwie linijki prościej. Sprawdźmy, czy to działa:
Loaded suite test/test_average Started .. Finished in 0.000383 seconds. 2 tests, 3 assertions, 0 failures, 0 errors
Czy można to jeszcze skrócić? Przy pomocy inject
tak.
class Array def average inject{ |sum, element| sum + element }.to_f / size unless size == 0 end end
Loaded suite test/test_average Started .. Finished in 0.000383 seconds. 2 tests, 3 assertions, 0 failures, 0 errors
Zadziałało, może jeszcze bardziej?
class Array def average inject(:+).to_f / size unless size == 0 end end
Inject
otrzymał symbol operacji, którą ma przeprowadzić na wszystkich elementach tablicy. Ponieważ operacją tą jest dodawanie, to należy operator dodwania +
poprzedzić znakiem symbolu :
. Gdyby metodą, którą chcemy wykonać było foo
, to kod musiałby wyglądać tak: inject(:foo)
. Sprawdźmy, czy ta przypominającą emotikonkę konstrukcja zadziała:
Loaded suite test/test_average Started .. Finished in 0.000312 seconds. 2 tests, 3 assertions, 0 failures, 0 errors
Tak oto przeszliśmy przez trzy fazy TDD. Napisaliśmy test; napisaliśmy kod, który przechodzi ten test; a potem poprawiliśmy ten kod, żeby wyglądał lepiej. I wcale nie było to takie trudne.