Posts Tagged tdd

TDD na żywym organiźmie

Jakiś czas temu próbowałem nauczyć się stosowania TDD na “testowym” projekcie. Niestety testowanie kontrolerów i widoków wydało mi się zbyt problematyczne.

Pod wpływem zgłębianiu tematu przy okazji pisania pracy magisterskiej zacząłem też myśleć o tym, że pokrycie kodu testami w 100% może nie być możliwe – szczególnie w przypadku, gdy technika ta nie jest znana zespołowi. Jako, że podoba mi się podejście “sztuki rzeczy możliwych” – pomyślałem, że przynajmniej trzeba mieć unit testy w miejscach krytycznych. Zakładając, że stosujemy uczciwie podejście fat model, skinny controller – większość krytycznych elementów znajduje się właśnie w modelach.

Modele łatwiej jest testować niż “wyższe” warstwy modeli MVC, więc tym bardziej wspomniana zasada wydaje się być ważna.

Ostatnio w projekcie natknęliśmy się na ciekawy przypadek. W bazie istniały obiekty, do których mogli być przypisani właściciela (wiele właścicieli do wielu obiektów):
Object habtm Owner
Dodatkowo to przypisanie było ograniczone czasowo, tzn.:
Object1 jest powiązany z OwnerA od 15 marca 2009 do 31 lipca 2009
oraz
OwnerB jest powiązany z Object1 od 01 czerwca do nie_wiadomo_kiedy (to znaczy, że aktualnie jest przypisany, ale nie wiadomo, kiedy ten stan się zakończy, nazwę to przypisaniem otwartym na potrzeby tego wpisu).

Możliwa była sytuacja, gdy Owner1 jest przypisany do ObjectA tak jak w przykładzie powyżej i od 01 listopada do nie_wiadomo_kiedy też z ObjectA.

Nie zagłębiając się więcej w szczegóły dodam tylko, że potrzebna była metoda, która odpowie na pytanie “czy dla danego Object, Owner i danych dat początkowej i końcowej mogę dodać powiązanie?”.

Członek zespołu zabrał się za to zadanie i po jakimś czasie skończył. Jednak wyznał szczerze, że nie jest pewien, czy ten fragment dobrze działa. Problem zdawał się rosnąć w momencie używania przedziałów otwartych z prawej strony. Pomyślałem, żeby zamiast ślęczeć nad kodem przez najbliższą godzinę i ręcznie testować przypadki – pobawić się w TDD.

Może nie bardzo podobało się to programistce, która nad kodem aktualnie pracowała, ale kod ten został wywalony – łatwiej jest stosować TDD gdy zaczyna się od zera (przynajmniej na moim, bardzo początkującym poziomie). Pracowaliśmy od teraz wspólnie nad kodem.

Podejście było takie, żeby dodawać coraz to kolejne kombinacje testów – dodanie powiązania z konkretnymi datami gdy w bazie istnieje przypisanie zamknięte, próba dodania przypisania otwartego gdy w bazie jest zamknięte i wszystkie możliwe kombinacja. Wyszło ich około 20*.

(*) możliwe, że niektóre przypadki są bardzo podobne i się dublują – chciałem jednak mieć je wymienione explicite

Proces wyglądał następująco:
1. test przypadku
2. fail testu
3. poprawa testowanej funkcji
4. pass wszystkich testów
5. test dla następnego przypadku
6. fail testu

Gdy była możliwość dokonywany był refaktoring testowanej funkcji, a raz nawet refaktoring samych testów.

Jaki był bilans tego działania? Pomyślmy jak wyglądałoby życie, gdybyśmy podeszli do tego problemu “po staremu”

Zajęło by to jakąś godzinę, mielibyśmy “dość mocne przekonanie”, że metoda spełnia wymagania. Jednak raczej staralibyśmy się uniknąć dotykania tego fragmentu w przyszłości, bo to oznaczałoby przeprowadzenie testów ręcznych od początku.
Nie posiadalibyśmy jednak twardych dowodów na to, że wszystkie testy zostały przeprowadzone – tylko silne poczucie, że “raczej tak”.

Jak to wyglądało z TDD?
Zajęło to około 4 godzin. Mamy twarde dowody na to, że wszystkie przypadki, które wymyśliliśmy są testowane. Mogą być przetestowane w każdym momencie. Znaleźliśmy błąd polegający na niesprawdzaniu, czy data startowa powiązania jest mniejsza niż końcowa. W efekcie tych działań otrzymaliśmy bardzo przyjemną funkcję logiczną, którą z przyjemnością wrzuciliśmy do funkcji beforeValidate testowanego modelu, aby żaden zapis nie mógł się odbyć przy nieodpowiednich danych.
Gdyby klient zaskoczył nas zmianą wymagań dot. zasad, które zaimplementowaliśmy – bez większych problemów można zabrać się za ich modyfikację pilnując, żeby dotychczasowe testy, które jeszcze są aktualne, przechodziły.
Jako bonus – jeden z członków zespołu dowiedział się czegoś więcej o TDD.

Dlatego jeśli myślisz, że TDD jest przerażające, bo wymaga pisania testów wszędzie (jak ja myślałem na początku), to możesz spróbować stosowania TDD tylko w krytycznych miejscach. Najczęściej w modelach. Kto wie, może po jakimś czasie, gdy oswoimy się z tą techniką pojawi się jakaś koncepcja, żeby zastosować TDD w kontrolerach a później widokach?

No Comments

Tdd lekcja numer 2

Programowanie jest trudne. Wymaga ciągłej perfekcji przez miesiące i lata pracy. W najlepszym przypadku pomyłki prowadzą do kodu, który się nie kompiluje. W najgorszym – do błędów czekających w ukryciu, by ujawnić się w momencie, w którym spowodują największe szkody.
Ludzie rzadko pracują perfekcyjnie. Nie dziwi więc, że oprogramowanie zwykle ma błędy.
Czyś nie byłoby przydatne narzędzie, które informowałoby o pomyłkach programistycznych bezpośrednio po ich popełnieniu; narzędzie tak skuteczne, że debugowanie stałoby się niemal niepotrzebne?
James Shore, Shane Warder, Agile Development. Filozofia programowania zwinnego.

Tym narzędziem miałoby być TDD, więc nie ma na co czekać – drugie spotkanie wytwarzaniem sterowanym testami.
Zacznę od momentu, gdzie skończyłem poprzednio: http://blog.grzegorzpawlik.com/2009/05/zaczynamy-test-driven-development/

Ciekawe wrażenie: mimo tego, że nie ma żadnego interfejsu mam poczucie, że program działa jak należy ;)

Mam pewną zagwozdkę. Otóż powinienem zacząć od zmiany testu. Pomyślałem o tym, żeby metoda getLastTopics zwracała zadaną w parametrze ilość ostatnich tematów. Jednak dodanie parametru przy wywołaniu tej metody nie prowadzi do błędu. To specyfika php, więc na razie wyłamię się z cyklu i najpierw zmienię sygnaturę metody. Trudno.

//models/topic.php
function getLastTopics($ammount) {
return $this->find('all');
}

Oczywiście metoda nie przeszła testu, poprawię więc test (sic!)

//tests/cases/models/topic.test.php
function testGetLastTopics() {
$topics = $this->Topic->getLastTopics(5);
$this->assertTrue(is_array($topics));
$expected = array( array( 'Topic' => array('id'=>1, 'title'=>'Pierwszy post') ) );
$this->assertEqual($topics, $expected);
}

Test przeszedł. Mogę wrócić do zalecanej kolejności. Znów popsuję test, bo chcę dostać 3 ostatnie posty:

//topic.test.php
function testGetLastTopics() {
$topics = $this->Topic->getLastTopics(5);
$this->assertTrue(is_array($topics));
$expected = array( array( 'Topic' => array('id'=>1, 'title'=>'Pierwszy post') ),
array( 'Topic' => array('id'=>2, 'title'=>'Jakiś post')),
array( 'Topic' => array('id'=>3, 'title'=>'Trzeci post')),
);
$this->assertEqual($topics, $expected);
}

Dostałem błąd, jednak aby go poprawić – muszę poprawić fixtures, gdyż danych testowych jest za mało…

//tests/fixtures/topic_fixture.php
var $records = array(
array('id'=> 1, 'title' => 'Pierwszy post'),
array('id'=> 2, 'title' => 'Jakiś post'),
array('id'=> 3, 'title' => 'Trzeci post')
);

Test ok, jednak wolę, żeby te tematy były w kolejności od najnowszego do najstarszego:

//topic.test.php
$expected = array(
array( 'Topic' => array('id'=>3, 'title'=>'Trzeci post')),
array( 'Topic' => array('id'=>2, 'title'=>'Jakiś post')),
array( 'Topic' => array('id'=>1, 'title'=>'Pierwszy post') ),
);

Test nie przechodzi, więc poprawiam model:

//topic.php
class Topic extends AppModel {
var $name = "Topic";
function getLastTopics($ammount) {
return $this->find('all', array('order'=> "Topic.id DESC"));
}
}

Wszystko ok, więc sprawdzam, czy dostanę dwa najnowsze elementy:

//topic.test.php
function testGetLastTopics() {
$topics = $this->Topic->getLastTopics(3);
$this->assertTrue(is_array($topics));
$expected = array(
array( 'Topic' => array('id'=>3, 'title'=>'Trzeci post')),
array( 'Topic' => array('id'=>2, 'title'=>'Jakiś post')),
array( 'Topic' => array('id'=>1, 'title'=>'Pierwszy post') ),
);
$this->assertEqual($topics, $expected);

$topics = $this->Topic->getLastTopics(2);
$expected = array(
array( 'Topic' => array('id'=>3, 'title'=>'Trzeci post')),
array( 'Topic' => array('id'=>2, 'title'=>'Jakiś post')),
);
$this->assertEqual($topics, $expected);
}

Test nie przechodzi, choć wydawało mi się, że powinien przejść… no tak, dodałem parametr ammount, ale dalej zwracam wszystkie elementy, szybka poprawka:

class Topic extends AppModel {
var $name = "Topic";
function getLastTopics($ammount) {
return $this->find('all', array('order'=> "Topic.id DESC", 'limit'=>$ammount));
}
}

I znów jest ok.

Chociaż rozpisałem się tu na dwa wpisy w blogu, to samo pisanie zajęło mi może 15 minut. Działającego kodu wprawdzie nie ma sporo, ale bez większego bólu jest pokryty w 100% testami i mam też dane startowe.

Czy możemy przetestować zapisywanie danych do bazy? Spróbujmy, zaczynając jak zwykle od testu. Jeśli wszystko działa jak należy to po zapisaniu danego element mogę go dostać wywołując metodę getLastTopics z parametrem 1:

function testSave() {
$new_topic = array('title'=>'Post testowy');
$this->Topic->save($new_topic);
$newest = $this->Topic->getLastTopics(1);
$this->assertEqual($new_topic['title'], $newest[0]['Topic']['title']);
}

Zauważ, że nie porównuję tu całej tablicy, a tylko wartość `title`. Zakładam, że nie wiem jakiego id się spodziewać, a lekką przesadą byłoby wywołanie getLastInsertId() i sprawdzenie czy się zgadza sama ze sobą.
Nie jest to może najpotrzebniejszy test, bo sprawdziliśmy metodę, którą model Title dziedziczy z frameworka, która z pewnością jest przetestowana. Jednak przynajmniej teraz wiem, że da się.

Co dalej?
Może warto, żeby tematy miały jakieś posty?
Test:

//post.test.php
App::import('model', 'Post');
class PostTestCase extends CakeTestCase {
var $name="post";
/**
* @var Post
*/
var $Post;
function start() {
parent::start();
$this->Post = ClassRegistry::init('Post');
}
}

Test nie przechodzi z powodu braku bazy. Ale spróbujmy najpierw dodać model.

//models/post.php
class Post extends AppModel {
var $name = "Post";
}

Nadal marudzi o brak bazy, więc trzeba będzie stworzyć fixture:

class PostFixture extends CakeTestFixture {
var $name = 'Post';
var $fields = array(
'id' => array('type'=> 'integer', 'key'=>'primary'),
'topic_id' => array('type'=> 'integer', 'null'=> false)
);
}

Spróbujmy odczytać dany post:

//post.test.php
function testFind() {
$post = $this->Post->findById(1);
$expected = array('Post' => array('id'=> 1, 'topic_id'=>1));
$this->assertEqual($post, $expected);
}

Dostaniemy błąd spowodowany brakiem danych testowych, naprawimy to dodając w post_pixture.php:

var $records = array( array('id'=> 1, 'topic_id'=>1),
);

Jednak Post zawsze powinien należeć do tematu:

function testFind() {
$post = $this->Post->findById(1);
$expected = array(
'Post' => array('id'=> 1, 'topic_id'=>1),
'Topic' => array('id'=>1, 'title'=>'Pierwszy post'),
);
$this->assertEqual($post, $expected);
}

Załadujmy jeszcze potrzebny topic_fixture:

var $fixtures = array('app.post', 'app.topic');

A teraz potrzebną relację, która sprawi, że z Postem dostaniemy topic:

class Post extends AppModel {
var $name = "Post";
var $belongsTo = array('Topic');
}

Jesteśmy na zielonym…
post_pass
… więc możemy skończyć. Jak na razie nie ma tu żadnych wypasów. Pocieszam się jednak, że to jak ze standardowym “Hellow world”. Nie zachwyca, ale nas zachwyca ;)

1 Comment

Zaczynamy Test Driven Development

Nie mam zamiaru opisywać tutaj zasad TDD. Po prostu zapragnąłem się tego uczyć i tutaj będę umieszczał poszczególne “lekcje”.

Pomysł jest taki, żeby zbudować przy pomocy TDD i CakePHP prosty engine forum. Założyłem nowy projekt w eclipse, wgrałem cake’a i bibliotekę simpletest do katalogu vendors. Założyłem dwie bazy private_bulletin_board i private_bulletin_board_test (ten przedrostek private służy tylko do uporządkowania moich baz w phpMyAdminie).

Zdefiniowałem configuracje baz:

//app/config/database.php
class DATABASE_CONFIG {

var $default = array(
'driver' => 'mysql',
'persistent' => false,
'host' => 'localhost',
'login' => 'netarch',
'password' => 'noth1nG',
'database' => 'private_bulletin_board',
'prefix' => '',
);

var $test = array(
'driver' => 'mysql',
'persistent' => false,
'host' => 'localhost',
'login' => 'netarch',
'password' => 'noth1nG',
'database' => 'private_bulletin_board_test',
'prefix' => '',
);
}

Pierwszy krok, to stworzenie testu. Pomyślmy co mógłby testować? Powiedzmy, że każde forum składa się z tematów. Zaczynamy test:

//tests/cases/models/topic.test.php
App::import('model', 'Topic');

class TopicTestCase extends CakeTestCase {
var $fixtures = array();

function start() {
parent::start();
$this->Topic = ClassRegistry::init('Topic');
}

}

Teoretycznie po uruchomieniu testu powinniśmy dostać informację o nieistniejącej klasie Topic. Jednak niespodzianka, brak klasy nie jest problemem, za to dostajemy info o braku tabeli topics dla klasy Topic:
missing-db

Widocznie ClassRegistry nie potrzebuje klasy, aby zainicjować jej obiekt ;) . Poprawmy metodę start:

function start() {
parent::start();
$this->Topic = new Topic();
$this->Topic = ClassRegistry::init('Topic');
}

Jest nieco lepiej:
class-not-found

Mamy test, który nie przechodzi. Co paradoksalnie oznacza, że wszystko jest w porządku. Krok drugi to stworzenie testowanej klasy:

/models/topic.php
class Topic extends AppModel {
var $name = "Topic";
}

Testujemy:
missing-db
Znów missing table, ale przynajmniej się tego spodziewaliśmy. Potrzebujemy testowych danych, stworzymy zaten fixture:

//test/fixtures/topc_fixture.php
class TopicFixture extends CakeTestFixture {
var $name = "Topic";
var $fields = array(
'id' => array('type'=>'integer', 'key'=>'primary'),
'title' => array('type'=>'string', 'length' => 150, 'null'=> false)
);

}

Niestety w międzyczasie należało wywalić z testu zapis $this->Topic = new Topic(). Nie pytajcie dlaczego ;)
Należy też w tym pliku dodać

var $fixtures = array('app.topic');

Testujemy:
success1

Pierwszy sukces. Jeden cykl zakończony. Żeby jednak nie było tak jałowo – będziemy kontynuować, aż model zacznie robić coś pożytecznego. Poprawmy test. Żeby to zrobić trzeba się zastanowić co od takiej klasy możemy oczekiwać? Wydaje się, że na pewno będziemy potrzebowali listę ostatnich aktualnych postów. Zatem zacznijmy znów od testu. Dodajemy metodę:

//topic.test.php
function testGetLastTopics() {
$topics = $this->Topic->getLastTopic();
}

i wykonujemy test:
error1
Test oblany. Zauważ, że nie dostaliśmy błędu o nieistniejącej metodzie. To przez “magiczne” funkcje modeli, które dla nieistniejących metod generują zapytania sql (dlatego możesz zrobić getByJakiesPoleWBazie()). Teraz trzymając się zasad należy dodać minimalną ilość zmian w kodzie, aby test zaczął przechodzić. W tym wypadku wystarczy zdefiniować metodę w modelu:

function getLastTopics() {}

Test:
pass1
Drugi cykl za nami. Teraz chciałbym, żeby metoda zwracała mi tablicę z tematami z forum. Modyfikuję test:

function testGetLastTopics() {
$topics = $this->Topic->getLastTopics();
$this->assertTrue(is_array($topics));
}

Test:
fail11
Oczywiście wszystko się zgadza. Poprawiamy metodę pamiętając o tym, że wykonujemy minimalną ilość poprawek, aby kod zacząć przechodzić testy.

class Topic extends AppModel {
var $name = "Topic";
function getLastTopics() {
return array();
}
}

test:
pass2
Jeśli zaczyna się robić nieco nudnawo, to podnieśmy poprzeczkę. Sprawdzimy, czy model faktycznie zwróci nam dane. Założymy, że topic to nazwa i id:

function testGetLastTopics() {
$topics = $this->Topic->getLastTopics();
$this->assertTrue(is_array($topics));

$expected = array( array( 'Topic' => array('id'=>1, 'title'=>'Pierwszy post') ) );
$this->assertEqual($topics, $expected);
}

I testujemy (oczywiście spodziewając się błędu):
fail2
Poprawiamy metodę w modelu:

function getLastTopics() {
return $this->find('all');
}

Nadal mamy błąd dlatego, że brakuje nam danych testowych. Musimy wrócić do fixtures na moment i dodać tam następujący kod:

var $records = array(
array('id'=> 1, 'title' => 'Pierwszy post'),
);

pass3

To na razie tyle. Niedługo może bardziej skomplikowane rzeczy ;)
część druga…

1 Comment