Formalizace výstupu presenteru a testování presenterů
- David Grudl
- Nette Core | 8218
V Nette\Application je každý HTTP požadavek formalizován do podoby jednoduchého objektu PresenterRequest. To má celou řadu výhod, aplikace je v podstatě odstíněna od HTTP, vnitřní operace se stávají srozumitelnější. A pak je tu klíčová výhoda při testování presenterů – můžeme si vytvořit objekt presenteru, přihrávat mu různé umělé vytvořené požadavky (tj. objekty PresenterRequest) a testovat, jak na ně reaguje. Bez nutnosti jakkoliv ovlivňovat prostředí, simulovat HTTP požadavky atd.
Pravda je, že uvedené neplatí úplně na 100 %, detekce AJAXu a ovládání cache je zatím stále vázáno na HTTP vrstvu.
Uvnitř presenteru přistupovat k parametrům dotazováním se na objekt HttpRequest se považuje za hrubou chybu. Přinejmenším to znemožňuje užívání některých postupů, jako je uschovávání a obnovení požadavku pomocí metod Application::storeRequest() a restoreRequest().
Co naopak není vůbec formalizované, tak je odpověď presenteru. Žádný PresenterResponse neexistuje. Vlastně v oblasti odpovědi panuje naprostá volnost.
Ale je to na škodu? Z jednoho jediného důvodu je: vážně to totiž komplikuje výše zmíněné testování. (Nechtěl bych teď diskutovat o důležitosti automatizovaného testování presenterů, berme testovatelnost jako klíčovou vlastnost moderního kvalitního frameworku.) Je super, že presenteru mohu snadno přihrát jakýkoliv požadavek vytvořením jednoho objektu. Ale je problém ověřit jeho reakci.
Reakce presenteru v podstatě může být následující:
- vyhození výjimky při chybě
- vyhození řídící výjimky AbortException či jejího potomka:
- výjimky ForwardingException nebo RedirectingException dále zpracované dispatcherem Application
- vypsání něčeho na výstup a vyhození výjimky TerminateException
- vytvoření šablony $template a její následné vykreslení na výstup
- vytvoření datového objektu $payload a jeho následné vypsání v serializované podobě
- vytvoření šablony $template a její následná transformace do prvků objektu $payload (snippety) + opět vypsání v serializované podobě
Z hlediska testování:
- vyhozené výjimky se dají testovat snadno a dobře
- vypsaný $payload se dá zachytit a deserializovat, je tedy testovatelný, byť ne úplně elegantně
- vykreslená šablona testování prakticky znemožňuje
Jak učinit presentery dobře testovatelnými? Tím, že bude možné
otestovat přímo objekt $template, namísto jeho vykreslené podoby. Měl bych
jedno snadné a jedno nesnadné řešení, jak toho dosáhnout. To snadné je
především triviální na implementaci – stačí přidat nějakou událost,
která se zavolá těsně před vykreslením šablony v metodě
renderTemplate()
a předat ji šablonu. Testovací skript si tedy
vytvoří presenter, napíchne se na tuto událost a pak mu jen předá vstupní
PresenterRequest a sleduje, jestli vypadne výjimka, jestli se něco vypíše na
výstup a vypadne TerminateException nebo jestli se zavolá obsluha oné
události, která zkontroluje podobu objektu šablony. A zároveň vyhodí svou
výjimku, čímž zabrání následnému vykreslení šablony. Chytré a
jednoduché, co?
No a pak je tu druhá možnost – výstup presenteru formalizovat do podoby
nového objektu IPresenterResponse. Takových může existovat celá řada –
forwardovací, redirectovací, šablonu vykreslující, payload vykreslující,
prostý text vypisující nebo dokonce soubor na výstup posílající. Asi
nemusím vysvětlovat, že pro testování by tohle byl stav naprosto ideální.
Zavolat $response = $presenter->run($request);
a pak jen
ověřit, že $response je očekávaný objekt.
Implementace druhého řešení je samozřejmě mnohem složitější. Ale moc mě láká, protože jde o čistou cestu. Tak, jak je čistý vstup (PresenterRequest), tak může být čistý i výstup (IPresenterResponse). Zároveň to část odpovědnosti presenteru přesouvá pryč. A obecně, pokud je možné část odpovědnosti třídy přesunout jinam, mělo by se to udělat, protože to je známka, že původní třída byla příliš složitá.
Zkusil jsem si to nanečisto napsat a v podstatě by úprava nepředstavovala zavlečení žádné zpětné nekompatibility. Kromě situací, kdy v Presenter::shutdown() očekáváme, že bylo něco vykresleno, ať už z jakéhokoliv důvodu (kešování atd). Na druhou stranu, tyhle postupy byly vždy hack a s novými pravidly by se řešili lépe. Také by bylo fajn předělat extras PDF generátory nebo FileDownloadery, nicméně ty současné by měly fungovat stále, jen by způsobily netestovatelnost presenteru.
Co si o tom myslíte?
- jasir
- Člen | 746
Tak já bych chtěl v první řadě vyjádřit nadšení a radost ze skvělého faktu, že Nette kráčí ke ke snadné testovatelnosti. Za to velmi děkuji.
Co se týče naznačených možností – obě cesty jsou zajímavé, ale druhá je skvělá. Stav, kdy testujeme stav jednoho objektu – PresenterResponse – (a nemusíme řešit zvlášt testy na různé vyhozené Exception), je ideální. Náročnost implementace neumím posoudit, ale jsem pro možnost č.2.
- Honza Marek
- Člen | 1664
Zní to rozhodně zajímavě. Jen mi není jasné technické provedení, jak bude probíhat třeba takové přesměrování. Bude snad PresenterResponse výjimkou?
- David Grudl
- Nette Core | 8218
Myslím, že se mi to nakonec povedlo – k dispozici v poslední nightly build.
Teď by to chtělo hlavně důkladně otestovat.
- jasir
- Člen | 746
Šlape to krásně, žádné problémy jsem nezaznamenal.
Pokusil jsem se o první test a vybral jsem si klasický loginForm.
Presenter request myslím vytvářím dobře. Zatím se mi v PhpUnit nedaří
aby prošel ten první test, padá mi to
InvalidLinkException: No route for Admin:Default:default(_fid=7399)
.
Ještě přesně nevím co s tím, Environment je ‚console‘. Druhý test
už projde. Takže otázke je, jak pod ‚console‘ nastavit routy atp. aby
fungovalo vytváření linků.
<?php
class AuthPresenterTest extends MyTestCase {
public function testGoodTryLogin() {
$request = new PresenterRequest(
'Admin:Auth',
'POST',
array('action'=>'login', 'do'=>'loginForm-submit'),
array('username'=>'admin','password'=>'demo', 'login'=>'Login')
);
$presenter = new Admin_AuthPresenter($request);
$response = $presenter->run();
$this->assertType('RedirectingResponse',$response,'Good login - redirecting response');
}
public function testBadLogin() {
$request = new PresenterRequest(
'Admin:Auth',
'POST',
array('action'=>'login', 'do'=>'loginForm-submit'),
array('login-username'=>'admin','login-password'=>'baad')
);
$presenter = new Admin_AuthPresenter($request);
$response = $presenter->run();
$this->assertType('RenderResponse',$response,'Bad login - render response');
}
}
?>
- David Grudl
- Nette Core | 8218
Nějaký router by tam být měl, klidně SimpleRouter.
Tam je ještě potřeba dořešit jednu záležitost – kam si presenter
šáhne pro router. Zatím se k němu dostává přes globální volání
Environment::getApplication()->getRouter()
což je samozřejmě
špatně. Tedy pro testování to chce nastavit
Environment::getApplication()->setRouter(new SimpleRouter)
.
- David Grudl
- Nette Core | 8218
Ve frameworku žádný objekt PresenterResponse nenajdete, je tu naopak interface IPresenterResponse a k dispozici hned pětice výchozích implementací:
- RenderResponse nejčastěji užívaný response, umí vykreslit libovolný řetězec nebo objekt (např. šablonu) na výstup.
- JsonResponse přijde ke slovu v AJAXových požadavcích, vykreslí payload serializovaný do formátu JSON.
- ForwardingResponse přejde na jiný presenter, nástupce dřívější ForwardingException.
- RedirectingResponse přesměruje na jiné URL, nástupce RedirectingException.
- DownloadResponse pošle na výstup „soubor ke stažení“.
Je samozřejmě možné odeslat i svůj vlastní response, nejlépe třeba metodou terminate.
- Panda
- Člen | 569
Celý systém s IPresenterResponse
je vynikající, výborně se
s tím pracuje (a to nejen pro účely testování aplikace). Narazil jsem ale
na drobné omezení JsonResponse
– nešla by upravit, aby
nepřijímala jen třídu typu stdClass
, ale i obyčejné pole?
JsonResponse
totiž teď používám ke komunikaci
s JavaScriptovou komponentou (konkrétně jde o jsTree), které se objekt
v JSON odpovědi příčí. Sice jsem si vytvořil něco na způsob
JsonArrayResponse
, ale připadá mi to jako zbytečná
třída navíc.
- Panda
- Člen | 569
David Grudl napsal(a):
A nelze použít něco jako
new JsonResponse((object) $pole)
?
To jsem samozřejmě zkoušel, ale to se právě převede do JSON jako objekt, takže výstup vypadá nějak takto:
{
"0": {
"attributes": {"id": 1},
"state": "leaf",
"data": "Hlavn\u00ed str\u00e1nka",
"children": []
},
"1": {
"attributes": {"id": 8},
"state": "leaf",
"data": "Dokumenty",
"children": []
},
// ...
}
A to se té komponentě nelíbí, není to pole. Teď jsem ale ve zdrojáku našel callback, který ovlivňuje načítání dat, takže to pole můžu nakonec poslat zapouzdřené do objektu.