2010-12-25

TDD utflykt i PHP land

Uppgiften är egentligen enkel. Lägg till inmatning av koordinat på en befintlig PHP-sida. På andra sidor finns motsvarande koordinatinmatning, så med lite klipp och klistra hade det varit snabbt gjort. Men gnagande känsla inom mig sa att det måste finnas ett bättre sätt. Trots att mina PHP kunskaper är begränsade så tänkte jag ta reda på om jag kunde hitta ett bättre sätt.

Efter en kort fundering kring lämplig design kom jag fram till att jag ville ha något som innebar minimalt med tillägg i de befintliga php-filerna (en kod fil och en mallaktig fil). En begränsande faktor är det enkla mallsystem som används för närvarande. Jag såg framför mig ett objekt som kunde leverera HTML som dynamiskt läggs till via mallens ordinarie substitutionsmekanism samt kunde leverera tillbaka koordinaten som värden som var enkla att stoppa in i databasen.

Jag började med att skapa en klass vars uppgift skulle vara att generera den HTML som behövs för skapa alla inmatningsfält (eventuellt redan ifyllda med ett standardvärde) samt tolka värdena i inmatningsfälten och omvandla det till en koordinat i form av två flyttal. Resultatet blev koden nedan, samt en statisk hjälpklass för lite enklare beräkningar.

<?php class Coordinate_Input { private $latitude = 0; private $longitude = 0; public function getLatitude() { return Coordinate_Helper::degMinToDouble($this->getLatDeg(), $this->getLatMin()); } private function getLatDeg() { if (isset($_POST['lat_deg'])) return $_POST['lat_deg']; return Coordinate_Helper::doubleToDeg($this->latitude); } private function getLatMin() { if (isset($_POST['lat_min'])) return $_POST['lat_min']; return sprintf("%02.3f", round(Coordinate_Helper::doubleToMin($this->latitude), 3)); } public function getLongitude() { return Coordinate_Helper::degMinToDouble($this->getLonDeg(), $this->getLonMin()); } private function getLonDeg() { if (isset($_POST['lon_deg'])) return $_POST['lon_deg']; return Coordinate_Helper::doubleToDeg($this->longitude); } private function getLonMin() { if (isset($_POST['lon_min'])) return $_POST['lon_min']; return sprintf("%02.3f", round(Coordinate_Helper::doubleToMin($this->longitude), 3)); } public function getHtml() { return '<table class="input_coord"> <tr> <td> <select name="lat_h" class="input_coord_h"> <option value="N" {lat_N_selected}>N</option> <option value="S" {lat_S_selected}>S</option> </select> </td> <td> <input type="text" name="lat_deg" maxlength="2" value="' . $this->getLatDeg() . '" class="input_coord_deg" />&deg; </td> <td> <input type="text" name="lat_min" maxlength="6" value="' . $this->getLatMin() . '" class="input_coord_min"/>\' </td> </tr> <tr> <td> <select name="lon_h" class="input_coord_h"> <option value="E" {lon_E_selected}>E</option> <option value="W" {lon_W_selected}>W</option> </select> </td> <td> <input type="text" name="lon_deg" maxlength="3" value="' . $this->getLonDeg() . '" class="input_coord_deg" />&deg; </td> <td> <input type="text" name="lon_min" maxlength="6" value="' . $this->getLonMin() . '" class="input_coord_min" />\' </td> </tr> </table>'; } public function setCoordinate($latitude, $longitude) { $this->latitude = $latitude; $this->longitude = $longitude; } } ?>

När jag kommit så här långt och börjat testa genom att mata in lite olika koordinater och laddat om sidan, så inser jag hur väldigt ineffektivt och omständligt det är. Så jag bestämmer mig för att försöka göra detta genom testdriven utveckling. Det jag främst vill åt är att kunna verifiera funktionen genom ett antal enhetstest och slippa all tidsödande manuell testning. Men jag är också nyfiken på hur koden kommer att utvecklas när testen och testbarhet får styra.

Jag utelämnar detaljerna kring enhetstestramverket, i det här fallet SimpleTest, för att försöka hålla nere på inläggets längd. Det finns säkert anledning att återkomma till det i ett senare inlägg.

Först ut att testa blev den statiska hjälpklassen. En fördel med att kunna testa en enhet i isolering är att det är mycket enklare att verifiera korrekt beteende vid randvillkor och felaktig indata. Som synes passade jag även på att flytta formateringen av minuter-delen av en koordinat till hjälpklassen.

<?php require_once('simpletest/autorun.php'); class CoordinateHelperTestCase extends UnitTestCase { function testPositiveDoubleToDeg() { $this->assertEqual(0, Coordinate_Helper::doubleToDeg(0)); $this->assertEqual(10, Coordinate_Helper::doubleToDeg(10)); $this->assertEqual(11, Coordinate_Helper::doubleToDeg(11.1)); $this->assertEqual(12, Coordinate_Helper::doubleToDeg(12.999)); $this->assertEqual(13, Coordinate_Helper::doubleToDeg(13.9999)); $this->assertEqual(14, Coordinate_Helper::doubleToDeg(14.99999)); $this->assertEqual(16, Coordinate_Helper::doubleToDeg(15.999992)); } function testPositiveDoubleToMin() { $this->assertEqual(0, Coordinate_Helper::doubleToMin(0)); $this->assertEqual(0, Coordinate_Helper::doubleToMin(10)); $this->assertEqual(30, Coordinate_Helper::doubleToMin(10.5)); $this->assertWithinMargin(33, Coordinate_Helper::doubleToMin(10.55), 0.0005); $this->assertWithinMargin(33.3, Coordinate_Helper::doubleToMin(10.555), 0.0005); $this->assertWithinMargin(59.999, Coordinate_Helper::doubleToMin(10.99999), 0.0005); $this->assertWithinMargin(0, Coordinate_Helper::doubleToMin(10.999992), 0.0005); } function testPositiveDoubleToMinString() { $this->assertEqual("00.000", Coordinate_Helper::doubleToMinString(0)); $this->assertEqual("30.000", Coordinate_Helper::doubleToMinString(10.5)); $this->assertEqual("13.333", Coordinate_Helper::doubleToMinString(11.2222222)); $this->assertEqual("26.667", Coordinate_Helper::doubleToMinString(12.4444444)); $this->assertEqual("59.999", Coordinate_Helper::doubleToMinString(13.99999)); $this->assertEqual("00.000", Coordinate_Helper::doubleToMinString(14.999992)); } function testDegMinToDouble() { $this->assertEqual(0, Coordinate_Helper::degMinToDouble(0, 0)); $this->assertEqual(10, Coordinate_Helper::degMinToDouble(10, 0)); $this->assertWithinMargin(10.333333, Coordinate_Helper::degMinToDouble(10, 20), 0.0001); $this->assertWithinMargin(10.5, Coordinate_Helper::degMinToDouble(10, 30), 0.0001); } } ?>

När det mest grundläggande är på plats så blev målsättningen att försöka få så mycket som möjligt av koden under test. Klassen Coordinate_Input har för många ansvarsområden, så därför delade jag upp den i två delar, Coordinate_Presenter och Coordinate_View (namnen kommer från Model-View-Presenter, MVP, men jag vet egentligen inte om de passar i sammanhanget). Tanken är att Coordinate_View ska ansvara för skapa HTML:en för inmatningsfälten. Just den delen verkar vara svår att enhetstesta. Så istället för att ödsla tid på att fundera ut något sätt att lösa problemet, väljer jag att skjuta upp det så länge. Men genom att göra vyn så simpel som möjligt så kanske det inte är kritiskt att enhetstesta den.

Detta innebar att det blir Coordinate_Presenters ansvar att ge vyn färdigformatterade värden så vyn kan visa dem utan modifiering. Den typen av beteende är enkel att testa genom att helt enkelt låta testklassen utge sig för att vara vyn och verifiera att den mottager korrekta värden från presentatören (ibland har detta det föga beskrivande namnet Self Shunt). I ett starkt typat språk hade det varit naturligt att definiera vyn genom att deklarerar ett gränssnitt, men eftersom PHP är dynamiskt typat såg jag ingen anledning till att göra det. Nedan följer delar av testklassen.

class Coordinate_PresenterTests extends UnitTestCase { private $lat_deg = 0; private $lat_min = 0; private $lon_deg = 0; private $lon_min = 0; public function setLatitude($deg, $min) { $this->lat_deg = $deg; $this->lat_min = $min; } public function setLongitude($deg, $min) { $this->lon_deg = $deg; $this->lon_min = $min; } function testPresenterPreparesView() { $presenter = new Coordinate_Presenter(); $presenter->init(1.33, 123.3456); $presenter->prepare($this); $this->assertIdentical('01', $this->lat_deg); $this->assertIdentical('19.800', $this->lat_min); $this->assertIdentical('123', $this->lon_deg); $this->assertIdentical('20.736', $this->lon_min); } }

Det föll sig naturligt att skapa en värdeklass för koordinat och flytta över koden från den statiska hjälpklassen till värdeklassen. Förutom de existerande testen så la jag till ytterligare några som verifierade att undantag kastades vid felaktig indata.

En annan sak jag gjorde för att underlätta enhetstestandet var att skapa en Request-klass som lindar in de globala variablerna POST och GET. Då kunde jag dessutom göra det möjligt att ange ett standardvärde om motsvarande POST-värde inte var satt. Request-objektet initieras med POST eller GET om det finns.

<?php class Http_Request { private $key_values = array(); public function __construct() { if (!empty($_POST)) $this->key_values = $_POST; else if (!empty($_GET)) $this->key_values = $_GET; } public function get($key, $value = '') { if (array_key_exists($key, $this->key_values)) return $this->key_values[$key]; return $value; } public function set($key, $value) { $this->key_values[$key] = $value; } } ?>

Testen av Coordinate_Presenter utökades för att verifiera att den hämtade värden från Request-objektet.

class Coordinate_PresenterTests extends UnitTestCase { private function createRequest($lat_deg, $lat_min, $lon_deg, $log_min) { $request = new Http_Request(); $request->set(Coordinate_Presenter::lat_deg, $lat_deg); $request->set(Coordinate_Presenter::lat_min, $lat_min); $request->set(Coordinate_Presenter::lon_deg, $lon_deg); $request->set(Coordinate_Presenter::lon_min, $log_min); return $request; } function testLatLonCanBeSet() { $presenter = new Coordinate_Presenter(); $presenter->init(1, 2); $this->assertEqual(Coordinate_Coordinate::create(1, 2), $presenter->getCoordinate()); } function testLatLonAreReadFromRequest() { $request = $this->createRequest(2, 33, 3, 45); $presenter = new Coordinate_Presenter($request); $this->assertEqual(Coordinate_Coordinate::create(2.55, 3.75), $presenter->getCoordinate()); } function testLatLonAreSetByRequestNotInit() { $request = $this->createRequest(2, 33, 3, 45); $presenter = new Coordinate_Presenter($request); $presenter->init(1, 2); $this->assertEqual(Coordinate_Coordinate::create(2.55, 3.75), $presenter->getCoordinate()); } }

Resultatet av den testdrivna designen så här långt syns nedan.

class Coordinate_Presenter { const lat_deg = 'lat_deg'; const lat_min = 'lat_min'; const lon_deg = 'lon_deg'; const lon_min = 'lon_min'; private $coordinate; private $request; public function __construct($request = false) { $this->request = $this->initRequest($request); $this->init(0, 0); } private function initRequest($request) { if ($request) return $request; return new Http_Request(); } private function getLatDeg() { return $this->request->get(self::lat_deg, $this->coordinate->latDeg()); } private function getLatMin() { return $this->request->get(self::lat_min, $this->coordinate->latMin()); } private function getLonDeg() { return $this->request->get(self::lon_deg, $this->coordinate->lonDeg()); } private function getLonMin() { return $this->request->get(self::lon_min, $this->coordinate->lonMin()); } public function init($latitude, $longitude) { $this->coordinate = Coordinate_Coordinate::create($latitude, $longitude); } public function prepare($view) { $view->setLatitude($this->getLatDegString(), $this->getLatMinString()); $view->setLongitude($this->getLonDegString(), $this->getLonMinString()); } private function getLatDegString() { return sprintf("%02d", $this->getLatDeg()); } private function getLatMinString() { return sprintf("%06.3f", $this->getLatMin()); } private function getLonDegString() { return sprintf("%03d", $this->getLonDeg()); } private function getLonMinString() { return sprintf("%06.3f", $this->getLonMin()); } public function getCoordinate() { return Coordinate_Coordinate::fromDegMin($this->getLatDeg(), $this->getLatMin(), $this->getLonDeg(), $this->getLonMin()); } }
class Coordinate_View { private $lat_deg; private $lat_min; private $lon_deg; private $lon_min; public function setLatitude($deg, $min) { $this->lat_deg = $deg; $this->lat_min = $min; } public function setLongitude($deg, $min) { $this->lon_deg = $deg; $this->lon_min = $min; } public function getHtml($presenter) { $presenter->prepare($this); return '<table class="input_coord"> <tr> <td> <select name="lat_h" class="input_coord_h"> <option value="N" {lat_N_selected}>N</option> <option value="S" {lat_S_selected}>S</option> </select> </td> <td> <input type="text" name="lat_deg" maxlength="2" value="' . $this->lat_deg . '" class="input_coord_deg" />&deg; </td> <td> <input type="text" name="lat_min" maxlength="6" value="' . $this->lat_min . '" class="input_coord_min"/>\' </td> </tr> <tr> <td> <select name="lon_h" class="input_coord_h"> <option value="E" {lon_E_selected}>E</option> <option value="W" {lon_W_selected}>W</option> </select> </td> <td> <input type="text" name="lon_deg" maxlength="3" value="' . $this->lon_deg . '" class="input_coord_deg" />&deg; </td> <td> <input type="text" name="lon_min" maxlength="6" value="' . $this->lon_min . '" class="input_coord_min" />\' </td> </tr> </table>'; } }

Det som återstår nu är hantering av N/S resp. E/W delen i koordinaten samt validera de inmatade värdena. Men det får bli som en uppföljning till det här inlägget.

Inga kommentarer: