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.
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 többféleképpen 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: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:
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;
Ant – Egy nagyon remek build eszköz
CruiseControl – 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.
phpUnderControl – 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.
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 Xdebug 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):Az elkészült jelentés a report/ mappában található, és az index.html fájlon keresztül tekinthetjük meg.
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 Design by Contract-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.
Amikor tesztjeinket a termék kódja előtt készítjük el, és az eredményeik alapján dolgozunk, Test Driven Development-nek nevezzük. Gondos tervezést igé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).Ha tesztelni szeretnénk a
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
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
Így a kódot át kell írni. A megoldás kétféle lehet; Az első, hogy megvalósítunk a
A másik megoldás a dependency injection használata. A technológia lényege esetünkben, hogy aA teszt létrehoz egy úgynevezett mockot, mely megvalósít egy
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.
■ A sorozatban megjelent
- PHP osztályok egységtesztelése
- PHP osztályok egységtesztelése II
Tesztesetek szervezése
Az előző részben bemutatott tesztkódot a következőképpen futtatuk:$ phpunit arithmetic-test.php
<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>
Parancs | Hatás |
---|---|
$ phpunit | Lefut az összes tesztállomány |
$ phpunit Application_Db | Lefut a MySQL.php, PgSQL.php, CouchDB.php fájlokban található összes teszt |
$ phpunit Application_Controller | Lefut az összes tesztállomány az Application/Controllers mappában |
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;
Ant – Egy nagyon remek build eszköz
CruiseControl – 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.
phpUnderControl – 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.
Tesztek mérése: lefedettség (code coverage)
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 Xdebug 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):
$ phpunit --coverage-html ./report
Mit teszteljünk
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 Design by Contract-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.
Fejlesztés tesztelés alapján (TDD)
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, Test Driven Development-nek nevezzük. Gondos tervezést igé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).
Könnyen tesztelhető kód
Írjunk egy osztályt, ami alog()
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:<?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;
}
}
<?php
/*
* Backend/File.php
* Fájlba író napló háttértár
*/
class File
{
public function write()
{
//napló írása
}
}
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:<?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;
}
}
<?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');
}
}
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.Static metódusok és egykék (singleton)
Egyes nyelvi konstrukciók és megoldások meglehetősen nehezen egységtesztelhetők. Jó példa erre bármely statikus metódus, ugyanis ezeket nehéz mockolni, a statikus adattagok pedig az osztály reszetelé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.
Csak gyorsan átfutottam a
A Logger::getBackend() szerintem vagy ugyanúgy használjon lazy initializationt, mint az első példában és mellette legyen egy settere, vagy a backendet állítsuk be konstruktorban (és így is maradhat a setter). Ha egyszer mindenképp szükség van a backend objektumra, akkor ne lehessen elérni azt az állapotot, hogy az null és hívjuk a nemlétező metódusát.
ellenorzes DI eseten
ahogy belegondolok, az eggyel feljebb levo szintnek kell jeleznie ha sikertelen a Backend objektum letrehozasa.
En is elkezdtem refaktoralni az egyik objektumomat, amely sokadik oroklodesen esett at (nalam inkabb a kozos fuggvenyek es objektumvaltozok miatt van jonehany oroklodes). Viszont a kulonbozo Exceptionok hasznalata mar kicsit elbonyolitotta a helyzetet. Gondoltam kicsiben alkalmazom a DI-t, ezzel is megkonnyitve a tesztelest.
koszi a cikket.CI-t nem
CI-t nem biztos hogy belevettem volna ebbe a cikkbe, de ha mar igy tortent, akkor mindenkeppen emlitesre erdemes a Jenkins(nehai Hudson), illetve ha mar Jenkins es PHP, akkor nyilvan megkerulhetetlen a http://jenkins-php.org/.
A stub/mock/double/etc. kifejezesek megfelelo megerteseben segitsegul lehetnek a http://xunitpatterns.com/Mocks,%20Fakes,%20Stubs%20and%20Dummies.html oldalon talalhato tablazatok.
A kovetkezo epizodban erdemes lehet meg picit a leggyakoribb tesztelesi hibakrol beszelni (test smells), illetve ezek lehetseges megoldasairol.
Tyrael
Jó cikk. Mindössze annyit
b3ha
Loader
Kicsit öreg, kicsit...
Az aktualitása tehát így kezelendő, de a filozófia nem változott.
Mennyi időt?
Csak úgy kíváncsiságból, egy fejlesztő egy projectet átlagban kb. hány órát
szokott tesztelni? (javításokkal együtt)
Például egy paypal-on fizetős webshop-ot, adminnal, termékkezeléssel, stb.
Csak nagyságrendileg, ami "még elfogadható".
[/off]
Fele-fele
Ami a kérdésedet illeti: túl keveset. Nagyon kevés olyan hely van, ahol akár a technikai vezetés annyira átlátja ennek a fontosságát, hogy keresztülverje az üzleten az elsőre horribilisnek tűnő fejlesztői időket. Az is gond, hogy nem zöldmezős projektbe baromi nehéz bevezetni a unittesztelést, mivel egy halom kód van, amely jobbára úgy-ahogy működik és nem volt szempont a tesztelhetőség a megírásakor.
Most próbáltam ki a Microsoft
Én anno még nagyon régen azt tanultam, hogy 1 elégedetlen ügyfél kb 100 elégedettel egyenértékű, mert rossz hírét kelti a cégnek / terméknek... Pedig aztán én vagyok az, akit sosem érdekeltek a gazdasági tárgyak, a PM-ek meg azok, akik ezeket tanulták...
Negatív példa
És van rá egy nagyon jó megoldásuk, olcsóbb, mint a tisztességes tesztelés: nincs semmiféle elektronikus kontakt, a telefonos elérhetőségükön meg kizárólag hardver hibák adminisztrációjával foglalkoznak. Elgondolkodtató. Lehet, hogy ez az új trend?
Technikai vezetésnek a hosszú távú előnyöket érdemes megmutatni
Az olyan kód aminél nem volt szempont a tesztelhetőség, annál nem volt szempont az, hogy valaha valaki is hozzányúljon ahhoz a kódhoz még egyszer, nem volt szempont a csapatmunka és nem volt szempont a működő kód. (vízmérték, centi no meg szemmérték nélkül építenél-e házat??)
Ami a legfontosabb, hogy hosszú távon lesz nagy nyereség az automata teszt. Mert nem fogod tudni vele elkerülni azt, hogy újraírd az egész kódot akár – pont ez a célja, hogy megtehesd!!! –, de nem kell a teljes folyamatot újra lejátszanod, újra feltalálnod a spanyolviaszt. Ráadásul teszed ezt nagy magabiztossággal, hisz az összes hibalehetőséget ki fogja neked dobni az automata rendszer.
Az igazság, hogy ugyan olyan rossz eredményeket adó kódot fogsz írni mint az előző rendszer – ezt biztosítják a tesztek ugye, no meg ez a refaktor –, de a kódod jobban szervezett lesz, könnyebben tudsz majd benne hibát keresni és javítani, új funkciót hozzáadni, egyszóval a folyamatosan változó felhasználói igényeknek megfelelni.
És akkor arról nem is beszéltem, hogy a tesztkódok nagy részét úgy is megírod már menet közben, akkor miért ne tedd ezt úgy, hogy később is fel tudd használni. (1-2% pluszról beszélünk csak ilyenkor, hisz a teszteket megírtad, csak nem automaták) Vagy van itt valaki aki úgy kódol, hogy beírja a szövegszerkesztőbe a kódot és egyből adja át az ügyfélnek, hogy kész, csókolom tessékmáraszámlát kifizetni, az a pár apróbb hiba nemszámít? Vagy megír egy komplexebb alkalmazást és csak egyben teszteli, a részeket a kisebb osztályokat, függvényeket sose?
Indulásnál lehet, hogy sok meló lesz, mert egy új módszert, szemléletet tanulsz, de az itt befektetett energia később igencsak megtérül.
pp
Egy érdekes gondolatot
That’s true. And everybody is trying to involve the end user in the bug hunting to gain time. The development cycle must be shortened. The same thing is happening with mobile phones and cars also. They are working very hard to bring new products to the market ever faster. It doesn’t really matter anymore if the product is any good. It is only important that it’s a new version, slightly better than the previous and that it is available almost immediately after the last one. Those who are not putting out new products all the time and thus creating the impression that they are working hard on improving their products will not capture the public’s attention. Consumers forget such companies very fast.
Hát elég érdekes, én azt
Felület
Bár az egységtesztelésről szól, ahhoz, hogy "határozzuk meg a felületét", ahhoz kapcsolódhat esetleg, hogy még biztonságosabbá tehető a fenti api, ha a File osztály megvalósítana egy Backend interfészt, pl.