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:

  1. Napisz test, który sprawdza funkcję, którą piszesz. Test powinien zakończyć się błędem.
  2. Napisz kod, który sprawi, że test zacznie działać.
  3. 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.