contributte / planette-site

💀 [DISCONTINUED] All the roads go through the Planette
https://planette.vercel.app
MIT License
9 stars 3 forks source link

2010-01-30: navstevni-kniha-v-nette-s-testy-tdd #83

Open paveljanda opened 7 years ago

paveljanda commented 7 years ago

Předpoklady

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

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: guestbook.sqlite. 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/guestbook/document_root/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í

Download

https://files.nette.org/git/pla/nette-guestbook.png

{{tags: tutorial}}

{{author: Ola|1784}}

paveljanda commented 7 years ago

author: Honza Kuchař (honza.kuchar@grifart.cz)