A szoftverfejlesztés egy kreatív munka, így egyelőre jelentős része nem automatizálható, a kódot író és azt tesztelő embereken múlik a végeredmény. Egy-egy alkalmazás fejlesztése hosszú éveket vehet igénybe, többen dolgozhatnak rajta — így óriási koncentrációt igényel a fejlesztőcsapat részéről, hogy úgy feleljenek meg az üzleti igényeknek, hogy közben betartsák a fejlesztési konvenciókat és jól karbantartható kódot produkáljanak. Mindezek olyan nyomást gyakorolnak a programozókra, hogy a legnagyobb igyekezett mellett is becsúszhatnak hibák. Amíg nincsenek tökéletes emberek, addig nem lesz általuk írt, tökéletes kódbázis sem.
A mai modern fejlesztőkörnyezetek (IDE-k) azonnal ellenőrzik a kód szintaktikáját, sok esetben valamilyen lint (pl. JavaScriptnél JSHint) eszközzel is biztosíthatjuk magunkat a hibák ellen, sőt a legtöbb nyelvben szükséges az elkészült kód előfordítása is — azonban ez könnyedén abba a hitbe ringathat minket, hogy garantáltuk a szoftver helyes működését. Ezek az eszközök specifikus (pl. szintaktikai) vagy gyakran előforduló szemantikai hibák elkerülésére jól használhatóak, azonban nem végeznek mélyebb ellenőrzést, hiszen a fejlesztőkörnyezet számára nem dokumentáljuk a szoftver algoritmikus működését. Ha az automatikus hibakeresésben bízunk, akkor akár előre elkönyvelhetjük, hogy egy bug a legrosszabb időpontban fog előbukkanni és a lehető legnagyobb kárt fogja okozni.
A kétezres évek óriási reformokat hoztak a szoftverfejlesztésbe, az agilis projektszemlélet mellett egyre nagyobb igény keletkezett a könnyen karbantartható, bővíthető és a minél hibátlanabb kódbázisra is. A cél a nagy, monolitikus alkalmazásoktól átterelődött a kicsi, specifikus, az ügyfél igényeit kielégítő, azonban felesleges részeket nem tartalmazó, egyszerűbb szolgáltatások és az ezekből felépülő szoftvercsomagok felé.
Az új ökoszisztémába már nem fér bele az adhoc fejlesztés, a kódnak stabilnak kell lennie, biztosítani kell, hogy egy későbbi módosítás ne rontsa el a rendszer más részeit. Elvárás, hogy egy alkalmazás rugalmas legyen, hiszen csak így képes az állandóan változó, finomodó üzleti igényeknek megfelelni - ez azonban azt is jelenti, hogy rengetegszer kell a szoftver már megírt részeiben módosítani. A tisztán manuális tesztelés ilyen esetekben szinte a lehetetlennel egyenértékű, hiszen minden egyes módosítás alkalmával végig kellene ellenőrizni az alkalmazás teljes funkcionalitását.
Természetesen régebben is létezett és sok helyen a fejlesztési konvenciók részét képezte a unit-tesztek írása. A kódot közvetlenül megfuttató, az osztályokat és a metódusokat hívogató automatikus tesztekkel már biztosíthatjuk, hogy az alkalmazás a megfelelő módon működjön.
Teszteket legkönnyebben valamilyen keretrendszer segítségével írhatunk, amely általában segít a megírt tesztek futtatásában is. A JavaScript esetén a futtatókörnyezet egy böngésző vagy egy NodeJS szerver, azonban amíg fejlesztéskor — a gyors visszajelzés érdekében — egy platformon érdemes futtatni tesztjeinket, addig a szoftver kiadása előtt ajánlott az összes támogatott kliensen leellenőrizni a teljes tesztkészletet. Bár manapság a JavaScript futtatókörnyezetek már nagyon hasonlóak, néha meglepő, böngészőfüggő hibákat tudunk elkapni ilyen módon. Ha igazán gyors visszajelzést szeretnénk kapni, akkor a fejlesztéshez válasszunk úgynevezett headless, vagyis felülettel nem rendelkező böngészőt (pl. PhantomJS). Utóbbiak lényegesen gyorsabbak, mint a hagyományos társaik, hiszen a felületek renderelése csak virtuálisan történik meg és mivel parancssorból kezelhetőek így jóval könnyebben integrálódhatnak a fejlesztőkörnyezetünkbe.
A JavaScript legnépszerűbb teszt-keretrendszerei a Mocha, a Jasmine, a jQuery tesztelésére is használt QUnit és a JsTestDriver által biztosított Assertion library. A következőkben az utóbbival fogunk foglalkozni, ugyanis ez áll a legközelebb, a klasszikus, úgynevezett xUnit keretrendszerekhez (pl. a Java JUnit-jához vagy a PHP-s PHPUnithoz).
A teszt-keretrendszerekben általában tesztkészleteket definiálhatunk, melyek mindegyike egy-egy osztály egységtesztelésére szolgál. A JsTestDriver Assertion libraryben ezt a TestCase függvénnyel tehetjük meg:
// Tegyük fel, hogy a következő osztályra szeretnénk tesztet írni: var Calculator = function() ; Calculator.prototype.add = function(a, b) { return a + b; }; // Definiáljuk a tesztkészletet: TestCase('CalculatorTests', );
A TestCase második paraméterében egy objektumot kell átadnunk, melyben felsorolhatjuk a teszteseteinket. Fontos megjegyezni, hogy akárhány tesztet is definiálunk, azok mind egymástól függetlenül, nem feltétlenül kötött sorrendben fognak meghívódni. Lehetőségünk van egy kiválasztott, vagy akár az összes eset egyszerre történő futtatására — előbbi jól jön, ha gyors visszajelzést szeretnénk kapni a teszt helyességéről, azonban csak a teljes készlet futtatásával biztosíthatjuk az alkalmazás helyes működését.
Minden tesztesetben legalább egy assertion-nek, vagyis ellenőrzésnek kell szerepelnie. Az xUnit keretrendszerek a legkülönbözőbb függvényeket biztosítják az érték- és állapotellenőrzésre:
Ha bármely vizsgálat elbukik, akkor a teszteset fail állapotba kerül, melyről a teljes futást követően értesít minket a futtatókörnyezet.
A JsTestDriver Assertion library használatánál minden teszt-függvényt a test kulcsszóval kell prefixelni, azonban ezt követően bármilyen nevet írhatunk. A nem test-el kezdődő metódusokat közvetlenül nem hívja meg a test runner, így nyugodtan írhatunk vagy egy összetett tesztből kiemelhetünk saját metódusokat.
TestCase('CalculatorTests', { testAdd_GivenTwoNumbers_ReturnsSumOfThem: function() { var calculator = new Calculator(); assertEquals(3, calculator.add(1,2)); }, testAdd_GivenAZero_ReturnsTheOtherNumber: function() { var calculator = new Calculator(); assertEquals(2, calculator.add(0,2)); assertEquals(3, calculator.add(3,0)); } });
A fenti példában a kalkulátor példányosítása egy csúnya ismétlés, kiált azért, hogy emeljük ki valahova. A teszt-keretrendszerek általában biztosítanak egy minden teszteset előtt lefutó metótust — ez esetünkben a setUp lesz. Kitűnő lehetőség arra, hogy kiemeljük az objektum konstruálást:
TestCase('CalculatorTests', { setUp: function() { // A this.calculator minden tesztesetben egy teljesen // új objektumpéldány lesz! this.calculator = new Calculator(); }, testAdd_GivenTwoNumbers_ReturnsSumOfThem: function() { assertEquals(3, this.calculator.add(1,2)); }, testAdd_GivenAZero_ReturnsTheOtherNumber: function() { assertEquals(2, this.calculator.add(0,2)); assertEquals(3, this.calculator.add(3,0)); } });
A tesztek írása gyakorlatilag egyidős a szoftverfejlesztéssel, hiszen mindig is igény volt arra, hogy a drága manuális tesztelést kiváltsuk valamilyen automatizmussal vagy legalábbis leellenőrizzük a kódnak azon részeit, amit a felület segítségével igen nehezen vagy egyáltalán nem tudnánk elérni.
Az automatikus tesztek írása mögötti legfontosabb motiváció mindig is az volt, hogy leellenőrizhessük, ha a szoftver hirtelen nem úgy működik, ahogy szeretnénk. Ha egy teszt elbukik, azonnal tudjuk, hogy probléma van és még időben orvosolhatjuk.
A hagyományos tesztírás azonban problémás, sosem tudott igazán közkedvelt lenni, valahogy mindig a szükséges rossznak tekintettük és még ma is a fejlesztés mostohagyerekeként bánunk vele.
Nem lenne jó egy olyan eszköz, ami folyamatosan jelezné a fejlesztői hibákat, azonnal ellenőrizné a program helyes működését, amint a programozó leír egy kódrészletet? Egy eszköz, ami kvázi szükségtelenné tenné a debuggolást?
Szerencsére ma már van ilyen, ha nem is egy eszköz, de legalább egy programfejlesztési módszertan — ez a tesztvezérelt fejlesztés (Test Driven Development / TDD). A TDD a tesztírás-kódolás-refaktorálás hármas gyors ciklusát jelenti. A fejlesztés során e három kis lépés követi egymást egészen addig, amíg az adott szoftverrész el nem nyeri végleges formáját. Minden egyes lépés után a teljes tesztkészletet lefuttatjuk, így biztosítjuk magunkat arról, hogy nem rontottunk el semmit az előző módosítás óta.
A tesztvezérelt fejlesztés nem triviális, hosszú tanulási folyamat szükséges hozzá és régi, legacy kódra nehéz adaptálni — azonban jól használva jelentősen javíthatja a kód minőségét, azonnal dokumentálja azt és hatékonyan véd a jövőbeni hibák ellen.
A tesztvezérelt fejlesztés során először mindig a tesztet írjuk meg, csak ezt követően az implementációt. Kicsit fura lehet ez a kifordított módszer, azonban ha belegondolunk, akkor teljesen logikus: a tesztben megfogalmazzuk, hogy mit szeretnénk elérni, milyen osztályra, és annak milyen metódusára van szükségünk és azt hogyan szeretnénk használni. Ekkor valójában egy interfészt definiálunk, vagyis megadjuk, hogy jelen pillanatban, hogyan lenne a legkényelmesebb az adott szoftverrészt használnunk. Ha ezzel végeztünk, akkor a tesztünk elbukik, hiszen nincs mögötte valós kód — feladatunk ezt követően, hogy működésre bírjuk, azaz nekikezdhetünk a kódolásnak, az igazi implementációnak. Utóbbi akkor készül el, ha a teszt kielégült.
A TDD során az osztályok publikus interfészeit vizsgáljuk és mindig az elvárt működésre, sohasem az implementációra írunk tesztet! Ha azt teszteljük, hogy az osztályunk milyen metódusokat, osztályokat milyen sorrendben hív meg, akkor a kód merevvé válik, nehezen tudjuk módosítani a későbbiekben, egy egyszerű metódus kiemelése is problémákkal járhat.
A szigorúan betartott tesztvezérelt fejlesztés során közel 100%-os kódlefedettséget érünk el, vagyis elvileg a szoftver minden egyes állapotára készül egy automatikus teszt. A nagy lefedettség és a sűrű tesztfuttatás következményeként, amint olyan implementációt írunk, amely nem várt módon módosítja a szoftver működését, akkor azonnal visszajelzést kapunk elbukó tesztek formájában.
A TDD három fázisból áll: a tesztírásból (piros fázis), az implementációból (zöld fázis) és a kód tökéletesítéséből, azaz a refaktorálásból. A fejlesztés során mindhárom lépést a lehető legkisebb és leggyorsabb módosítással próbáljuk elvégezni, ideális esetben és rengeteg gyakorlás után egyik sem tart tovább 20-30 másodpercnél. A refaktorálás végeztével újra az első fázis következik és ez így megy egészen addig, amíg az adott programrész el nem készül.
A TDD akkor a leghatékonyabb, ha a páros programozással ötvözzük: hiszen amíg valaki azzal foglalkozik, hogy kielégítsen egy tesztet, a másik fejlesztő már előre gondolkodhat azon, hogy mi a következő lépés, milyen új tesztesettel léphetnek előrébb.
Nézzük meg, hogy hogyan is néz ki a tesztvezérelt fejlesztés és, hogy pontosan mit is jelentenek az egyes fázisok.
Az első lépés egy teszt írása arra a viselkedésre, amit ki szeretnénk hozni a későbbi implementációból. Ekkor az úgynevezett piros fázisban vagyunk, hiszen az a cél, hogy egy elbukó tesztet írjunk (a tesztkörnyezetek a sikertelen teszteket általában piros színnel jelölik).
Ebben a lépésben gyakorlatilag megtervezzük a viselkedéshez szükséges interfészt, azt, hogy milyen osztályokra és azokban milyen metódusokra van szükségünk. Az igazán nagyszerű az, hogy a TDD rákényszerít minket, hogy ne az implementáló, hanem az osztályt használó szemszögéből tervezzük meg a kódunkat, aminek következtében az sokkal praktikusabb, felhasználóbarátabb lesz!
Az ideális teszt legfeljebb 4-5 sor, de természetesen nem mindig sikerül ilyen röviden megfogalmazni az elő- és utófeltételeket. Utóbbi esetben igyekezzünk úgy alakítani a kódot, hogy a következő teszteket már egyszerűbben is meg lehessen írni. Ha egy tesztet nem tudunk könnyedén megfogalmazni vagy az implementáció nagyon sokáig tart, akkor nagy eséllyel túl nagy fába vágtuk a fejszénket, túlságosan sok dolgot akartunk egyszerre tesztelni. Próbáljuk meg kisebb részekre bontani a viselkedést, gondoljuk át, hogy milyen segédosztályra vagy osztályokra lenne szükségünk, hogy gyorsabban haladjunk és írjunk azokra tesztet.
Ha a teszt elkészült akkor futtassuk a teljes tesztkészletet és bizonyosodjunk meg róla, hogy egyedül az utoljára megírt bukik el és az az elvárt módon lesz sikertelen. Utóbbi azt jelenti, hogyha az aktuális teszt mégsem bukik el vagy valamilyen meglepő hibát dob (pl. mert már létezik egy hasonló nevű osztály, amiről nem is tudtunk), akkor mindenképp foglalkozzunk a problémával.
A következő lépés az implementáció írása, a cél, hogy a lehető legminimálisabb kóddal elégítsük ki az előbb elbukó tesztet. Nem érdekes, hogyha a megoldás nem elegáns vagy még egy-két sebből vérzik — csak a teszt kizöldítésével foglalkozzunk. Sohase implementáljunk többet, mint amit a tesztünk megkíván, ha bármi más mellékhatása vagy többlet-tartalma van az újonnan megírt kódnak, akkor arra egy másik tesztet kellett volna írnunk.
Ha elkészült az implementáció akkor futtassuk le az összes tesztet és bizonyosodjunk meg róla, hogy minden rendben.
Ha túl sokáig tart az implementáció, akkor gondoljuk át újra, hogy pontosan mit is szerettünk volna csinálni, esetleg lépjünk egy lépéssel visszább. Nyugodtan kommenteljük ki az utolsó tesztet és próbáljuk a feladatot kisebb egységekre bontani. Könnyen lehet, hogy egy komplexebb viselkedés újragondolásából ilyenkor újabb osztályok esnek ki. Próbáljuk először ezeket a segédosztályokat megírni (természetesen tesztvezérelt módon), majd ha elkészültek akkor visszatérhetünk a problémás teszthez, most már sokkal könnyebben meg kell tudni oldanunk.
A TDD utolsó fázisa a refaktorálás, vagyis a kód minőségének javítása. Amíg az előző lépésben az volt a feladatunk, hogy az elvárt viselkedést a lehető leggyorsabban oldjuk meg, ezúttal az, hogy a kódot minél elegánsabbá és olvashatóbbá varázsoljuk. Most már nem célunk a gyorsaság, szemünk előtt a karbantarthatóság és a helyes kód-design kell, hogy lebegjen. Fontos, hogy a refaktorálás sosem módosíthatja az alkalmazás viselkedését — minden kódolási lépéssel egy, a korábbival ekvivalens állapotban kell tartanunk a szoftvert.
A refaktorálás egy nagyszerű lehetőséget ad arra, hogy a következő teszteset implementációjának megágyazzunk. Ha már van elképzelésünk arról, hogy a következő tesztnél mire lesz szükségünk, akkor alakítsuk úgy a kódunkat, hogy segítsük vele a legközelebbi iterációt.
Minden módosítás után futtassuk le a teljes tesztkészletet, így megbizonyosodhatunk róla, hogy még mindig az elvárt módon működik az alkalmazás és nem rontottunk el semmit. A TDD egyik erőssége, hogy ebben a lépésben már szabadon alakíthatjuk a kódot, hiszen a szoftver működésének helyességét a tesztek folyamatosan biztosítják. Ha egy teszt (legyen az az utolsó vagy bármelyik korábbi) elbukik akkor tudjuk, hogy véletlenül módosítottuk az alkalmazás viselkedését és azonnal vissza kell lépnünk!
Könnyű megfeledkezni róla, hogy a teszteket is refaktorálni kell. Ha elhanyagoljuk a tesztkészletünket, ismétléseket hagyunk benne, komplex, sok-soros teszteket, akkor a későbbiekben egyre nehezebb lesz áttekintenünk és bővítenünk azt. A duplikációkat folyamatosan emeljük ki a setUp metódusba vagy külön gyártó- esetleg saját assert függvényekbe.
A tesztek minőségének folyamatos javítása nem csak a karbantarthatóság szempontjából fontos — mivel a tesztkészletünk mindennél jobban dokumentálja a szoftver működését, ezért a könnyen átlátható tesztek segítik a fejlesztőket a kód későbbi megértésében is. A TDD mellett nincs feltétlenül szükség fejlesztői dokumentációra, a teszteket átnézve bárki képet kaphat arról, hogy hogyan is kell egy-egy osztályt vagy metódust használni.
A refaktorálás végeztével egy újabb elbukó tesztet írunk, ezzel újrakezdve a TDD ciklusát. A piros-zöld-refaktorálás lépéseit mindaddig folytatjuk, amíg az elvárt viselkedést meg nem kapjuk.
Amikor a következő tesztet tervezzük, gondoljunk arra, hogy a TDD kulcsa a kis lépésekben rejlik — hiszen minél kisebb az iteráció annál rugalmasabbak leszünk, ha valami nem várt problémába ütközünk az implementálás során. Az igazi baby step-ek elérése rengeteg gyakorlást igényel, még a tesztvezérelt fejlesztésben jártas programozót is el tudja gondolkodtatni egy-egy összetettebb probléma helyes felbontása.
A TDD tanulására, önmagunk fejlesztésére úgynevezett coding kata-kat, vagyis rövid és egyszerű gyakorlófeladatokat érdemes megoldani. Ezek a feladatok általában nem igényelnek túl bonyolult implementációt, azonban könnyedén kipróbálhatóak rajtuk a tesztvezérelt fejlesztés különböző aspektusai.
A tesztvezérelt fejlesztés bemutatásához a következőkben az egyik legnépszerűbb gyakorlófeladatot, a FizzBuzz kata-t fogjuk megoldani.
A feladat a következő: készítsünk egy osztályt, ami 1 és 100 között kiírja az összes számot, azonban minden hárommal osztható helyett Fizz-t, minden öttel osztható helyett Buzz-t és minden három és öttel osztható szám helyett FizzBuzz-t jelenít meg.
Először is érdemes átgondolni a feladatot, mire is lesz szükségünk, mi lehet az első kis lépés.
Az első gondolatunk talán az, hogy a feladat pontos leírása alapján fel tudnánk vázolni, hogy hogyan is képzeljük el a végeredményt szolgáltató kódot. Az azonnal látszódik, hogy szó szerint véve a feladatot elég nagy lépést kellene tenni — konkrétan száz értéket ellenőrizhetnénk, amely ráadásul a feladat összes megszorítását tartalmazná. Ebből a gondolatmenetből viszont már adódik egy egyszerűbb lépés is: mi lenne, hogyha a feladatot megoldó metódusnak egy paraméterben átadnánk, hogy meddig írja ki a sorozatot — ekkor nem kellene az összes esetet vizsgálni, elég lenne kisebbekkel elkezdeni. Ez tényleg nem tűnik bonyolultnak: az első esetben 0 elemű, majd ezt követően az 1-et tartalmazó egy elemű sorozatot várnánk el, és így tovább haladhatunk a bonyolultabb megszorítások felé.
Írjuk is meg az első tesztünket a 0 értékre, ami bár túlságosan egyszerűnek tűnhet, azonban igen fontos lépés, hiszen ilyenkor formáljuk meg, hogy milyen interfészt is képzelünk el az adott szoftverrésznek:
TestCase('FizzBuzz', { testDisplay_TakeZero_ReturnsEmptyList: function() { var fizzBuzz = new FizzBuzz(); assertEquals('', fizzBuzz.display(0)); } });
Egy teszt neve legalább olyan fontos, mint maga a tartalma, hiszen a dokumentáció szempontjából ez az egyik legfontosabb aspektus. Az egységtesztek elnevezésére egy széleskörűen elfogadott módszer a metódus név - környezet vagy kontextus leírása - elvárt viselkedés hármasra épülő megfogalmazás. A kontextus általában az osztály beállításaira, a metódus paramétereire vonatkozik, az elvárt viselkedés pedig az állapotváltozásra vagy a visszaadott értékre. Egy teszt neve azonban mindig legyen olvasható és ne a be- és kimeneti paraméterek felsorolásából álljon, sokkal inkább a teszteset emberi jelentését hordozza.
Már ezen egyszerű teszt írása során is meg kellett hoznunk néhány igen fontos döntést: - El kellett dönteni, hogy mi legyen a megoldó osztály és metódus neve. Figyeljünk oda, hogy az elnevezések beszédesek legyenek és megfelelően illeszkedjenek egymáshoz. - Döntést kellett hozni a visszatérési értékről is, hiszen ezt nem definiálta pontosan a feladat. A visszatérési érték szoros összefüggésben van a metódus nevével. Kövessük itt is a legkisebb meglepetés elvét, azaz ne zavarjuk össze a későbbi használót azzal, hogy a névtől független, váratlan típussal térünk vissza.
Ha a fenti esetet vesszük figyelembe és a FizzBuzz osztály, és egy display nevű metódusa mellett döntünk, akkor a visszatérési érték egyértelműen valami string-szerű kell, hogy legyen.
Elkészült az első tesztünk, melyet futtatva természetesen hibát kapunk: nincs FizzBuzz nevű változónk. Itt az ideje, hogy elkészítsük az első implementációt. Most csupán annyi a dolgunk, hogy a lehető legegyszerűbb kódot írjuk meg, ami kielégíti a tesztet, semmiképp ne írjunk bele plusz tudást, egyelőre szükségtelen részeket.
"use strict"; var FizzBuzz = function() ; FizzBuzz.prototype = { display: function() { return ''; } };
Semmi másra nincs szükségünk, minthogy visszaadjuk az üres sztringet. Ennél egyszerűbb megoldás valószínűleg nincs is, és jelen pillanatban teljesen megfelel. A tesztünk szépen lefut, a zöld fázisba kerültünk, azaz elkezdhetünk refaktorálni. Mivel egyelőre nincs túl sok kódunk és nem is feltétlenül látjuk, hogy a következő lépést mi könnyítené meg, így egyszerűen hagyjuk így és írjuk meg a második tesztesetet.
TestCase('FizzBuzz', { testDisplay_TakeZero_ReturnsEmptyList: function() { var fizzBuzz = new FizzBuzz(); assertEquals('', fizzBuzz.display(0)); }, testDisplay_TakeOne_ReturnsTheFirstElement: function() { var fizzBuzz = new FizzBuzz(); assertEquals('1', fizzBuzz.display(1)); } });
Piros fázisba érkeztünk, itt az ideje a teszt kizöldítésének, melyhez nincs másra szükség, mint a paraméter felhasználására:
"use strict"; var FizzBuzz = function() ; FizzBuzz.prototype = { display: function(lengthOfSequence) { if (!lengthOfSequence) { return ''; } return '1'; } };
Egy metódusnál érdemes a hibás vagy gyors visszatérési állapotokat a függvény elejére helyezni. Ezt a módszert early return-nek nevezik és sokat segít a metódus átláthatóságában, ugyanis jobb a kivételes eseteket szem előtt tartani, ráadásul a valódi implementáció is a függvény alap-indentálása mellett maradhat.
A tesztünk immáron zöld, így elkezdhetjük a kód refaktorálását. Az implementáció egyelőre nem tűnik olyannak, amihez hozzá kellene nyúlni — annál inkább a tesztek, ott ugyanis ordas problémára figyelhetünk fel: a FizzBuzz konstruálása duplikátumként szerepel. Nem jó gyakorlat a jövőre feltételezéseket tenni, viszont most egész biztosak lehetünk benne, hogy ezután is hasonló módon fogjuk konstruálni a sorozatot, így emeljük át ezt a műveletet a tesztkészlet setUp-jába:
TestCase('FizzBuzz', { setUp: function() { this.fizzBuzz = new FizzBuzz(); }, testDisplay_TakeZero_ReturnsEmptyList: function() { assertEquals('', this.fizzBuzz.display(0)); }, testDisplay_TakeOne_ReturnsTheFirstElement: function() { assertEquals('1', this.fizzBuzz.display(1)); } });
Itt az idő, hogy továbbhaladjunk egy új iterációval, azaz írjunk egy elbukó tesztet. Értelemszerűen a két elemű lista következik, amely már jelentős mértékben módosítja a kimenetet, hiszen több számot is meg kell jeleníteni.
Az már előre konstatálható, hogy az implementáció nem lesz olyan egyszerű, mint eddig — adott esetben most megállhatnánk és úgy refaktorálhatnánk a kódot úgy, hogy a következő lépést már könnyebben tehessük meg. Ettől azonban most tekintsünk el, és próbáljuk meg, hátha néhány sorból is meg tudjuk oldani a feladatot.
A teszt bizonyára nem okoz nehézséget, egyetlen kérdésre kell csak választ adni: hogyan válasszuk el a lista elemeit? Mivel a specifikáció nem pontosított a kimenettel kapcsolatban, egyelőre használjuk a vesszőt szeparátorként:
testDisplay_TakeTwo_ReturnsTheFirstTwoElement: function() { assertEquals('1,2', this.fizzBuzz.display(2)); }
Újra piros fázisba érkeztünk, írjuk is meg a tesztet kielégítő implementációt. Legegyszerűbbnek az tűnik, hogyha egy tömbbe pakoljuk a számokat addig amíg elérjük a kívánt szekvencia-méretet, végül a szeparátorral összekonkatenáljuk a tömb elemeit.
FizzBuzz.prototype = { display: function(lengthOfSequence) { if (!lengthOfSequence) { return ''; } var fizzBuzzSequence = []; for (var i=1; i<=lengthOfSequence; i++) { fizzBuzzSequence.push(i); } return fizzBuzzSequence.join(','); } };
A teszt kielégült, nekiállhatunk a refaktorálásnak. Egyértelmű, hogy a display metódus kezd egy kissé túlterhelődni, próbáljuk meg egy kissé szétbontani:
FizzBuzz.prototype = { SEPARATOR: ',', display: function(lengthOfSequence) { if (!lengthOfSequence) { return ''; } return this._getSequenceUntil(lengthOfSequence).join(this.SEPARATOR); }, _getSequenceUntil: function(length) { var fizzBuzzSequence = []; for (var i=1; i<=length; i++) { fizzBuzzSequence.push(i); } return fizzBuzzSequence; } };
Ez a változat már alakul — a display metódus egyértelműen tisztult, eltűnt egy zavaró ciklus és a lényegi implementáció most már beszédessé vált.
Mivel a JavaScriptben nem triviális privát osztálymetódusokat használni, így konvenció szerint alulvonással jelöljük azokat a függvényeket, amelyeket ilyen tulajdonságúnak gondolunk. Természetesen az óvatlan fejlesztőt semmi sem akadályozza meg abban, hogy az osztályon kívülről is meghívja a _getSequenceUntil-hoz hasonló metódusokat — azonban mindig gondoljunk arra, hogy az ilyen függvények nincsenek közvetlenül tesztekkel alátámasztva!
Mivel a vesszőt, mint szeparátort önkényesen választottuk ki, így érdemes konstansként kiemelni — bárki aki ránéz az osztályra egyből láthatja, hogy hol módosíthatja az elemeket elválasztó karaktert.
Nézzünk most rá a tesztekre, ugyanis ezen a ponton érdemes újragondolni az eddigi elnevezéseket is. A teszteket mindig jellemzők köré csoportosítsuk: minden teszt egy speciális viselkedést teszteljen, vagyis a különböző esetek ne legyenek egymásnak megfeleltethetőek. Ha több teszteset ugyanazt a viselkedést teszteli, akkor az felesleges redundanciát okoz, a kód karbantarthatóságát nehezíti, hiszen egy esetleges módosítás esetén az összes vonatkozó tesztet át kell írni.
Az üres teszteset külön viselkedést vizsgál, de a számot visszaadók már ugyanazt az esetet valósítják meg. Érdemes lenne ezt a redundanciát megszüntetni azzal, hogy a hétköznapi számok megjelenítését vesszük egy tesztesetnek, vagyis összevonjuk az utolsó két tesztet. Rendben, a szándék megvan, már csak egy nevet kell találni a tesztnek. Első körben legyen:
testDisplay_TakeFewerThanThree_ReturnsOnlyNumbers: function() { assertEquals('1', this.fizzBuzz.display(1)); assertEquals('1,2', this.fizzBuzz.display(2)); }
A refaktorálást egyelőre be is fejezhetjük, ugorjunk is a következő tesztesetre:
testDisplay_TakeFirstFour_ReturnsFizzWhenDivisibleByThree: function() { assertEquals('1,2,Fizz,4', this.fizzBuzz.display(4)); }
A megfogalmazásnál egy kis trükköt használtunk: ha az első négy értéket kérjük le, akkor a 3 helyén Fizz fog szerepelni, ámde a 4 is ott lesz számként. Ezzel csak egyetlen újdonságot hozunk be, viszont látványosabb, hogy mi is történik.
Piros fázisban vagyunk, zöldítsük ki a tesztet. A legegyszerűbb megoldás, ha egy elágazást teszünk a ciklusba:
FizzBuzz.prototype = { SEPARATOR: ',', display: function(lengthOfSequence) { if (!lengthOfSequence) { return ''; } return this._getSequenceUntil(lengthOfSequence).join(this.SEPARATOR); }, _getSequenceUntil: function(length) { var fizzBuzzSequence = []; for (var i=1; i<=length; i++) { if (i % 3 === 0) { fizzBuzzSequence.push('Fizz'); } else { fizzBuzzSequence.push(i); } } return fizzBuzzSequence; } };
A teszteket futtatva csupa zöldet kapunk — ámde a kezdeti örömöt hamar bánat kendőzheti, hogyha jobban megnézzük a kódjainkat. Mind a teszt, mind az implementáció elindult egy problémás irányba.
A tesztjeink egyre bonyolultabbá válnak, ráadásul az egyes tesztek a korábbiakra épülnek. Tegyük fel, hogy megírtuk az összes tesztet, lesz már egy legalább 5 elemű lista a Buzz-al és egy 15 elemű a FizzBuzz-al, ámde valamit elrontunk a számok kiírásával kapcsolatban. Ebben az esetben az összes tesztünk el fog bukni, hiszen mindegyikben építettünk arra, hogy a számokat jól jelenítjük meg. Sok esetben nem oldható meg, hogy a tesztek teljesen függetlenül vizsgálják a hozzájuk tartozó viselkedést, azonban — amennyire lehet — törekedni kell rá.
Az implementáció sincs jobb helyzetben. A _getSequenceUntil metódus most már túl sok felelősséget hordoz magában: nem csak a szekvencia végigszámolásáért felel, de az értékek helyes fordításáért is.
Természetesen osztályon belül is javíthatnánk a problémán, hogyha a felelősségeket több metódus között osztanánk fel — azonban maga az osztály még ebben az esetben is két jelentősen különböző feladatot látna el.
Szedjük össze, hogy mit is kellene megoldani: - A teszteket egyszerűbbé és függetlenebbé kellene tenni. - Valahogy el kellene választani a szekvencia-készítés és a számból FizzBuzz értékre való fordítást.
Ahogy a legtöbb problémára, így a fentiekre is megoldást jelent, ha viselkedéseket külön osztályokba szervezzük ki: maradjon a FizzBuzz-ban a szekvencia létrehozása, azonban hozzunk létre egy FizzBuzzTranslator osztályt, ami az adott szám lefordításáért felel.
Az a szerencsés eset áll fenn, hogy a zöld fázisban vagyunk, vagyis — jelen pillanatban bármit is csinálunk — ha a tesztek lefutnak, akkor nem rontottuk el a program működését. E védőháló alatt nyugodtan hozzunk létre egy új osztályt és egyszerűen emeljük ki a fordítást:
var FizzBuzzTranslator = function() ; FizzBuzzTranslator.prototype = { getValueOf: function(number) { if (number % 3 === 0) { return 'Fizz'; } return number; } };
Mivel a FizzBuzz számok fordítása az új osztályban valósul meg, így a FizzBuzz._getSequenceUntil nagyszerűen leegyszerűsödik, a feladat végéig feltehetően már nem is kell módosítani:
var FizzBuzz = function() { this.translator = new FizzBuzzTranslator(); }; FizzBuzz.prototype = { SEPARATOR: ',', display: function(lengthOfSequence) { if (!lengthOfSequence) { return ''; } return this._getSequenceUntil(lengthOfSequence).join(this.SEPARATOR); }, _getSequenceUntil: function(length) { var fizzBuzzSequence = []; for (var i=1; i<=length; i++) { fizzBuzzSequence.push(this.translator.getValueOf(i)); } return fizzBuzzSequence; } };
Adósak maradtunk a tesztek átalakításával: a létrejött új osztályt is le kellene tesztelni. Mivel már elkészültek a teszteseteink így első körben emeljük át ezeket, ügyelve arra, hogy ne módosítsunk a jelentésükön — az új osztály interfészével, de ugyanazt vizsgálják:
TestCase('FizzBuzzTranslator', { setUp: function() { this.translator = new FizzBuzzTranslator(); }, testGetValueOf_TakeFewerThanThree_ReturnsOnlyNumbers: function() { assertEquals('1', this.translator.getValueOf(1)); assertEquals('1,2', this.translator.getValueOf(1) + ',' + this.translator.getValueOf(2)); }, testGetValueOf_TakeFirstFour_ReturnsFizzWhenDivisibleByThree: function() { assertEquals('1,2,Fizz,4', this.translator.getValueOf(1) + ',' + this.translator.getValueOf(2) + ',' + this.translator.getValueOf(3) + ',' + this.translator.getValueOf(4)); } });
Amint az látható csak néhány módosítást eszközöltünk: - Ezúttal kézzel kellett előállítanunk a sorozatot. - Az üres bemenettel nem foglalkozik a fordító, így ez a teszt törölhető. - A tesztek neveiben módosítottuk a metódus nevét.
A fenti felesleges lépésnek tűnhet, azonban ne feledjük: a jó TDD kulcsa az apró lépésekben rejlik. Mindig a lehető legkevesebb módosítást végezzük és állandóan futtassuk a teszteseteinket. Egy bonyolultabb kód esetén sokat segíthet, ha betartjuk a fenti szabályt és először egy az egyben próbáljuk átemelni a teszteket.
Most, hogy megbizonyosodtunk arról, hogy minden rendben, az új osztályunk megfelelően működik, elkezdhetjük a saját tesztjeinek refaktorálását. Jelen pillanatban két viselkedést vizsgálunk, a számból számra, és a számból Fizz-re való fordítást, így erre írjunk két új tesztesetet. Ha ezek elkészültek — és lefut az összes teszt — akkor az áthozott két tesztet ki is törölhetjük, mivel az újak mellett redundánssá váltak. A végeredmény valahogy így fog kinézni:
TestCase('FizzBuzzTranslator', { setUp: function() { this.translator = new FizzBuzzTranslator(); }, testGetValueOf_GivenSimpleNumber_ReturnsTheNumber: function() { [1,2,4,7,8,11,13,14].forEach(function(number) { assertEquals(number, this.translator.getValueOf(number)); }, this); }, testGetValueOf_GivenNumberDivisibleByThree_ReturnsFizz: function() { [3,6,9,12].forEach(function(number) { assertEquals('Fizz', this.translator.getValueOf(number)); }, this); } });
A fentiekben több különböző tesztesetre is ellenőrizzük a viselkedéseket — ennek megvan a maga előnye és hátránya is. Nyilván a későbbiekben úgy változhatnak a követelmények, hogy azzal eltörhet egy ilyen, széles teszthalmazt tartalmazó ellenőrzés. Ez azonban nem feltétlen probléma, lehet, hogy felhívja a figyelmünket valami olyan esetre vagy mellékhatásra, amire hirtelen nem gondoltunk és foglalkozunk kell vele.
Az új osztály tesztjei már függetlenek egymástól, ha elrontjuk valamelyik viselkedést, akkor csak egy teszt fog elbukni és egyből tudjuk, hogy hol is keressük a hibát.
Kicsit gondban lehetünk a következő lépéssel kapcsolatban: mihez írjunk tesztet? A fordítóhoz vagy a magasabb-szintű osztályhoz? A legjobb, hogyha nem bonyolítjuk túl a kérdést: a fordítóhoz tudunk a legkönnyebben tesztet írni és a feladat szempontjából is ez a legfontosabb rész jelenleg. Ettől függetlenül mintegy elérendő célként azért vázoljuk fel a végeredményt ellenőrző kódot, így ha végeztünk a FizzBuzzTranslator-al, akkor egyből láthatjuk, hogy minden rendben van-e.
Ha a megoldást végiggondoljuk, akkor könnyen rájöhetünk, hogy a FizzBuzz számok 15 elemenként ismétlődnek. Ha megvizsgáljuk az első 30 elemet, akkor a megszorításokat és az ismétlődést is ellenőrizzük, így elég ennyi:
testDisplay_TakeFirst30_ReturnsFirst30FizzBuzzElements: function() { var expected = '1,2,Fizz,4,Buzz,Fizz,7,8,Fizz,Buzz,' + '11,Fizz,13,14,FizzBuzz,16,17,Fizz,19,Buzz,' + 'Fizz,22,23,Fizz,Buzz,26,Fizz,28,29,FizzBuzz', result = this.fizzBuzz.display(30); assertEquals(expected, result); }
A teszt tökéletesen megfelel a végső célként — mivel egyelőre messze vagyunk tőle, hogy működjön így most nyugodtan kommenteljük ki. Ez a módszer sokat segíthet a tervezésben, hiszen előre átgondolhatjuk a végeredmény összefüggéseit és azt, hogy esetleg milyen plusz interfészre van szükség a megoldáshoz.
Most újra zöld fázisban vagyunk, így megírhatjuk a következő elbukó tesztet, ezúttal már a fordító osztályba:
testGetValueOf_GivenNumberDivisibleByFive_ReturnsBuzz: function() { [5,10].forEach(function(number) { assertEquals('Buzz', this.translator.getValueOf(number)); }, this); }
A tesztben semmi meglepő nincs, futtassuk is az összeset, így megbizonyosodva arról, hogy valóban a piros fázisban vagyunk. Az implementáció is meglehetősen egyszerű:
FizzBuzzTranslator.prototype = { getValueOf: function(number) { if (number % 3 === 0) { return 'Fizz'; } if (number % 5 === 0) { return 'Buzz'; } return number; } };
Itt az ideje a refaktorálásnak — adja is magát a FizzBuzzTranslator, hogy egy kissé beszédesebbé tegyük:
FizzBuzzTranslator.prototype = { getValueOf: function(number) { if (this._isFizz(number)) return 'Fizz'; if (this._isBuzz(number)) return 'Buzz'; return number; }, _isFizz: function(number) { return number % 3 === 0; }, _isBuzz: function(number) { return number % 5 === 0; } };
Már csak egyetlen teszteset van vissza, a FizzBuzz érték — mivel a fejlesztési folyamat során már — tudtunkon kívül — igen jól megágyaztunk neki, így nem nehéz a teszt és az implementációja sem:
testGetValueOf_GivenNumberDivisibleByThreeAndFive_ReturnsFizzBuzz: function() { [15,30].forEach(function(number) { assertEquals('FizzBuzz', this.translator.getValueOf(number)); }, this); } // És a kizöldítéshez szükséges implementáció: var FizzBuzzTranslator = function() ; FizzBuzzTranslator.prototype = { getValueOf: function(number) { if (this._isFizzBuzz(number)) return 'FizzBuzz'; if (this._isFizz(number)) return 'Fizz'; if (this._isBuzz(number)) return 'Buzz'; return number; }, _isFizz: function(number) { return number % 3 === 0; }, _isBuzz: function(number) { return number % 5 === 0; }, _isFizzBuzz: function(number) { return this._isFizz(number) && this._isBuzz(number); } };
A feladatot abszolváltuk, az összes esetre lefutnak a tesztjeink. Most már visszakommentelhetjük a FizzBuzz osztály utolsó tesztjét is, ellenőrizve, hogy valóban jól működik az algoritmusunk.
A kód szebbé, átláthatóbbá tételét ezután se adjuk fel — mivel most védenek a tesztek, így könnyű dolgunk van, nem tudjuk elrontani a működést. Figyeljünk oda azonban, hogy sose módosítsuk egyszerre a tesztet és az implementációt, mert akkor inkonzisztens állapotot okozhatunk. Először mindig az egyiket módosítsuk és ha a tesztek lefutnak, csak azután írhatjuk át a másikat. Ne feledjük, hogy amíg a kód tesztje a teszt, addig a teszt tesztje a kód!
Fejezzük be a feladatunkat egy kis refaktorálással. A FizzBuzz tesztjei közül kivehetjük a redundánssá vált teszteseteket. Adott esetben, ha az javítja a dokumentáltságot, segíti az olvasót abban, hogy könnyebben megértse az osztály működését, akkor hagyhatunk bent redundáns teszteket, de vigyázzunk velük, mert ugyanígy meg is nehezíthetik a későbbi karbantartást.
TestCase('FizzBuzz', { setUp: function() { this.fizzBuzz = new FizzBuzz(); }, testDisplay_TakeZero_ReturnsEmptyList: function() { assertEquals('', this.fizzBuzz.display(0)); }, testDisplay_TakeFirst30_ReturnsFirst30FizzBuzzElements: function() { var expected = '1,2,Fizz,4,Buzz,Fizz,7,8,Fizz,Buzz,' + '11,Fizz,13,14,FizzBuzz,16,17,Fizz,19,Buzz,' + 'Fizz,22,23,Fizz,Buzz,26,Fizz,28,29,FizzBuzz', result = this.fizzBuzz.display(30); assertEquals(expected, result); } });
A FizzBuzzTranslator egy kis trükközéssel még rövidebbé és olvashatóbbá alakítható:
var ASSERT_SAME = 'assert_same'; TestCase('FizzBuzzTranslator', { setUp: function() { this.translator = new FizzBuzzTranslator(); }, testGetValueOf_GivenSimpleNumber_ReturnsTheNumber: function() { // Hagyjunk pár példát getValueOf használatára szem előtt // ezzel segítve a teszt dokumentációs jellegét assertEquals(1, this.translator.getValueOf(1)); assertEquals(2, this.translator.getValueOf(2)); [4,7,8,11,13,14].forEach(this.assertTranslation(ASSERT_SAME)); }, testGetValueOf_GivenNumberDivisibleBy3_ReturnsFizz: function() { [3,6,9,12].forEach(this.assertTranslation('Fizz')); }, testGetValueOf_GivenNumberDivisibleBy5_ReturnsBuzz: function() { [5,10].forEach(this.assertTranslation('Buzz')); }, testGetValueOf_GivenNumberDivisibleBy3And5_ReturnsFizzBuzz: function() { [15,30].forEach(this.assertTranslation('FizzBuzz')); }, assertTranslation: function(translation) { var assertion = function(number) { assertEquals( translation === ASSERT_SAME ? number : translation, this.translator.getValueOf(number) ); }; return assertion.bind(this); } });
A fenti példán jól látszik, hogy mennyi segítséget adnak a tesztek, hogy mennyire jó érzés olyan védőháló alatt dolgozni, ami folyamatosan biztosítja az alkalmazás helyes működését.
A TDD előnyeit teljes mértékben csak rengeteg gyakorlás árán élvezhetjük ki. Egy másfajta gondolkodásmódot kíván meg, mint amit korábban megszokhatott az ember — olyan tapasztalatokat kell megszerezni, ami nem megy máshogy, minthogy számos különböző projektben és helyzetben kipróbálja magát az ember. A programozáshoz hasonlóan a TDD is kis trükkök garmadájának ismeretét igényli.
Egy elérendő cél, hogy tesztjeink mindig gyorsak legyenek. Mivel minden lépés után le kell futtatni az összes tesztet, így könnyen belátható, ha percekben mérhető a tesztkészlet futási ideje, akkor nem fog sokáig tartani a fejlesztési lendület. Ha valakinek 10 másodpercnél tovább kell várakoznia, akkor figyelme könnyen elkalandozik. Próbáljuk a teljes tesztkészlet futási idejét 10 mp alá szorítani, de persze az a legjobb, ha ennél is sokkal gyorsabb. A mai gépek számítási teljesítménye mellett ez nem elérhetetlen, így jó ha odafigyelünk rá.
Fontos tisztában lenni azzal, hogy a TDD során elsősorban egységteszteket (unit teszt) próbálunk írni, azok minden szabályát betartva. Hogy ez mit jelent, talán Michael Feathers, az agilis fejlesztési módszertanok egyik úttörője és hangadója definiálta a legszemléletesebben: Egységteszt az a teszt, ami gyors. Ha egy teszt lassú, akkor az nem egységteszt!. Nem nevezhetőek unit tesztnek azok a tesztek, amelyek egy hálózaton keresztül más gépekkel kommunikálnak, amelyek használják a fájlrendszert vagy az adatbázist. Az ilyen tesztek lassúak, nehézkes definiálni őket és sokszor speciális környezeti beállításokat igényelnek. A TDD során az egyik legfontosabb szempont a tesztek egyszerűségének megőrzése.
Sokan tévesen úgy gondolják, hogy a különböző egységek (pl. osztályok) tesztjeinek teljesen függetlennek kell lenniük egymástól. Ez nem feltétlenül igaz, amíg a teszt gyors marad és azt a viselkedést vizsgálja, amire kíváncsiak vagyunk. Ha teljesen függetleníteni szeretnénk egymástól az osztályok tesztjeit akkor könnyedén beleesünk abba a csapdába, hogy az implementációt teszteljük és nem a viselkedést. Ez úgy fordulhat elő, hogy a vizsgálataink arra szorítkoznak, hogy A osztály meghívja-e B osztály adott metódusát, és használja-e a C osztályt. Ha így teszünk, akkor a refaktorálás egy kész rémálom lesz, egy egyszerű metódus átmozgatása egyik osztályból a másikba is problémás lehet — hiszen az implementációt ellenőrző tesztek mindegyikét módosítani kell.
A tananyag az ELTE - PPKE informatika tananyagfejlesztési projekt (TÁMOP-4.1.2.A/1-11/1-2011-0052) keretében valósult meg.
A tananyag elkészítéséhez az ELTESCORM keretrendszert használtuk.