ugrás a tartalomhoz

PHP osztályok egységtesztelése II

erenon · 2011. Nov. 16. (Sze), 08.26
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 sorozatban megjelent

Tesztesetek szervezése

Az előző részben bemutatott tesztkódot a következőképpen futtatuk:
$ phpunit arithmetic-test.php
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:
<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>
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:

ParancsHatás
$ phpunitLefut az összes tesztállomány
$ phpunit Application_DbLefut a MySQL.php, PgSQL.php, CouchDB.php fájlokban található összes teszt
$ phpunit Application_ControllerLefut 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
Az elkészült jelentés a report/ mappában található, és az index.html fájlon keresztül tekinthetjük meg.

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 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:
<?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
    }
}
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:
<?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');
    }
}
A teszt létrehoz egy úgynevezett mockot, 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.

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.
 
1

Csak gyorsan átfutottam a

Protezis · 2011. Nov. 16. (Sze), 13.01
Csak gyorsan átfutottam a cikket, örülök, hogy ilyen témakörök is előkerülnek itt a weblaboron.

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.
5

ellenorzes DI eseten

carstepPCE · 2011. Nov. 21. (H), 02.23
kell ellenenorzes arra, hogy a setter metodus nem e null-t kapott vagy esetleg interface vagy objektumtipus alapjan ellenorizzunk?

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.
2

koszi a cikket.CI-t nem

Tyrael · 2011. Nov. 16. (Sze), 14.34
koszi a cikket.
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
3

Jó cikk. Mindössze annyit

bh · 2011. Nov. 16. (Sze), 19.50
Jó cikk. Mindössze annyit tennék hozzá, hogy a code coverage állapotáról az ide-k ügyesen tudnak riportolni vizuálisan, pl netbeans.

b3ha
4

Loader

janoszen · 2011. Nov. 16. (Sze), 23.42
Egy apró megjegyzés a PHPUnitról: én saját classloadert írtam és jópár napom ráment annak a kiderítésébe, hogy miért nem futnak le a teszteseteim. *A PHPUnit teszteseteket minden esetben a PHPUnitnak kell betöltenie!* Eszedbe ne jusson saját magad betölteni!
6

Kicsit öreg, kicsit...

erenon · 2011. Nov. 21. (H), 09.28
Örülök hogy végül megjelent ez a cikk is, pedig több, mint egy éve (!) írtam (github gist)

Az aktualitása tehát így kezelendő, de a filozófia nem változott.
7

Mennyi időt?

EL Tebe · 2011. Dec. 1. (Cs), 10.04
[off]
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]
8

Fele-fele

janoszen · 2011. Dec. 1. (Cs), 22.22
Tapasztalatom szerint a unittesztelés kb az 50%-a a melónak. Ez elsőre soknak tűnik, de megint csak tapasztalat szerint ennek sokszorosát fordítjuk hibajavításra. Így is lesznek hibák, de sokkal könnyebb lesz javítani, mert eleve sarokba szorulnak a meglevő tesztesetek miatt, nem kell végig guberálni az egész projektet.

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.
9

Most próbáltam ki a Microsoft

inf3rno · 2011. Dec. 2. (P), 00.01
Most próbáltam ki a Microsoft Pivot-ot :-) mintapéldája a nem tesztelt szoftvernek. Ott kezdődik, hogy a nyitóképernyő szürkére állítja az összes gombot rajta, és csak akkor lesznek aktívak, ha bezárom a munkalapot, és újra megnyitom... Nem gáz ez egy kicsit, hogy még el sem indult a program, már az elején elvérzett? Egyébként utána ahogy adatot vittem bele azonnal lefagyott, uh. maradt az uninstall... Ezek után vajon megéri tesztelni a programokat?!

É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...
10

Negatív példa

H.Z. v2 · 2011. Dec. 2. (P), 07.45
Hát nem is tudom... Régebben is sok bajom volt a rosszul, esetleg egyáltalán nem tesztelt szoftverek miatt, főként linuxos környezetben, de amit az új laptopom drivereivel és egyéb, gyári alkalmazásaival tapasztalok...
É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?
11

Technikai vezetésnek a hosszú távú előnyöket érdemes megmutatni

pp · 2011. Dec. 2. (P), 08.40
Ha nincsenek tesztek, soha nem fogják refaktorálni a kódot. Ezáltal gúzsba kötik a saját kezüket. Aki tesztek nélkül fejleszt az magára veszi a hibátlan munka végtelen felelősségét.

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
12

Egy érdekes gondolatot

Hidvégi Gábor · 2011. Dec. 2. (P), 18.01
Egy érdekes gondolatot találtam a tesztelésről (kiemelés tőlem):
But we now also see a new trend, especially with browsers. New versions are being deployed almost monthly. The whole thing borders on obsession.

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.
13

Hát elég érdekes, én azt

inf3rno · 2011. Dec. 2. (P), 19.00
Hát elég érdekes, én azt hittem, hogy az emberek a minőséget keresik... :-) Bár az újat is keresik, szóval ez is igaz, de szerintem ha leteszek egy használható programot az asztalra amíg mások egy kalap ..., akkor én nyertem, mert az összes ügyfél hozzám jön egy idő után, ha tudom folyamatosan hozni ezt a színvonalat... Ez hosszú távú befektetés... Persze nehéz hosszú távon gondolkodni, nekem sem mindig megy, de az üzletben szerintem elengedhetetlen. Bár az igazi pénzt itthon még mindig lopj, csalj, hazudj technikával lehet csinálni...
14

Felület

Tibcsi1003 · 2013. Ápr. 4. (Cs), 13.40
Nagyon jó cikk!

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.
interface Backend { 
    public function write();
}
Majd:
class File implements Backend { 
    public function write() {
        // napló írása
    }
}
Ezután a Loggerben előírni, hogy a setBackend csak és kizárólag Backend típusú objektumot kaphasson, így nem lehet hibás objektumot átadni, például olyat, aminek nincs write metódusa:
public function setBackend(Backend $backend) {
    $this->_backend = $backend;  
    return $this;
}
Persze enélkül is megoldható, de szerintem úgy tesztelni kellene, hogy mi történik, ha nem a megfelelő paramétert adjuk a setBackend-nek, pl. a LoggerTestben egy új testInvalidBackendArgument esetet is létre kellene hozni, de lehet hogy tévedek, mert gondolom a tesztek sikertelenek lennének, ha hibás argumentumot adunk meg.