Skip to content

Instantly share code, notes, and snippets.

@erenon
Created August 26, 2010 18:25
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save erenon/551907 to your computer and use it in GitHub Desktop.
Save erenon/551907 to your computer and use it in GitHub Desktop.
PHP osztályok egységtesztelése II
PHP osztályok egységtesztelése II
=================================
Cikkünk első részében megismerkedtünk az egységtesztelés alapjaival, felmértük a fontosságát, ismertettük előnyeit és hátrányait. A folytatásban bemutatásra kerül egy egyszerű teszteset szervezés, valamint átfogóbban tárgyaljuk a könnyen tesztelhető kód főbb jellemzőit.
[h3]Tesztesetek szervezése[/h3]
Az előző részben bemutatott tesztkódot a következőképpen futtatuk:
[colorer=cmd]$ phpunit arithmetic-test.php
[/colorer]
A fenti parancs beolvasta a megadott fájlt, és lefuttatta a benne található tesztet. Könnyen beláthatjuk, hogyha egynél több tesztkódot tartalmazó fájlunk van, hamar kényelmetlenné válik a teljes kódbázis tesztelése. A tesztelés indítását [url=http://www.phpunit.de/manual/current/en/organizing-tests.html]többféleképpen[/url] is megkönnyíthetjük. Röviden bemutatok egy lehetséges XML konfigurációt. Tételezzünk fel egy phpunit.xml fájlt a tesztelni kívánt project gyökerében (vagy más, tetszőleges helyen, pl.: tests/) a következő tartalommal:
[colorer=xml]
<phpunit>
<testsuites>
<testsuite name="Application_Db">
<file>Application/Db/MySQL.php</file>
<file>Application/Db/PgSQL.php</file>
<file>Application/Db/CouchDB.php</file>
</testsuite>
<testsuite name="Application_Controller">
<directory>Application/Controllers/</directory>
</testsuite>
</testsuites>
</phpunit>
[/colorer]
Amennyiben léteznek a megadott tesztfájlok, a konfigurációs állománnyal azonos mappában állva a különböző parancsok hatásai a következőek:
[table]
[row][hcell]Parancs[/hcell][hcell]Hatás[/hcell][/row]
[row][cell]$ phpunit[/cell][cell]Lefut az összes tesztállomány[/cell][/row]
[row][cell]$ phpunit Application_Db[/cell][cell]Lefut a MySQL.php, PgSQL.php, CouchDB.php fájlokban található összes teszt[/cell][/row]
[row][cell]$ phpunit Application_Controller[/cell][cell]Lefut az összes tesztállomány az Application/Controllers mappában[/cell][/row]
[/table]
Tesztjeinket okosan csoportosítva megkímélhetjük magunkat felesleges tesztek futtatásától és a túl sok gépeléstől is, amit az összetartozó tesztek egymásutáni futtatása okozna.
Lehetőségünk van tesztjeinket automatikusan, időzítve vagy más eseményhez kötve futtatni, beépíteni a build folyamatba, ehez több eszköz is rendelkezésünkre áll, a teljesség igénye nélkül felsorolnék néhányat;
[url=http://ant.apache.org/]Ant[/url] - Egy nagyon remek build eszköz
[url=http://cruisecontrol.sourceforge.net/]CruiseControl[/url] - Antra épülő CI (continuous integration) szerver, képes figyelni a VCS-t, és commitkor buildelni, az eredmények webes felületen keresztül tekinthetőek meg.
[url=http://phpundercontrol.org/]phpUnderControl[/url] - Gyakorlatilag a CruiseControl egy sminkje PHP eszközökre kihegyezve.
Sajnos a fenti eszközök konfigurációja a cikk hatáskörén kívül esik, de üzembe állítható mindhárom kizárólag a phpUnderControl útmutatója alapján, habár az anttal egyébként is érdemes megismerkedni. Mindig mérjük fel, hogy tényleg szükségünk van-e CI szerverre. Az Ant önmagában sokat lendít a komfortérzeten, mellé gyakran elég, ha csak a VCS rendszerünk hurkait (hook) használjuk.
[h3]Tesztek mérése: lefedettség (code coverage)[/h3]
Csupán abból, hogy tesztjeink hibátlanul lefutnak, nem juthatunk arra a következtetésre, hogy kódunk tökéletes. Több probléma is felmerülhet:
* Nem teszteljük a kód teljes egészét, ami kimarad, hibás lehet
* Lehet a teszt is hibás, valójában hibás a kód, mégis lefut a teszt
* Lehet, hogy a teszt egy hiba folytán részben vagy teljesen függetlenedik a kódtól
Az első probléma javítására szolgál a code coverage report. A jelentés alapján megtudhatjuk, hogy mely kódsorok kerültek végrehajtásra a tesztelés során. Amik kimaradtak, azok vagy feleslegesek, vagy újabb tesztesetre szorulnak, például egy elágazás else ága. Fontos megjegyezni, hogy 100%-os lefedettség sem jelent tökéletes kódot (lásd fent), valamint attól, hogy egy sor végrehajtódik a tesztelés során, még korántsem biztosan hibátlan. Például a $a/$b, az előző részben.
A phpunit lehetővé teszi az [url=http://xdebug.org/]Xdebug[/url] php extension segítségével ilyen jelentések készítését. Egy könnyen olvasható változat elkészítéséhez használjuk a következő parancsot (feltételezve egy phpunit.xml jelenlétét):
[colorer=cmd]$ phpunit --coverage-html ./report
[/colorer]
Az elkészült jelentés a report mappában található, és az index.html fájlon keresztül tekinthetjük meg.
[h3]Mit teszteljünk[/h3]
Felmerülhet a kérdés tesztjeink írása során, hogy egy osztály mely részére fókuszáljunk, milyen egységekben teszteljük azt. Könnyű elveszni a részletekben, és ha nem terv szerint haladunk, gyorsan változó, a működést tekintve lényegtelen részeket tesztelhetünk.
Fontos, hogy mielőtt elkészítünk egy osztályt, határozzuk meg a felületét, amit akár egy interfaceben (felület) rögzíthetünk. Ez gondos tervezést ígényel, de nagy előnyökkel jár. A felület csupán a publikus metódusokat (és esetleges mezőket) tartalmazza, a privátokat nem! A felület mutatja meg, hogy az osztály "felhasználói" milyen úton kommunikálhatnak az osztállyal. Miután kialakultak a metódusaink, határozzuk meg a bemenő paramétereit. Ezeket kell biztosítania a hívó félnek, a felületben rögzített formában. Ezután állapítsuk meg, hogy mi az az érték vagy struktúra, amit a metódus biztosít visszatéréskor. Ezzel dolgozik majd tovább a hívó kód. (Megjegyzés: esetenként előfordulhat, hogy a bemeneti és kimeneti értékeken kívül más tényezőket is figyelembe kell vennünk, például a globális tér vagy egy háttrétár változásait. Ezeket a dokumentációban tudjuk rögzíteni, a felületben nem, de ezután ugyanúgy tesztelhetjük őket) Ha a tervezés során előre meghatározzuk, hogy egy metódusnak milyen bemeneti értékekre milyen kimenettel kell válaszolnia, azt [url=http://en.wikipedia.org/wiki/Design_by_contract]Design by Contract[/url]-nak nevezzük.
Ezután a tesztelés már egyértelművé válik. Mindig a felületet kell tesztelni, soha nem az osztály belső állapotát. Az magánügy. Amíg egy osztály a tesztek alapján teljesíti a contractban (szerződés) vállalt feladatait, addig jól működik, implementációs kérdések rögzítésére nincs szükség az egységtesztekben.
[h3]Fejlesztés tesztelés alapján (TDD)[/h3]
Ha az előbbiekben leírtakat követjük, szinte logikusan következik az újabb lehetőség. Vannak szabályaink (szerződéseink, contract) az osztályok működésére, és a tesztjeink gondoskodnak a betartásukról. Miért ne írhatnánk meg előre a teszteket? Akkor csupán a hibázó tesztek mentén haladva kéne implementálnunk az előre meghatározott felületeket, mindig tiszta képet kapnánk arról, hogy mikor vagyunk készen, és merre tovább.
Amikor tesztjeinket a termék kódja előtt készítjük el, és az eredményeik alapján dolgozunk, [url=http://en.wikipedia.org/wiki/Test-driven_development]Test Driven Development[/url]-nek nevezzük. Gondos tervezést ígyényel, de később nagyon pontos munkát tesz lehetővé, valamint még az osztály lefejlesztése előtt kibukik, ha annak felülete nehezen használható, vagy nem működik egységként (ugyanis ekkor problémáink akadnak a tesztírással).
[h3]Könnyen tesztelhető kód[/h3]
Írjunk egy osztályt, ami a log() metódusán keresztül átadott karakterláncot egy tetszőleges háttértárolóra menti, például fájlba írja vagy adatbázisban tárolja. Láthatjuk, hogy a bejegyzés konkrét elmentése a log() függvényt megvalósító Logger osztály szintjétől különböző absztrakciós szinten történik, tehát a write() metódust, mely a konkrét írást végzi, hátterenként más és más osztály valósíthatja meg. A két osztály röviden:
[colorer=php]
<?php
require_once 'Backend/File.php';
/*
* Logger.php
* Naplózó műveleteket támogató osztály, különböző háttértárolókkal
*/
class Logger
{
private $_backend;
public function Log($message)
{
$this->getBackend()->write($message);
}
public function getBackend()
{
if (null === $this->_backend) {
$this->_backend = new Backend_File();
}
return $this->_backend;
}
}
[/colorer]
[colorer=php]
<?php
/*
* Backend/File.php
* Fájlba író napló háttértár
*/
class File
{
public function write()
{
//napló írása
}
}
[/colorer]
Ha tesztelni szeretnénk a log() helyes működését, a következőt kell tennünk; mindenképpen be kell hívni a Logger és Backend_File osztályokat, meg kell hívni a log() metódust, majd ellenőrizni kell a naplófájlt. (Majd azt el is kell távolítani, hogy az esetleges további tesztek zavartalanul működhessenek.)
Könnyen beláthatjuk, hogy az egységtesztelés elve sérült. Két osztályt is felhasználunk, valamint a fájlrendszert is piszkáljuk, pedig csak arra vagyunk kíváncsiak, hogy a log() meghívja-e a háttér write() metódusát, a megfelelő paraméterrel.
A probléma megoldása lehetne, hogy egy speciális teszthátteret biztosítunk (Mock), mely ellenőrzi a hívást, ez viszont nem lehetséges, mert a Logger osztályban rögzítve van a háttértár neve. (Lényegtelen, hogy ez a név hardcoded, vagy konfigurációs állományból nyert, a probléma fennáll)
Így a kódot át kell írni. A megoldás kétféle lehet; Az első, hogy megvalósítunk a Logger osztályban egy chooseBackend($backendName) metódust, mely választ a rendelkezésre álló hátterek közül, és bitosítunk egy Test_Backendet is. Ennek a megoldásnak a hátránya azon túl, hogy csúnya, az, hogy a teszt kódot kevernünk kell a termék kódjával, ami felesleges rendetlenséghez vezet.
A másik megoldás a Dependency Injection használata. A technológia lényege esetünkben, hogy a Logger osztály nem maga példányosítja a háttértárat, hanem egy kész példányt kap, melynek csak a felületét ismeri. Így a kód könnyen tesztelhetővé válik:
[colorer=php]
<?php
/*
* Logger.php
* Naplózó műveleteket támogató osztály, különböző háttértárolókkal
*/
class Logger
{
private $_backend;
public function Log($message)
{
$this->getBackend()->write($message);
}
public function setBackend($backend)
{
$this->_backend = $backend;
return $this;
}
public function getBackend()
{
return $this->_backend;
}
}
[/colorer]
[colorer=php]
<?php
/*
* logger-test.php
*/
require_once 'Logger.php';
/**
* Az Logger osztály műveleteit tesztelő metódusok osztálya
*/
class LoggerTest extends PHPUnit_Framework_TestCase
{
public function testLog()
{
$backend = $this->getMock('Backend_File', array('write'));
$backend->expects($this->once())
->method('write')
->with('foobar');
$logger = new Logger();
$logger->setBackend($backend);
$logger->log('foobar');
}
}
[/colorer]
A teszt létrehoz egy úgynevezett [url=http://www.phpunit.de/manual/current/en/test-doubles.html#test-doubles.mock-objects]Mock[/url]-ot, mely megvalósít egy write metódust, és figyeli annak hívásait. A dublőrt úgy állítjuk be, hogy sikeres tesztet eredményezzen, ha a write metódus pontosan egyszer kerül meghívásra, a 'foobar' paraméterrel. Ezután a Logger osztályunknak adjuk, mintha csak egy backend lenne, majd megpróbáljuk naplózni a 'foobar' karakterláncot. Ha minden megfelelően működik, akkor jutalmunk a sikeres tesztet jelző pont lesz.
[h3]Static metódusok és egykék (singleton)[/h3]
Egyes nyelvi konstrukciók és megoldások meglehetősen nehezen egységtesztelhetők. Jó példa erre bármely static metódus, ugyanis ezeket nehéz mockolni, a static property-k pedig az osztály resetelését igényelhetik, ami ugyancsak kevéssé elegáns (de legalább megvalósítható).
Másik problémás technika a klasszikus Singleton minta lehet. Az osztály maga tartalmazza a saját példányát, így megnehezíti a különböző tesztek függetlenítését. Gondoljuk meg kétszer, hogy használunk-e egykéket, és mielőtt tényleg megvalósítanánk, fontoljuk meg valamely Registry minta használatát. Kizárólag teljesítményoptimalizálás miatt felesleges egykékkel bajlódni, valószínűleg nem a példányosítás lesz a szűk keresztmetszet, amit megspórolunk vele.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment