Świetna książka PHP: Hight quality frameworks and applications

Real-Worls Solutions for Developing High Quality PHP Frameworks and Applications

Real-Worls Solutions for Developing High Quality PHP Frameworks and Applications


Świetna dla każdego, kto

  • pracuje na systemach z zaszłościami (Legacy Systems)
  • myśli, że refaktoring takich systemów nie jest możliwy
  • testy automatyczne muszą być równie przyjemne jak hemoroidy
  • boi się, że aplikacja, w którą zainwestował już sporo powoli staje się niemożliwa w utrzymaniu
  • myśli, że system, którego używa od lat trzeba przepisać od zera

Pozycja zdecudowanie nie jest przeznaczona dla tych, którzy zaczynają swoją przygodę z PHP. Jeśli jednak już okrzepłeś i szukasz czegoś więcej – polecam gorąco.

Share Button

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?

Share Button

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 ;)

Share Button