Bevezetés, alapismeretek
A Laravel könyvtárstruktúrája alapból tartalmaz egy tests nevű könyvtárat. Ebben két alkönyvtár helyezkedik el, a Feature és a Unit.
A Feature-re gondoljunk úgy, mint ha egy funkcionalitást akarnánk
implementálni és utána (vagy előtte) ezt szeretnénk tesztelni a
projektünkben. Mondjuk az a funkcionalitásunk, hogy el akarunk adni
zenéket az oldalunkon, és ezt szeretnénk végigtesztelni: (1) csak
regisztrált felhasználók vásárolhatnak, (2) csak bizonyos termékeket
vehetnek, (3) a fizetési folyamatot végigkövetve menedzselnénk és
tesztelnénk a lépéseket stb. A UnitTest egy sokkal
jobban izolált, leválasztott tesztelési lehetőséget ad nekünk az alsóbb
szinteken. Mondjuk adott class adott funkcióját teszteljük ezen a
szinten. Tehát a tesztelést úgy képzeljük el, mint ha kívülről befelé
(outside → in) haladnánk, Feature-től indulunk majd utána a Unit teszt
következik és ez úgy néz ki, hogy az adott osztály adott függvényét
akarjuk tesztelni azon módon, hogy mondjuk milyen lefutási eredménnyel
tér vissza… és ide-oda lépkedünk a két szint között, amíg hibákat
kapjuk.
A Unit test, vagy más néven egységteszt, a metódusokat
teszteli. Adott paraméterekre ismerjük a metódus visszatérési értékét
(vagy mellékhatását). A Unit test megvizsgálja, hogy a tényleges
visszatérési érték megegyezik-e az elvárttal: ha igen, sikeres a teszt,
egyébként sikertelen. Elvárás, hogy magának a Unit test-nek ne legyen
mellékhatása. A Unit test-elést minden fejlett programozási környezet
(Integrated Development Environment, IDE) támogatja, azaz egyszerű ilyen
teszteket írni. A jelentőségüket az adja, hogy a futtatásukat is
támogatják, így egy változtatás után csak lefuttatjuk az összes Unit
test-et, ezzel biztosítjuk magunkat, hogy a változás nem okozott hibát.
Ezt a folyamatot nevezzük regressziós tesztnek.
Mivel a projektünk
már tartalmaz is a tests könyvtáron belül (és almappáiban) ExampleTest
osztályokat, ezért azok már futtathatók is. Említettem, hogy a Laravel a
PHPUnit csomagot használja tesztelésre. Ez a projektünkben a vendor /
bin / phpunit mappában található meg. Futtassuk is le a parancsot:
vendor/bin/phpunit
Ennek hatására láthatjuk, hogy a PHPUnit melyik verziója van telepítve és ami még fontosabb jelen esetben: a két említett osztály és annak tesztelő metódusai végre fognak hajtódni. Szerencsére zöld OK-ot kapunk vissza, tehát lefutnak hibamentesen ezek a tesztjeink (láthatjuk még a lefutás idejét és hogy mennyi memóriát használt ehhez). Azt is láthatjuk még, hogy 2 tesztünk futott le (ezt jelzi a két darab . is) és a tesztmetódusokon belül 2 "assertions". Ha hibát kaptunk volna, akkor az adott teszt helyén a . helyett piros hátterű F betűt kapnánk.
Az assert utasítás segítségével fogalmazhatunk meg programunk egy pontján egy olyan logikai állítást, amelynek igaznak kell(ene) lennie. Az assert utasítások a szemantikát és a teljesítményt illetően is az üres utasítással egyenértékűek. Ha az állítás valóban igaz, az assert utasítás hatástalan, ellenkező esetben egy Assertion hiba következik be. A Laravel 9-ben elérhető assert utasítások itt érhetők el.
Mivel most sokszor fogjuk használni a tesztelést, érdemes létrehozni egy alias nevet, amivel a fenti hosszú parancsot tudjuk meghívni csak rövidebben (Powershell-es vagy röviden, PS-es terminal-ban tudjuk kiadni ezt):
Set-Alias -Name phpunit -Value vendor/bin/phpunit
Utána már csak simán a phpunit parancs kiadásával tudjuk indítani a tesztelést. Példának okáért rögtön teszteljük ezt úgy, hogy csak egy fájlt futtatunk le:
phpunit tests/Unit/ExampleTest.php
Látható, hogy ez működik és így már csak 1 teszt és 1 assert futott le, helyesen. Ha megnézzük a 2 ExampleTest osztályt és benne a metódusokat, akkor láthatjuk, hogy a Unit mappában lévő egy sima igaz-igaz állítás egyezőségét vizsgálja meg, a Feature mappában lévő pedig lekéri a kezdőoldalunkat és megvizsgálja, hogy a visszakapott válasz az 200-as HTTP állapotkódot ad-e vissza, vagyis, hogy hibamentesen betölti-e a kezdőoldalt.
Mikor teszteljünk? Mivel most a komplex példánk már elég jól felépült, működnek a kódjaink, ezt tudjuk, ezért azt mondhatjuk, hogy az implementálás után tesztelünk éppen. De előfordulhat most is (illetve a későbbiekben bármikor) olyan funkcionalitás, amelynek előbb a tesztjét írjuk meg, aztán pedig elvégezzük a tényleges megvalósítást, és kódolunk addig, ameddig ez az új tesztünk OK eredményt nem ad vissza. Ez utóbbit hívják tesztvezérelt szoftverfejlesztésnek (test-driven development).
Saját teszt létrehozása és a szabályok
Hozzunk létre egy új tesztet és közben ismerkedjünk a tesztelés szabályaival.
php artisan make:test RoutesTest
Alapértelmezetten ilyenkor a Feature mappába fog bekerülni az új teszt osztályunk. Ha Unit tesztet hoznánk létre akkor írjuk utána a -u kapcsolót. A következő lépés, hogy hozzunk létre tesztelő metódusokat, amelyek visszajelzést adnak a webalkalmazásunk helyes működéséről. Tesztelő metódust kétféle módon hozhatunk létre:
- Helyezzük el a következő annotációs megjegyzést a metódus előtti sorban: /** @test */
- Kezdjük a metódusunk nevét így: test
Mindkettő jó megoldás, azt használjuk, ami számunkra szimpatikusabb, logikusabb, átláthatóbb. Adjunk viszont a metódusunknak nagyon beszédes, olvasható nevet, mert a teszt futtatása során, főleg amikor már nagyon sok lesz belőlük, akkor sokat tud segíteni, hogy rögtön átlássuk, mégis mivel van probléma.
Ha valaki ismeri a User Story (Felhasználói történet) tervezői eszközt, annak ismerős lehet az a stratégia, hogy hogyan építsünk fel egy ilyen tesztelési metódust (kövessük tehát a Given-When-Then, vagyis a GWT sablont):
- Van az adott felhasználó, aki be van jelentkezve a rendszerbe;
- Amikor a felhasználó eléri az adott útvonalat (például: /teams);
- Akkor listázza ki neki a csapatokat.
Közben persze mindent ellenőrizzünk le assert utasításokkal. De még mielőtt túlságosan bonyolult "Feature"-ökbe, programfunkciókba mennénk bele. Próbáljunk tesztelni néhány egyszerűbb metódust és közben gyakoroljunk.
public function test_flights_page()
{
$response = $this->get('/flights');
$response->assertStatus(200);
}
Itt például, ha a teszt során lekérjük a /flights útvonalat, akkor elvárás, hogy a 200-as HTTP állapotkóddal térjen vissza. Vagy itt egy másik metódus:
public function test_welcome_page()
{
$response = $this->get('/');
$response->assertViewIs('welcome');
$response->assertSee('Home');
}
A teszt során itt lekérjük a kezdőoldal útvonalat és megvizsgáljuk, hogy az a welcome nézet-e, illetve, hogy a visszakapott oldal HTML kódjában megtalálható-e a 'Home' felirat... nálam természetesen mindkét felvetésre igen a választ, ezt várom tehát el az oldalam működésétől. Ha valamelyik teszt hamisat ad vissza, tehát elbukik (a szakzsargon szerint), akkor a teszt nem fut le és hibát ad vissza. A hiba esetén a rendszer azt is megmondja, hogy hol és mivel volt a probléma.
Hozzunk létre egy új tesztet, ami már egy picit bonyolultabb lesz:
php artisan make:test FlightsTest
Ebben a fájlban, ahogy a neve is mondja, a repülőjáratokat szeretném majd tesztelni, de úgy, hogy a már meglévő adataimat ne "szemeteljem tele". (Megjegyzés, nekem sajnos sikerült teleszemetelnem, aztán törölni is a meglévő adataimat, úgyhogy ne essetek ugyanabba a hibába, mint én estem... már rögtön itt az elején hozzuk létre a teszteléshez használt környezetet.)
Hozzunk létre egy új környezeti fájlt a Terminal segítségével:
cp .env .env.testing
A teszteléshez egy SQLite adatbázist fogunk használni (ha nem akarunk SQLite-ot, hanem megtartanánk a MySQL-es adatkapcsolatot,
akkor a teszteléshez mindenképpen használjunk egy tesztadatbázist).
Tipp (vagyis inkább erős javaslat): a tesztadatbázisnak mindig olyat használjunk, amilyen az éles adatbázis is (lehetőleg ugyanazon az adatbáziskezelő szerveren helyezzük el őket), így tényleg eléggé biztosak lehetünk abban, hogy amiket írunk adatmanipuláló és -lekérdező teszteket, azok le fognak futni az adott környezetben.
Én SQLite-ot fogok használni, úgyhogy az újonnan létrejövő .env.testing fájlban a DB_CONNECTION attribútum értékét átírhatjuk sqlite -ra, a többi DB_ kezdetű attribútumot pedig törölhetjük is a fájlból. Az APP_ENV attribútum értékét pedig változtassuk meg testing -re. Mivel ez sqlite-ot fog használni, az azt várja el tőlünk alapértelmezetten, hogy a database mappánkban legyen egy database.sqlite nevű fájlunk, ezért ezt hozzuk is oda létre majd futtassunk egy migrálást, ami ezt az .env.testing fájlt használja: php artisan migrate --env=testing . Ennyi kezdeti beállítás után már foglalkozhatunk a FlightsTest osztályunkkal és hozzuk létre ezt a metódust:
public function test_flight_factory()
{
$flight = Flight::factory()->create();
$response = $this->get('/flights/' . $flight->id);
$response->assertOk();
}
Mit várunk el ettől? Azt szeretnénk, hogy jöjjön létre a Flight factory segítségével egy új repülőjárat. Utána ugye működnie kell annak az útvonalnak, ahol ennek az új repülőjáratnak tudjuk megnézni a részleteit (kvázi a FlightsController show metódusa fut le az adott paraméterrel és visszaadja a felhasználónak a részletező weboldalt). Majd egy 200-as, tehát OK, megfelelő lefutást kell visszaadnia. Ez tehát az elvárás, teszteljük is ezt le. (Most már a saját tesztjeinket építgetjük és futtatjuk a phpunit parancs segítségével, így zavaró is lehet, hogy az ExampleTest osztályok metódusai is mindig lefutnak, amiknek igazából sok jelentőségük már nincsen, úgyhogy akár ezek törölhetők is a projektből, vagy kikommentezhetjük a tesztelő metódusaikat is, és akkor példaként megmaradnak nekünk.)
phpunit tests/Feature/FlightsTest.php
Ez az egy tesztelő metódusunk sikeresen le is futott és létrejött az SQLite-os flights adattáblában az új repülőjárat (ennek lekérdezésére én DB Browser-t használok, de létezik hozzá VSCode-os menedzser is, bármelyiket lehet használni.) Próbáljunk ki még egy dolgot úgy, hogy közben a tesztünket is bővítjük:
use RefreshDatabase;
public function test_flight_factory()
{
$attribute = ['name' => 'Test Flight'];
$flight = Flight::factory()->create($attribute);
$response = $this->get('/flights/' . $flight->id);
$response->assertOk();
$response->assertSee('Test Flight');
}
Az osztályon belül használjuk a RefreshDatabase importálását. Ez annyiban lesz a segítségünkre, hogy ha olyan tesztelő metódust használok, amely az adatbázisban hajt végre módosításokat (mint ez az iménti is), akkor a változtatás végrehajtása után törli is a módosításokat, így nem fog megnőni az adatbázisunk mérete és a "szemetelés" sem lesz rá igazából hatással. Az iménti példakód úgy hoz létre repülőjáratot, hogy meghatározom neki a name attribútumot, majd a végén a járat részleteinek lekérésénél ellenőrzöm, hogy ott van-e, látható-e a létrehozáskor megadott név.
A bejegyzéshez tartozó változások Github commit elérése itt található.
A későbbiekben többször elő fog fordulni, hogy előbb létre fogjuk hozni a tesztet, majd utána implementáljuk a megvalósítást. Sőt, egy még látványosabb eszközzel, a Dusk-kal is meg fogunk ismerkedni, amivel még látványosabb lesz a tesztelés, de nem szeretnék még ennyire előre rohanni...