Návštěvní kniha v Nette s testy (TDD)
Předpoklady
- Webserver (PHP >= 5.2, popora SQLite 3) s doinstalovaným PHPUnit (např. XAMPP)
- IDE s podporou PHPUnit (např. NetBeans)
- Nette
- Dibi (znát, doporučuji http://dibiphp.com/cs/quick-start)
Pár slov úvodem
V tomto článku se pokusím Vám ukázat vývoj řízený pomocí testů (tzv. TDD) v Nette na jednoduché aplikaci – návštěvní knize. Pokud Vás vývoj řízený testy nezajímá, ale raději byste se seznámili se samotným Nette, prostě části o testech přeskočte. Strohé zadání návštěvní knihy zní nějak takto:
Zadání aplikace
- Na úvodní stránce aplikace bude zobrazen přehled všech příspěvků v knize.
- V případě, že v knize nejsou žádné příspěvky o tom uživatele aplikace informuje.
- Kdokoli může přidat příspěvek do knihy.
- Administrátor může příspěvky mazat, normálnímu uživateli se to nesmí povést.
Začínáme
Abychom mohli začít, musíme si nejprve předpřipravit stavební kámen. Protože se článek nezabývá návrhem databáze, pro naše účely jsem jednu připravil, proto si ji prosím stáhněte zde: . Nejprve si stáhneme poslední stabilní verzi Nette pro PHP 5.2 a začneme nahráním Skeletonu na webserver. Skeleton najdete ve složce tools z archivu, vezmeme jeho 4 podsložky a nakopírujeme je do složky guestbook na webserveru (nesmíme zapomenout doplnit knihovny Nette a dibi do složky libs). Poté nastavíme práva na 777 složkám app/log a app/temp. Pokud jste ještě nevytvořili ve Vašem IDE projekt pro návštěvní knihu, učiňte tak právě nyní. Ve složce app bude samotný kód aplikace, ve složce libs a tests jak název napovídá budou knihovny (Nette, dibi, přip. Texy, Zend nebo jiné komponenty z Nette Extras), zatímco ve složce document_root budou umístěny soubory přístupné z venčí – tedy CSS, JavaScriptové soubory a soubor index.php načítající aplikaci ze složky app. Pokud nenahráváte složky app a libs mimo document root. Je důležité, aby se v nich nacházel soubor .htaccess z archivu Nette, jinak ve vaší aplikaci vzniká bezpečnostní díra!
Při programování aplikací v Nette se používá architektura MVP, pokud ji neznáte, můžete si přečíst popis v dokumentaci: MVP.
Staženou databázi nahrajeme do složky app/models. Nyní je dobré vědět,
co který soubor ve Skeletonu dělá: soubor app/config.ini obsahuje konfiguraci
aplikace, tedy typicky třeba připojení k databázi, různé nastavení
služeb, konfiguraci php atd. Pokud navštívíte soubor
document_root/index.php, stane se zhruba toto – nadefinují se základní
konstanty (WWW_DIR, LIBS_DIR a APP_DIR), načte (require) se soubor
app/bootstrap.php, který následně spustí aplikaci (příkazem
$application->run()). Dále se již pomocí autoloadingu načtou
a spustí presentery, modely a šablony načež se celá aplikace vykreslí.
Pokud jste již někdy testy psali, víte, že pokud testujete model,
nepotřebujete (resp. je nežádoucí), aby běžela celá aplikace –
v Nette se to dá obejít podmíněním (if (Environment::getName() !==
"console")) příkazu $application->run(). Právě jsme
se seznámili s třídou Environment – jak její název napovídá, obsahuje
pomocné metody (statické) pro práci s prostředím – umožňuje načítat
konfigurační soubory, více viz API). Abychom mohli začít, upravíme soubor
app/config.ini doplněním následujícího kódu do sekce common:
database.driver = "pdo"
database.dsn = "sqlite:%appDir%/models/guestbook.sqlite"
Na konec souboru app/config.ini ještě přidejte [console <
development]. Nyní se připojíme k databázi v bootstrap.php pomocí
pár řádků PŘED voláním $application->run()
doplněním:
dibi::connect(Environment::getConfig('database'));
Pokud se teď rozhodnete otevřít aplikaci v prohlížeči, měli byste vidět „It works!“. Pokud místo toho vidíte červenou stránku (tzv. Laděnku), zkontrolujte oprávnění u souboru guestbook.sqlite.
Píšeme testy
U TDD se nejprve píšou testy a až poté se píše kód (snaží se o „co nejmenší, co vyhoví testům“). Nejjednodušší bude začít s testem modelu. Vytvoříme tedy soubor tests/GuestbookTest.php, začneme s následujícím kódem:
<?php
require_once "PHPUnit/Framework.php";
require_once dirname(__FILE__) . "/../document_root/index.php";
/**
* Test of Guestbook model
*/
class GuestbookTest extends PHPUnit_Framework_TestCase
{
/** @var Guestbook */
private $model;
public function setUp()
{
$this->model = new Guestbook;
dibi::query("TRUNCATE TABLE %n", $this->model->table);
}
}
Dá se to použít jako šablona pro všechny testy modelů – díky
metodě setUp se při spuštění každého testu vyprázdní
tabulka. Ujasníme si, co na modelu chceme testovat – v modelu budeme
potřebovat insert a delete. Insert můžeme otestovat tak, že po jeho
provedení bude v tabulce více jak 0 záznamů. V našem testu provedeme
2 inserty, zkusíme výpis všech záznamů a poté spustíme test smazání
řádku – ověříme, že v tabulce jeden řádek zůstal. Nic dalšího
v modelu testovat nemusíme, duplicitní záznamy jsou povoleny. Pokud se na
napsání testu necítíte, zde je připravený:
<?php
require_once "PHPUnit/Framework.php";
require_once dirname(__FILE__) . "/../document_root/index.php";
/**
* Test of Guestbook model
*/
class GuestbookTest extends PHPUnit_Framework_TestCase
{
/** @var Guestbook */
private $model;
protected function setUp()
{
$this->model = new Guestbook;
dibi::query("TRUNCATE TABLE %n", $this->model->table);
}
public function testInsert()
{
$values = array(
'author' => 'Jožko',
'email' => 'jozko@gmail.com',
'title' => 'Lorem',
'content' => 'Ipsum dolor sit amet',
'added' => new DateTime(),
);
$this->model->insert($values);
$this->model->insert($values);
$this->assertEquals(2, dibi::fetchSingle("SELECT COUNT([id]) FROM %n", $this->model->table));
}
public function testSelect()
{
$this->testInsert();
$rows = $this->model->fetchAll();
$this->assertEquals(2, count($rows));
}
public function testDelete()
{
$this->testInsert();
$id = dibi::fetchSingle("SELECT MAX([id]) FROM %n", $this->model->table);
$this->model->delete($id);
$this->assertEquals(1, dibi::fetchSingle("SELECT COUNT([id]) FROM %n", $this->model->table));
}
}
Nyní si zkuste sami napsat model vyhovující testům – třídu Guestbook umístěte do souboru app/models. Z testů vyplývá, že model musí dědit od Nette\Object nebo podobné třídy (kvůli property „table“, více viz. stránka o Nette\Object), musí mít metody insert, fetchAll a metodu delete. Pokud to nezvládnete, zkuste se inspirovat v archivu ke stažení.
Test presenteru a presenter
Abychom mohli otestovat presenter, musíte znát pár věcí.
Základním prvek v Nette je komponenta. Komponentou je například
formulář, menu, nebo třeba „stránkovadlo“. Komponenta je třída
dědící od Nette\Control nebo Nette\Component, vytváří se v presenteru
metodou createComponent<Name>. Dovolte mi to ilustrovat na
příkladu: máme formulář pro přidání příspěvku do naší knihy,
pojmenujeme ho třeba addItemForm – v presenteru musí existovat metoda
createComponentAddItemForm (která form vytvoří a poté ho vrátí příkazem
return), v šabloně se poté tato komponenta vykreslí skrze volání
{widget addItemForm} – dejte si pozor na velikost písmen. Více
viz FAQ/Co přesně dělá volání {widget ...} v šabloně.
K formuláři se v presenteru dá přistoupit pomocí
$this['addItemForm'] (presenter implementuje rozhranní ArrayAccess).
Po odeslání formuláře se přidá get parametr do URI adresy určující, jaký signál byl proveden – v tomto případě se na konec adresy přidá ?do=addItemForm-submit. Více o signálech. Toho využijeme při testování.
Abychom mohli spustit životní cyklus presenteru, potřebujeme vytvořit objekt typu PresenterRequest obsahující patřičné informace. Pokud se aplikace spustí přes prohlížeč, PresenterRequest je vytvořen díky routování, v našem případě jej musíme vytvořit uměle.
Pokud na presenteru zavoláme $presenter->run($request),
metoda nám vrátí odpověď – jedná se o objekt implementující
rozhranní IPresenterResponse.
Presenter bude sestávat z jedné action (default), z jednoho
formuláře (addItemForm) a jednoho signálu
(deleteItem). To nám spolu s předchozím vysvětlením stačí
k tomu, abychom napsali test (tests/GuestbookPresenterTest.php).
Základ je téměř stejný jako u testu modelu:
<?php
require_once dirname(__FILE__) . "/../document_root/index.php";
require_once "PHPUnit/Framework.php";
/**
* Test of Guestbook Presenter
*/
class GuestbookPresenterTest extends PHPUnit_Framework_TestCase
{
/** @var GuestbookPresenter */
private $object;
protected function setUp()
{
$this->object = new GuestbookPresenter;
}
}
Jakmile navštívíme presenter, měli bychom vidět formulář. To otestujeme následující metodou:
public function testSeeAddItemForm()
{
$requestData = array(
'action' => 'default' // přistupujeme k výchozí action
);
$request = new PresenterRequest('Guestbook', 'get', $requestData); // vytváříme request
$response = $this->object->run($request); // spouštíme presenter
$this->assertType("AppForm", $response->getSource()->presenter['addItemForm']); // getSource vrací šablonu, její proměnná presenter by měla mít komponentu addItemForm typu AppForm
}
Protože formuláře v Nette by měli být psány přes pattern Post-Redirect-Get, vyzkoušíme, jestli nás aplikace po správném vyplnění formuláře přesměruje. Pokud ale formulář vyplníme nevalidně, aplikace by nás přesměrovat neměla.
Nejprve formulář vyplníme správně:
public function testFillFormAndRedirect()
{
$requestData = array(
'action' => 'default',
'do' => 'addItemForm-submit', // signál
// dále následují data formuláře
'author' => 'Jožko',
'email' => 'jozko@gmail.com',
'title' => 'Lorem',
'content' => 'Ipsum dolor sit amet',
'save' => 'save', // tlačítko (submit button)
// obecně: název form. prvku => hodnota
);
$request = new PresenterRequest('Guestbook', 'POST', $requestData, $requestData);
$response = $this->object->run($request);
$this->assertType('RedirectingResponse', $response); // aplikace nás musí přesměrovat
}
Nyní všechna pole formuláře necháme prázdná:
public function testFillFormAndNotRedirect()
{
$requestData = array(
'action' => 'default',
'do' => 'addItemForm-submit', // signál
// dále následují data formuláře
'save' => 'save', // tlačítko (submit button)
);
$request = new PresenterRequest('Guestbook', 'POST', $requestData, $requestData);
$response = $this->object->run($request);
$this->assertType('RenderResponse', $response); // aplikace nás NESMÍ přesměrovat
}
Poslední věc, kterou musíme otestovat, je zajištění bezpečnosti – pouze administrátor může mazat příspěvky. V našem případě postačí primitivní ověření, zda je uživatel přihlášen (k tomu drobně upravíme UsersModel.php ze Skeletonu, najdete ho v archivu). Pokud se pokusí smazat zprávu nepřihlášený uživatel, vyhodíme výjimku ForbiddenRequestException – což při vhodném nastavení nechá vykreslit Error presenter (selhání ověříme blokem try – catch), zatímco pokud uživatel bude přihlášen, přesměrujeme zpět.
Oba testy vypadají takto:
public function testDoNotAllowDelete()
{
$requestData = array(
'do' => 'delete',
'id' => 1,
);
$request = new PresenterRequest('Guestbook', 'GET', $requestData, $requestData);
try {
$response = $this->object->run($request);
} catch (Exception $e) {
$this->assertType('ForbiddenRequestException', $e);
}
}
public function testAllowDeleteWhenLogged()
{
$this->object->loggedIn = TRUE;
$requestData = array(
'action' => 'default',
'do' => 'delete',
'id' => 1,
);
$request = new PresenterRequest('Guestbook', 'GET', $requestData, $requestData);
$response = $this->object->run($request);
$this->assertType('RedirectingResponse', $response);
}
Na základě výše uvedeného testu si zkuste napsat presenter. Pokud se na to necítíte, zkuste se inspirovat presenterem z archivu. Nezapomeňte také na výpis všech položek.
Routování
Až doposud jsme aplikaci museli navštěvovat přes link ve formátu http://localhost/…ot/guestbook – pro pohodlnější přístup upravíme v app/bootstrap.php řádky z:
$router[] = new Route('index.php', array(
'presenter' => 'Homepage',
'action' => 'default',
), Route::ONE_WAY);
$router[] = new Route('<presenter>/<action>/<id>', array(
'presenter' => 'Homepage',
'action' => 'default',
'id' => NULL,
));
na
$router[] = new Route('index.php', array(
'presenter' => 'Guestbook',
'action' => 'default',
), Route::ONE_WAY);
$router[] = new Route('<presenter>/<action>/<id>', array(
'presenter' => 'Guestbook',
'action' => 'default',
'id' => NULL,
));
Tím se stane presenter Guestbook výchozím a pokud nebude v URL obsažena informace o presenteru, zobrazí se právě on.
Kompletní test aplikace
Nakonec ještě Seleniem vyzkoušíme přidat příspěvek, uvádím už jen zdroják:
<?php
require_once dirname(__FILE__) . "/../document_root/index.php";
require_once 'PHPUnit/Extensions/SeleniumTestCase.php';
class GuestbookAddTest extends PHPUnit_Extensions_SeleniumTestCase
{
protected function setUp()
{
$this->setBrowser('*firefox');
$this->setBrowserUrl("http://localhost/guestbook/document_root/");
}
public function testSuccess()
{
$this->open();
$this->type("author", "Jožko");
$this->type("title", "Titulek");
$this->type("content", "obsah");
$this->clickAndWait("save");
$this->assertTextPresent("byl přidán");
}
}
AJAX
Na závěr, aby naše kniha byla „cool“, si ukážeme, jak jednoduše se formuláře či odkazy dají zAJAXovat. Potřebovat k tomu budeme jQuery (použijeme verzi 1.4) a soubor jquery.nette.js (který sestavíme spojením souborů dvou rozšíření, oba pochází z dílny Honzy Marka – Ajax s jQuery a Ajaxové formuláře s jQuery). Poslední JavaScriptový soubor si napíšeme dle vzoru z dokumentace k oběma rozšířením, pojmenujeme ho třeba guestbook.js:
$(function () {
// vhodně nastylovaný div vložím po načtení stránky
$('<div id="ajax-spinner"></div>').appendTo("body").ajaxStop(function () {
// a při události ajaxStop spinner schovám a nastavím mu původní pozici
$(this).hide().css({
position: "fixed",
left: "50%",
top: "50%"
});
}).hide();
});
// zajaxovatění odkazů provedu takto
$("a.ajax").live("click", function (event) {
event.preventDefault();
$.get(this.href);
// zobrazení spinneru a nastavení jeho pozice
$("#ajax-spinner").show().css({
position: "absolute",
left: event.pageX + 20,
top: event.pageY + 40
});
});
$("form").live('submit', function () { // POZOR, AŽ OD jQuery 1.4!!!
$(this).ajaxSubmit();
return false;
});
Všechny tři potřebné soubory umístíme do složky document_root/js a
načteme je v app/templates/@layout.phtml. Dále uděláme 4 věci a naše
aplikace je téměř kompletně AJAXová: obalíme blok s příspěvky kódem
{snippet itemlist} a {/snippet} v souboru
app/templates/Guestbook/default.phtml a obalíme výpis flash zpráv kódem
{snippet flashes} a {/snippet} v souboru
@layout.phtml, nesmíme zapomenout aplikovat Zavináčovou
magii; do metod handleDelete a addItem presenteru Guestbook přidáme
řádek $this->invalidateControl() a podmíníme
přesměrování pomocí if (!$this->isAjax()); nakonec odkazu
pro smazání přidáme třídu ajax a voila – máme plně funkční, web
2.0 aplikaci :-). Pokud byste chtěli do návštěvní knihy přidat třeba
našeptávač napovídající jméno, mohly by se Vám hodit informace ze
seriálu na Zdrojáku – konkrétně z dílu Nette
Framework – AJAX (pokračování).
Další možnosti rozšíření
- Doplnit stránkování
- Nastylovat knihu
- …
- …

Připojené soubory
- guestbook.sqlite 2 kB
- guestbook-tdd.zip 408 kB
- guestbook.sqlite 2 kB
Komentáře 
buff | 12. 2. 2010, 12:31 | question
Není tohle špatně?
try { $response = $this->object->run($request); }
catch (Exception $e) { $this->assertType(‚ForbiddenRequestException‘, $e); }
Co když to žádnou výjimku nevyhodí, pak by test taky prošel, ne? UPDATE: Omlouvám se za formátování, ale líp to tady asi nejde :(
peci1 | 17. 5. 2010, 21:33 | bug
Jako pise buff o testu na exception, ktery projde, kdyz neni zadna vyjimka vyhozena, vidim chybu i v druhe casti. Tam by se naopak melo testovat nevyhozeni vyjimky.
Jinak nejak nechapu tu AJAXovou cast na konci. Chapal bych, kdyby pokracovala ukazkou, jak otestovat takovy AJAX request, ale takhle mi to spis prijde jako AJAXova agitka…
Ondřej Mirtes | 18. 5. 2010, 8:41 | comment
Na vyhození výjimky má PHPUnit speciální syntaxi (pokud není vyhozena, test neprojde):
/**
* @expectedException InvalidStateException
*/
public function testIsExceptionThrown()
{
//...
}
Nevyhození výjimky by se dalo testovat nějak takto:
public function testIsExceptionNotThrown()
{
try {
//...
} catch (Exception $e) {
$this->fail();
}
}
Ondřej Brejla | 3. 6. 2010, 21:05 | comment
Já třeba nemám rád anotace pro testování výjimek, takže druhý běžný způsob (pro mě čitelnější):
public function testIsExceptionThrown()
{
try {
// ...code
$this->fail();
} catch (Exception $ex) {
}
}
ic | 16. 7. 2010, 18:56 | question
Nějak divně se mi chová ten poslední Seleniový test, test proběhne, v konzole je psáno vždy ok, podle toho co má provést je to taky vždy v pořádku ale v NetBeans někdy test selže a někdy projde, častěji tedy selže, bohužel. Taky vám to funguje takhle ‚dobře‘ ? Testuji ten samý soubor stále dokola a ještě pořád ani nevím proč někdy projde a někdy selže. Nevíte čím to může být?
Phoenix | 28. 9. 2010, 15:38 | comment
Já výjimky testuji přes metodu $this->setExpectedException(‚Exception‘); IMHO nejčitelnější :-).

johno | 29. 1. 2010, 18:16 | comment
Bolo by vhodne nazvy testov pisat tak, aby bolo jasne ake spravanie sa testuje. Napriklad namiesto testSuccess() → testAddedCommentShouldBeVisible() alebo testFillFormAndNotRedirect() → testEmptyCommentFormShouldNotRedirect() aj ked tu mam skor problem s tym, ze to je whitebox test a nie blackbox. Skor by sa malo testovat testEmptyCommentShouldntBeSaved().
Takto ma clovek pred sebou vlastne specifikaciu spravania a nemusi po mesiacii lustit co to znamena success. Taktiez testovat model (insert,save) je trochu zbytocne kedze ta funkcionalita sa len vyuziva a mala by byt otestovana uplne inde. Dokazom je len fakt, ze k tym testom modelu nebolo treba napisat ziadny kod.