2011-02-06

TDD utflykt i PHP land, del 3

Det har blivit anledning att göra en ny utflykt. Den här gången handlar det om att lägga till funktionen extra punkter. En cache kan ha ett godtyckligt antal extra punkter knutna till sig. Det påminner lite om det som gjorts tidigare då inmatning av en koordinat är centralt även här. Skillnaden är att det ska göras på en egen sida. Dessutom kommer sidan göras med Smarty.


Det enklaste sättet att göra något sådant här, särskilt om man inte gjort det tidigare, är att ta en befintlig sida (en php-fil och en mallfil) som gör något liknande och modifiera den tills det att man har fått det man vill. Jag började med det också, mest för att få mall-filen på plats. Jag ignorerade logiken i php-filen och ersatte variablerna med hårdkodade värden.

Tanken är att samma sida ska kunna användas för att både lägga till nya punkter och redigera befintliga punkter. Efter lite experimenterande så hade jag följande utkast till mall. ({t} och {/t} är ett tillägg till Smarty som hanterar översättning.)

<form action="childwp.php" method="post"> <div class="content2-pagetitle"> {$pagetitle|escape} </div> <table> <tr> <td>{t}Waypoint type:{/t}</td> <td><select name="wp_type"> {html_options values=$wpTypeIds output=$wpTypeNames selected=$wpType} </select></td> </tr> <tr> <td>{t}Coordinate:{/t}</td> <td>{coordinput prefix="coord" lat=$wpLat lon=$wpLon}</td> </tr> <tr> <td>{t}Description:{/t}</td> <td><textarea name="desc" rows="5" cols="60">{$wpDesc}</textarea></td> </tr> <tr> <td></td> <td> <button type="submit" name="back" value="back">{t}Cancel{/t}</button> <button type="submit" name="submitform" value="submit">{t}Submit{/t}</button> </td> </tr> </table> </form>

Som synes en tämligen okomplicerad historia. Värt att notera är {coordinput} som också är ett tillägg till Smarty. Detta tillägg fanns redan i den befintliga kodbasen, så jag tänkte använda det och se vad som händer. Det gör en del av det som min tidigare utvecklade Coordinate_Presenter och Coordinate_View gör (se del 1 och del 2). Även om tillägget innehåller lite för mycket logik för min smak.

Just det här att hålla nere mängden logik i mall-filen är något jag tänker sträva efter. Bara för att man kan lösa ett problem med logik i mallen så betyder det inte att det är det bästa sättet att göra det på. Som exempel kan jag nämna att mallen som jag utgick ifrån innehåller logik för att anpassa rubriken på sidan beroende på om det handlar om lägga till ny eller redigera befintlig. Det är helt rätt att rubriken ska variera beroende på sammanhang, men jag tycker inte det är mallens uppgift att ta hand om det.

Dags att börja TDD:a. Jag tänker mig en presentatör (Presenter) som förser mallen (som får agera vy) med den information som ska visas. Första testfallet blir att verifiera att rätt rubrik sätts. Ibland kan det vara svårt att få till ett testfall. Då brukar det underlätta om man börjar bakifrån.

<?php require_once('simpletest/autorun.php'); class ChildWp_PresenterTests extends UnitTestCase { function testSetPageTitle() { $this->assertEqual('Add waypoint', $template->get('pagetitle')); } } ?>

Testfallet säger att om allt har gått bra så har mallobjektet fått strängen "Add waypoint" associerat med "pagetitle". Så vad är mallobjektet för något? Jag vet inte säkert, men det ligger nära till hands att anta att det är ett Smarty-objekt. Men samtidigt känns det onödigt att använda riktiga Smarty-objekt i testfallen. Dessutom saknar Smarty-objektet en get()-metod. Jag tänker dra nytta av att PHP inte är ett starkt typat språk och skapa en egen klass som både går och står som ett Smarty-objekt (detta brukar kallas för Duck typing).

class MockTemplate { private $values = array(); public function assign($tpl_var, $value) { $this->values[$tpl_var] = $value; } public function get($tpl_var) { return $this->values[$tpl_var]; } }

Nu går det i alla fall att köra testfallet. Självklart så rapportera SimpleTest ett fel (egentligen två), men det är precis vad vi vill ha.

function testSetPageTitle() { $template = new MockTemplate(); $this->assertEqual('Add waypoint', $template->get('pagetitle')); }

Nu saknas bara presentatören, vars uppgift är att sätta rätt värde. Jag har inte funderat på ett lämpligt gränssnitt, utan det får växa fram allteftersom jag lägger till nya testfall. En första variant syns nedan.

function testSetPageTitle() { $template = new MockTemplate(); $presenter = new ChildWp_Presenter(); $presenter->prepare($template); $this->assertEqual('Add waypoint', $template->get('pagetitle')); }

Presentatören i sin första tappning blir väldigt simpel.

class ChildWp_Presenter { public function prepare($template) { $template->assign('pagetitle', 'Add waypoint'); } }

Man kan ifrågasätta nyttan med att testa att rubriken sätts till "Add waypoint". Främsta syftet med testet är att på enklast möjliga sätt komma igång och få testfall och klassen som ska testas på plats. Nu när det är gjort är det dags att fortsätta. Nästa test blir att verifiera att rätt koordinat sätts. Jag väljer detta för att testfallet är enkelt att skriva.

function testSetZeroCoordinate() { $template = new MockTemplate(); $presenter = new ChildWp_Presenter(); $presenter->prepare($template); $this->assertEqual(0, $template->get('wpLat')); $this->assertEqual(0, $template->get('wpLon')); }

Presentatören är fortfarande väldigt enkel.

class ChildWp_Presenter { public function prepare($template) { $template->assign('pagetitle', 'Add waypoint'); $template->assign('wpLat', 0); $template->assign('wpLon', 0); } }

Hantering av punktens beskrivning görs på samma sätt.

Det sista i mallen som ska sättas är punktens typ. Men detta verkar komplicerat, så jag skjuter upp problemet i hopp om att det ska bli lättare att lösa längre fram. Istället tänker jag ta itu med det faktum att rubriken ska översättas. Eftersom jag löste problemet i del 2 så tänker jag hämta inspiration därifrån.

function testPageTitleIsTranslated() { $template = new MockTemplate(); $translator = new MockLanguage_Translator(); $translator->setReturnValue('translate', 'Add new waypoint'); $translator->expectOnce('translate', array('Add waypoint')); $presenter = new ChildWp_Presenter($translator); $presenter->prepare($template); $this->assertEqual('Add new waypoint', $template->get('pagetitle')); }

Jag utnyttjar mock-funktionaliteten i SimpleTest och låter den generera en mock-klass av Language_Translator. Testfallet verifierar att presentatören hämtar översättning av "Add waypoint" från översättarobjektet samt skickar resultatet till mallobjektet. Med inspirationen så blir presentatören så här.

class ChildWp_Presenter { private $translator; public function __construct($translator = false) { $this->translator = $this->initTranslator($translator); } private function initTranslator($translator) { if ($translator) return $translator; return new Language_Translator(); } public function prepare($template) { $template->assign('pagetitle', $this->translator->Translate('Add waypoint')); $template->assign('wpLat', 0); $template->assign('wpLon', 0); } }

Nu fyller inte det första testfallet något syfte längre, så jag tar bort det.

När användaren har fyllt i all information och trycker på Lägg till så ska en ny punkt skapas. Detta låter som ett lämpligt nästa testfall. När ett formulär postas tillbaka så går informationen från formuläret att komma åt via $_POST[]. Eftersom jag redan har skapat en klass för hantering av informationen så tänker jag använda den. Mitt första försök såg ut så här.

function testChildWpIsAdded() { $request = new Http_Request(); $childWpHandler = new MockChildWp_Handler(); $request->set('wp_type', 1); $request->set('coordNS', 'N'); $request->set('coordLat', '10'); $request->set('coordLatMin', '15'); $request->set('coordEW', 'E'); $request->set('coordLon', '20'); $request->set('coordLonMin', '30'); $request->set('desc', 'my waypoint'); $childWpHandler->expectOnce('add', array(1, 10.25, 20.5, 'my waypoint')); $presenter = new ChildWp_Presenter($request); $presenter->addWaypoint($childWpHandler); } class ChildWp_Handler { public function add($type, $lat, $lon, $desc) { } }

Förhållandevis mycket kod i testfallet. Men det mesta har att göra med skapa ett Request-objekt. Dessutom skulle man kunna dölja det med en hjälpmetod. Det som stör mig är den lösa kopplingen mellan variablerna i Request-objektet och deras motsvarande inmatningsfält som skapas av coordinput, men det får vara tills vidare.

En sak att lägga märke till är att jag skickar in Request-objektet som första parameter i konstruktorn, där jag tidigare skickat in ett Translator-objekt. Anledningen är att jag gör på samma sätt som i presentatören för koordinat (dvs Translator-objekt som parameter två). Genom att göra på samma sätt hoppas jag senare kunna extrahera en gemensam basklass.

Något jag inte har funderat på än så länge är hur skapandet av en punkt ska göras. Vem initierar det och vem utför det? Min ansats är att någon talar om för presentatören att göra det med hjälp av en "hanterare" (i brist på bättre namn). Själva skapandet verifieras genom att hanteraren blir anropad med rätt parametrar.

Implementationen syns nedan. De olika get-funktionerna hämtar relevant information ur Request-objektet. Hanteringen av typ och beskrivning är lätt. Däremot är koordinaten betydligt svårare, så jag löste det genom att helt enkelt "fuska" lite och hårdkoda värdena. Det känns som att nästa steg blir att ta hand koordinaten.

class ChildWp_Presenter { ... public function addWaypoint($childWpHandler) { $childWpHandler->add($this->getType(), $this->getLat(), $this->getLon(), $this->getDesc()); } private function getType() { return $this->request->get('wp_type'); } private function getLat() { return 10.25; } private function getLon() { return 20.5; } private function getDesc() { return $this->request->get('desc'); } }

Det är fullt möjligt att ta det hela vägen och verifiera genom att kontrollera att punkten blir skapad i databasen. Men då är det inte längre enhetstest, utan snarare integrationstest. Det blir också mer komplicerat att sätta upp rätt förutsättningar och, inte minst, städa upp efter sig. Om det inte görs på korrekt sätt finns det risk att testfallen blir godkända eller underkända på grund av gammal data i databasen.

Däremot tycker jag integrationstest är viktiga. Och kan man bara komma över den första, ofta höga, tröskeln, så har man ett mycket användbart verktyg som kan fånga många av de fel som är svåra att hitta med enhetstest. Och just i det här fallet hade det varit bra, för testfallet ovan missar en viktig sak. Men vad det är återkommer jag till i nästa inlägg.

Inga kommentarer: