TDD interjú kérdések

  • Mi volt az a cél a szoftverfejlesztésben, amit a TDD segítségével el lehet érni?
  • A TDD az mint tesztelési módszer vagy mint specifikációs módszer van inkább jelen a fejlesztésben?
  • Mivel jobb közvetlenül az implementáció előtt megírni az implementálandó kód tesztjét, mint közvetlenül utána?
  • A magas code coverage a TDD célja, vagy következménye? Ha a célja, miért? Ha a következménye, miért?
  • Milyen előnyei vannak a TDD által diktált kód tulajdonságainak?
  • Milyen hátrányai vannak a TDD által diktált kód tulajdonságainak?
  • Milyen előnyei vannak a TDD-s teszteknek?
  • Milyen hátrányai vannak a TDD-s teszteknek?
  • Milyen jellemzői vannak egy Mocking Framework-re épített tesztnek? Mi a hátránya a mock objektumok használatának?
  • Miért lényeges eleme a TDD-nek a Refactoring?
  • Mi a TDD és a loosely coupled kód kapcsolata?
  • Miért tartják lényeges elemnek a TDD-ben a “write failing test first” elvet?
  • Milyen esetekben nem lehet teljesíteni “write failing test first” elvet? Mi a megoldás?
  • Milyen indokok vannak amellett, hogy TDD esetén nem kell, hogy szülessenek privát metódusokhoz tesztek?
  • Milyen indokok vannak amellett, hogy alkalmanként lehet szükség egy privát metódus tesztelésére TDD fejlesztésnél? Hogyan lehet mégis elkerülni az ilyen helyzeteket? Mik az előnyök, hátrányok?
  • Mi a TDD és az kód optimalizáció (mint sebesség, memóriahasználat) kapcsolata?

Ha már…

…nem okoznak gondot a fenti kérdések, vagy lelkesen kutatsz a válaszok után, gyere hozzánk dolgozni! Az alábbi képre kattintva küldheted el jelentkezésed:

EPAM_Poster_1000

Mi a TDD lényege?

Sokan, akik ismerik – esetleg űzik – a TDD-t, erre a kérdésre a red/green/refactor ciklus ismertetésével válaszolnának. Mi ez a ciklus? A fejlesztő kiválaszt egy implementálandó követelményt. Ezután a követelmény implementálása előtt ír egy tesztet, amely a jövőbeli implementáció működését ellenőrzi. Ez a teszt vagy le sem fordul, de legalábbis nem fut le (red teszt). Ezután a fejlesztő megírja a funkciót, ami így kielégíti a korábban megírt tesztet (green teszt). A funkció megírása után a fejlesztő összenézi a korábban és most megírt kódokat, és szükség szerint refaktorálja azt. Ez tipikusan kódok kiemelése privát metódusokba, esetleg átnevezések, vagy funkciók kiszervezése külön osztályokba. Ezután kezdődik minden elölről.

Ez azonban nem a TDD lényege. A ciklus csak a módja annak, ahogy mindennapi rutinként azokat a célokat elérjük, amiért magát a TDD-t kidolgozták. Azért fontos ezt a különbséget tisztázni, hogy ne essünk a TDD-t dogmatikusan felfogó fejlesztők illetve a TDD-t egészében elutasítók hibáiba, és meg tudjuk tartani az egészséges egyensúlyt a mechanikusan betartott TDD ciklusok és a lehetséges egyéb megoldások között.

Agilis módszerek

A 90-es végén egy paradigmaváltás kezdődött a szoftverfejlesztésben. Ekkora már régen világos volt, hogy az egyik legfőbb problémát a fejlesztendő vagy karbantartandó szoftver követelményeinek változása okozza, és az is világos volt, hogy a változó követelményeket nem lehet kiküszöbölni. Emiatt olyan fejlesztési módszerek kellettek, amelyek együtt tudnak működni a követelmények változásával. Így születtek az agilis módszerek.

Hogyan élhetünk a változó környezettel?

A változó követelmények következményeként a szoftverfejlesztésben szinte mindig ugyanazok a hibák fordulnak elő. Az agilis módszerek ezeknek a tipikus hibáknak a kiküszöbölésére, de legalább mérséklésére törekszenek. Mik ezek a hibák?

Viszkózus architektúra

Az egyik jellemző hiba, hogy az eredetileg megálmodott architektúrát nem lehet olyan irányba “gyúrni”, hogy az új igények jól beleférjenek. Az előre kitalált váz sokszor elégtelennek bizonyul, emiatt elkezdődnek a keresztbe hack-elések, végül az eredeti koncepció felismerhetetlenné válik, a kusza kódhoz pedig többet senki nem mer hozzányúlni.

Törékenység

A törékenység némileg összefügg a viszkózus architektúrával – részben abból adódik, hogy az átláthatatlan architektúra miatt már kiszámíthatatlan, hogy adott módosítás milyen más funkciókra van hatással. Más esetekben abból adódik, hogy a programozók vagy eleve összenőve implementáltak eltérő felelősségű körű programrészeket, vagy ezek idővel – a módosítgatások által – összenőttek.

Inkrementális tervezés

Az inkrementális design – vagy az ugyanazt kifejező evolúciós design azon a beismerésen alapszik, hogy nehéz előre megmondani, hogy milyen szoftverszerkezet fogja jól kiszolgálni az eljövendő változtatási igényeket. Emiatt az agilis módszerek egy másik megközelítést alkalmaznak: elkezdik megvalósítani apró szeletenként azt, amit ma tudni lehet. Az apró szeleteknek fontos szerepe van: ugyanis az evolúciós design eleve arra készül, hogy a program szerkezetét folyamatosan változtatgatni kell. Ha a fejlesztés túl nagy darabokban – túl sokáig megy egy irányba, és ez az irány rossznak bizonyul, akkor onnan módosítani már nehezebb. Ha azonban szűk periódusokban, minden apróbb funkció után átnézzük a kódot, és azt mindig kerekre formázzuk, várhatólag kicsi módosítások történnek – hiszen egy apró hozzáadott új funkció jó eséllyel nem igényel teljes architektúrális módosítást. Az elvárás az, hogy a kicsi módosítások összességében egy helyes irányba viszik el a szoftver szerkezetét.

Folyamatos refaktor

Az inkrementális design/evolúciós desing alapeszköze a folyamatos refaktor. A refaktornak azonban vannak veszélyei – két teljesen különböző problémáról beszélhetünk.

Az egyik probléma, hogy a fejlesztő hajlamos elodázni a refaktort. Ez a hajlam két dologból táplálkozik. Egyrészt a természetes lustaságból. Az ember – a saját evolúciós designja miatt – energiaoptimalizálásra törekszik, emiatt nem szívesen csinál meg olyan teendőket, amiben nem látja a közvetlen hasznot, vagy úgy gondolja, hogy ráér az később.

Ha hozzáadtunk a programhoz egy viszonylag kicsi funkciót, és szemezünk a következővel, nem tűnhet úgy, hogy a refactor fontos – miért ne tudnánk megcsinálni a következő funkció után. A gond ezzel az, hogy a rákövetkező funkciónál is ugyanezt fogjuk gondolni, és a rákövetkezőnél is. Közben a szoftver felépítése egyre kevésbé kedvez az új funkcióknak, emiatt egyre több csúnyaságra kényszerül a fejlesztő – egyre nagyobb refaktorálási terhet tologatva – amely viszony egyre jobban ingerli az energiaoptimalizálási központját – azaz a feladatodázó agysejteket. A fenti folyamat olyan tipikus, hogy a jelenség saját nevet kapott: Technical Debt.

Módosításokból adódó hibák

A folyamatos refactor másik veszélye a megnövekedett esély arra, hogy meglévő funkciók elromlanak. Legyen akár mennyire átlátható egy program, és akár mennyire lelkes és felkészült a fejlesztő, ha egy programkódhoz hozzányúl, ott reális esély van hibázni.

A módosításokból adódó veszélyek kiküszöbölésére így az evolúciós design logikáját követő fejlesztéseknek olyan eszközt kell használniuk, amelyek védik a már meglévő és működő funkciókat.

Tesztek

Ehhez a védelmi eszközhöz nem kellett új ötlettel előállni az agilis módszertanok formálódásának az idejében sem. A kódjaikhoz a szorgalmasabb fejlesztőknek addig is volt módjuk teszteket írni – a kérdés inkább az volt, hogy ezt megtették-e, és ha igen, milyen alapossággal. A tesztek írása ellen ugyanis sok belső és külső tényező hat.

A leggyakoribb ellenérv, hogy “na nehogy már ez a három sor ne működjön, triviálisan egyszerű”. Ez méltányolható érv, ugyanakkor egy nagyobb program több százezer három sorból állhat, és ha csak minden ezrediknél nem figyel eléggé a fejlesztő, máris több száz hibát okoz.

Másik tipikus ellenérv az idő hiánya, illetve a haladás visszafogása. A fejlesztők – és a managerek – szeretik, ha tempósan tűnnek el a fejlesztendő funkciók, a tesztek írása viszont ezt visszafogja.

Nem elhanyagolható tényező a lustaság sem. Mint korábban említettük, az ember természeténél fogva hajlamos halogatni azokat a teendőket, amelyek nem adnak közvetlen hasznot. A tesztelés pedig nem ad közvetlen hasznot – a kód működik tesztek nélkül, legalábbis legnagyobb részben.

Hol tartunk most?

Láttuk, hogy a 90-es évekig ugyanazok a problémák jöttek elő szoftverfejlesztésben: a változó követelmények miatt mind a fejlesztés, mind a karbantartás nehézkes vagy kivitelezhetetlen volt. A programok arhitecturálisan idővel alkalmatlanná váltak a módosításokra.

Válaszként jöttek az agilis fejlesztési módszerek az inkrementális tervezés ötletével. Ennek kivitelezése folyamatos refactor-t igényel, ami viszont növeli a már működő kódokba utólag bevitt hibák esélyét. Ennek detektálására a meglévő kódokat alaposan lefedő tesztekre van szükség.

Azt is láttuk, hogy mind a refactor mind a tesztek írása könnyen esik áldozatul emberi jellemzőknek, mint a közvetlen haszon hiányából és lustaságból adódó elodázási hajlam.

A gyarlóság kezelése

Az emberi esendőség nagyon régi probléma – már több ezer éve is okozott gondot, főleg azoknak, akik szerették volna kedvükre irányítani a tömegeket, de a tömegek természete mást diktált volna. A megoldás is ismert évezredek óta: vallás kell, egyszerű szabályokkal, ahol a szabályok betartása a kívánt irányba tereli az embereket – függetlenül attól, hogy a kívánt irány önző vagy nemes célból lett meghatározva.

Ha a szabályokat sikerül ideologizálni, és adott ember elfogadja azt, utána a szabályokat mantraként ismételgetve azok áthágása kevéssé lesz valószínű. Ha pedig igen, egyfajta bűntudat kíséri – ami a következőkben újra csak a szabályok betartását ösztönzi. A “mantratizálás” a programozásban is működhet – és ennek kell tekinteni a red/green/refactor ciklust is.

Hogy az olvasó mentes a vallásra való hajlamtól? Erre kicsi az esély. Számtalan vallásból eredő szokás kíséri például a szoftverfejlesztést is, amit szinte mindenki kétkedés nélkül betart. Egyik tipikus példája a goto kerülése – akár sokkal esetlenebb megoldások árán is.

Agilis mantrák és céljaik

test first

Némely agilis megközelítés, mint például az extreme programming egyik, a programozói természettel leginkább szembemenő tétele a “test first” – azaz egy követelményhez előbb az annak teljesülését ellenőrző tesztet kell megírni, és csak utána a követelményt megvalósító kódot.

Azt hihetnénk, hogy azért van értelme a teszt megírását előre venni, mert így pszichológiailag nehezebb azt elodázni. Amint elkezdünk írni egy kódot teszt nélkül, máris áthágjuk a szabályt – a bűnbeesés pillanata nagyon explicit módon látszik.

Ebben talán van is valami, de a teszt előre vételének vannak más következményei, amelyek fontosak azoknak a problémáknak a kezelésében, amelyek érdekében az agilis módszerek létrejöttek.

Azt és csak azt

Az agilis fejlesztések jellemzője az inkrementális tervezés, aminek kivitelezéséhez a programot apró funkcióként építjük fel. Az apró funkció, mint szoftverépítési egység fontos az agilis módszer szempontjából, erről beszéltünk korábban. De mi az az “apró”?

A gond az “apró”-hoz hasonló laza definíciókkal az, hogy kedvünkre nyújthatjuk őket. Ha írunk egy osztályt, azt lendületből telirakhatjuk funkciókkal, és még mindig tekinthetjük aprónak, például mert elfér egy képernyőn.

Az előre megírt tesztnek az a fő előnye, hogy előre kijelöli az implementálandó funkció határait. Pontosan mérhetővé teszi, hogy melyik az a pont, ahol készen vagyunk, és ahol léphetünk a TDD ciklus következő elemeire, illetve egy következő követelmény implementálására.

Ha esetleg a fejlesztő még így is hajlamot érezne arra, hogy az előre megírt teszt által késznek jelzett programkód tupírozgatásával foglalkozna, miután egy követelmény (egy teszt) ki lett elégítve, a test first filozófia része, hogy kiköti, azt a legegyszerűbb kódot kell megírni, ami a tesztet működésre bírja. Ez gátat szab a “gold plating”-nek, ami a követelmények szempontjából feleslegesen vinne plusz komplexitást a kódba.

Követelmények darabolása

Egy másik, de az előzőekhez kapcsolódó következménye a tesztek előre vételének az, hogy segít önálló funkcionalitás darabokat generálni. Könnyű olyan kódot írni, ami egy lendületben megnyit egy adatforrást (file, szerviz, adatbázis), megszerzi az adatot, csinál vele valamit, majd visszaírja, esetleg továbbadja más komponenseknek. Hagyományos programozási szemlélettel ez egy kerek és egybefüggőnek tűnő feladat lehet. Azonban ebben a kódban összeforr az adat forrásának a kezelése az adaton végzett művelettel. Ez két funkció, ráadásul az egyik üzleti logika, a másik nem.

Ez a kettőség azonnal kiderül, ha megpróbálunk rá tesztet írni. Eltekintve attól, hogy file-hoz, adatbázishoz nem lehet unit tesztet írni (az már inkább integrációs teszt), a teszt egyik felének meg kellene nézni, hogy a kód a megfelelő adat-azonosítóhoz (filenév, rekord kulcsa, akármi) a megfelelő adatot szerzi meg. Ezután a tesztnek nézni kell, hogy az adaton a megfelelően hajtódott végre a művelet. Ez két teljesen eltérő ellenőrzés egy tesztben.

Ilyen komplex tesztet az legelvetemültebb programozók közül sem írna le mindenki, emiatt az “x Id-hoz tartozó adaton végezd el az y műveletet” követelmény kettéesik a “szerezd meg az x adatot” és a “egy x adaton végezd el az y” követelményekre – még mielőtt a kód írását elkezdenénk.

Kicsi követelmény, kicsi kód

Ha a unit tesztelés a fenti példánál a kód megírása után történik, a programozó vagy szétvágja a kódot utólag a kettéeső követelményeknek megfelelően, vagy nem. Ha viszont a követelmények a test first filozófiának köszönhetően már a kód megírása előtt “egységnyi méretre” zsugorodnak, akkor a hozzájuk később megírt kód nagy eséllyel nem keveredik más jellegű követelmények kódjaival. Ez a Single Responsibility Principle betartása felé vezeti a megoldást. Ez a principle az agilis fejlesztés egyik alapelve, amelyet a karbantartható programok készítése miatt érdemes figyelembe venni. Ilyen szoftverek készítése megoldható TDD nélkül is kellő figyelemmel. Ugyanakkor a TDD segíti a nem egybe tartozó funkciók szétválasztását.

Összefoglalva tehát, az agilis fejlesztés kiszolgálásához sok esetben az utólag írt unit tesztek is megfelelnének, azonban sokkal nagyobb tapasztalat és figyelem szükséges azonos eredmény eléréséhez. A test first megközelítés a specifikációt apró darabokra töri, mivel egy unit testben egyszerre egy dolog vizsgálható. A programozó ezután pontosan tudja, hogy a fejlesztendő kóddal meddig kell elmenni, elejét véve a felesleges munkának – ami az agilis módszerek egyik alapelve.

A fő újdonság és előny tehát a TDD-ben, hogy ezen a mikro szinten is bevezeti azt az elvet, hogy előbb definiáljuk a “kész” jelentését (Definition of Done), utána a legrövidebb úton érjük el. Ez az elv magasabb szinteken is megjelenik az agilis módszertanokban.

Teszt vagy specifikáció?

Az eddigiek alapján érződik, hogy a specifikáció és a teszt nagyon szoros kapcsolatban áll egymással. Ez így volt a test first elv megjelenése előtt is, hiszen a teszteket többnyire a specifikációk alapján készítik annak érdekében, hogy ellenőrizni lehessen, a program azt csinálja, amit elvárnak tőle.

Ha azonban megkérdeznénk egy profi tesztelőt, hogy mondjon vélemény egy tipikus TDD-s teszt készletről, valószínűleg ez a vélemény nem lenne túl jó. Miért?

A TDD-s tesztek csak a feltétel rendszer “széleire” mérnek, és másra nem. Hogy kell ezt érteni? Tegyük fel, hogy a specifikáció az, hogy ha a pénzösszeg nagyobb, mint 100 egység pénz, akkor legyen X, egyébként Y. Ez TDD-s megközelítésben szétesik két tesztre, mint “ha a pénzösszeg nagyobb, mint 100, akkor X”, illetve “ha a pénzösszeg nem nagyobb, mint 100, akkor Y”. Előbb a fejlesztő megírja a tesztet az első követelményhez, ez jó eséllyel 101-et használ teszt értéknek. A választás azért 101-re esik, mert test first ide vagy oda, a fejlesztő tudja, hogy ez valami if-fel lesz megvalósítva, és ha például 200-ra írná a tesztet, akkor lesz egy nagyobb “vakfolt” a tesztekben – és itt már kezdenek formálódni a bajok.

Nézzük előbb a “ha a pénzösszeg nagyobb, mint 100, akkor X” esetet. A fejlesztő megadja TDD-sen a legegyszerűbb implementációt, ami a tesztet kielégíti, ami most ez:

void f(decimal osszeg)
{
  X();
}

Ezután a fejlesztő veszi a következő követelményt, és implementálja tesztként. Milyen számra fog tesztelni? Valószínűleg 100-ra, megint csak az előbb említett vakfolt miatt. Ha például 50-re tesztelne, akkor a tesztek nem derítik ki azt az egyébként könnyen véthető hibát, ha 70 kerül a később implementálandó if-be 100 helyett.

A kódot ekkor a következő módon kell módosítani:

void f(decimal osszeg)
{
  if (osszeg > 100)
    x();
  else
    y();
}

Boldogok vagyunk tesztelő szemmel? Nem igazán. A tesztek jól kifejezik a specifikációt, de nem tesztelnek jól, azaz nem védenek igazán jól a hibák ellen. Pár elgépelést vagy figyelmetlenséget kivéd. A relációt nem lehet megfordítani, vagy véletlenül <=-nek írni. A 100-at nem lehet elrontani – pont ebben volt nagy szerepe a tesztben az érték kiválasztásának. Ha a tesztben más szám szerepelne, például az első tesztben 101 helyett 120 lenne, akkor a teszt vak lenne az if-ben előforduló hibás 110-es értékre – erre utaltunk a "vakfolt" kifejezéssel.

Ugyanakkor viszonylag könnyű úgy elrontani a kódot, hogy a két felírt teszt zöld maradjon, a kód mégsem feleljen meg a követelményeknek:

void f(decimal osszeg)
{
  if (osszeg != 100)
    x();
  else
    y();
}

Red/Green vs karakterizálás

Egy rendes unit teszt a fenti kódot nem csak két értékkel tesztelné. Minimum három, de inkább öt értéket adna meg a tesztelő. A 99, 100, 101-es értékek a “váltópont” körül fontosak. Ezenkívül hasznos egy (vagy több) kicsi illetve nagy érték, mint az 5 és a 700. Aztán lehet folytatni a furcsa értékekkel, mint 0, negatív számok, a decimal típus szélsőértékei, mint Decimal.MaxValue.

A TDD-s tesztekkel kapcsolatban viszont más elvárások vannak. A test first mantra kimondja, hogy a teszt legyen piros a megírásakor. Miért fontos ez?

Azért, mert ha a teszt piros, akkor valószínűleg tényleg olyan funkcióról van szó, ami még nincs implementálva. Ha a teszt azonnal zöld, akkor több eset lehet.

Egyrészt előfordulhat, hogy a teszt rosszul van megírva, ezért például mindig zöld. Vagy nem azt teszteli, amit szeretnénk. Másrészt előfordulhat, hogy a teszt által megfogalmazott követelmény már adódik egyéb funkciókból. Ekkor lehet, hogy a követelmény redundáns. De az is lehet, hogy nem – lehet, hogy a jövőben úgy változnak régi követelmények, hogy abból már nem következik az új. Akárhogyis, ha egy teszt zöld, akkor vizsgálódni kell.

A redundáns teszteket a TDD esetén nem szabad megtartani. Azért nem, mert a teszteknek karbantartási költsége van. Ha megváltozik egy követelmény, akkor a redundáns tesztek mindegyikét módosítani kell.

Másrészt a TDD-s teszteket a fejlesztők igen gyakran futtatják. Refaktorálások után, funkciók módosítása után ellenőrizni kell, hogy a régi funkciók nem sérültek. Minél több a redundáns teszt, annál több ideig tart a TDD-s teszteket futtatni, ami zavaró.

Egy jó unit teszt célja viszont az, hogy a funkció karakterisztikáját minél szélesebb körben megragadja és ellenőrizze. Emiatt többféle értékre tesztel. Ezzel ellentmondásban van a TDD-vel.

Az ellentmondás feloldása abban rejlik, hogy a TDD-s tesztek nem igazán nevezhetőek tesztnek. Unit tesztek abban az értelemben, hogy adott funkció működését izoláltan mérik, illetve formájukban és eszköztárukban megegyeznek a unit tesztekkel. De a fő szerepük az, hogy a specifikációt formalizáltan leírják, nem pedig az, hogy a funkciók karakterisztikájának a szélesebb spektrumát mérjék. Ennek megfelelően fontos kihangsúlyozni, hogy a TDD fejlesztés mellett is van szükség tesztelésre. Néhány esetben a TDD-s tesztek is bővíthetők jelentősebb overhead nélkül. A fenti példák esetében például alkalmazható a legtöbb testing framework által támogatott data driven test megoldás, ahol a keretrendszer több megadott adatra futtatja le ugyanazt a tesztet – remélhetőleg nem túl nagy időtöbblettel. Más esetekben viszont több külön teszt metódus írására kerülhet sor, ami már a TDD keretein belül nem számít javasolt megoldásnak – és ellene menne a “write failing test first” kitételnek.

A fejlesztő a legjobb teszter?

Egy újabb érv amellett, hogy a TDD tesztek nem a leghatékonyabb tesztek, hogy ugyanaz írja a tesztet, mint a tesztelendő funkciót. Ez koncepcionális hiba a tesztelés szempontjából. A tesztek egyik feladata, hogy validálják, a megvalósított funkció a szükséges követelményeket elégíti ki. A követelményekkel szemben egy lényeges dolog, hogy azt jól értelmezzék – az egyik tipikus hibaforrás a követelmények rossz értelmezése. Ha ugyanaz a személy írja a programot és a tesztet, ugyanazon félreértelmezés mentén valósítja meg mind a kettőt – így az értelmezési hiba kisebb eséllyel derül ki. Bizonyos módszerekkel – például pair programming, ez a jelenség csökkenthető, ha a pár egyik tagja írja a tesztet, és egy másik a megvalósítást.

Test first megközelítés egyéb következményei

Magas code coverage

Ez egy természetes következménye a test first megközelítésnek. Mivel ki van kötve, hogy a tesztet kielégítő legegyszerűbb kódot kell megírni, elméletileg nincsenek olyan programsorok, amelyek nem úgy kerültek a kódba, hogy ne egy teszt zöldé válását szolgálnák. Mivel minden sor egy (vagy több) teszt zöldé válásában részt vesz, szükségképpen következik, hogy egy (vagy több) teszt végigviszi rajta a vezérlést. Ebből elvileg 100% kód coverage következne. A gyakorlat azonban nem ennyire fekete/fehér.

Egyrészt vannak refaktorok, amikor a programozó újrastrukturálja a kódot. Itt a tesztek védenek attól, hogy a meglévő funkciók ne károsodjanak – azaz a kód funkcionálisan ne legyen kevesebb. Azonban semmi nem véd attól, hogy ne legyen a refaktor után több. Persze, lehet mérni tool-okkal, de ez tipikusan nem a napi gyakorlat része.

Másrészt azért nem minden kód egy TDD-s teszt által kerül bevezetésre. Számos egyszerű pattern van, amit a programozók egyformán szoktak implementálni – és itt nem feltétlenül a GoF-os design patterns-ről van szól. Sokszor a fejlesztő például az MSDN-ről másolja le, hogy hogyan néz ki egy Exception leszármazott sablonja. Itt elég magas fokú elvakultság kell ahhoz, hogy valaki az összes konstruktoron végigvigyen teszteket – ráadásul a követelmények ezt nem is indokolják.

Azokat a kódokat, amelyek nem unit tesztelhetőek, például mert file műveletekkel dolgoznak, vagy hálózattal, nem tudja a fejlesztő teljes körűen lefedni. Ez szintén csökkenti a 100%-os code coverage-t.

Összességében a code coverage a TDD-s fejlesztéseknél még így is magas. Ez növeli az esélyét, hogy bárhova nyúlunk a programba, egy esetlegesen bevitt hiba nem marad észrevétlen. Ugyanakkor láttuk az előzőekben a TDD-s tesztek korlátait – a regressziót egyéb módszerekkel is szűrni kell.

Tesztelhetőség

Ha nem test first jellegű a fejlesztés, akkor egy funkció fejlesztésénél könnyű elfeledkezni arról szempontról, hogy a funkciót izoláltan kellene tesztelni – pedig ez a unit tesztelés alapfeltétele.

Ha a kód nem a tesztelhetőség figyelembevételével készül, akkor gyakran csak egyéb funkciókon keresztül lehet tesztelni – sérül az izoláció, ami befolyásolhatja a teszt eredményét. Rosszabb esetben a fejlesztő könnyebben lemond a tesztelésről, ami viszont csökkenti a védelmet módosításoknál.

A unit tesztelhetőség elérhető TDD nélkül is kellő figyelemmel. Ugyanakkor a TDD természetes módon adja azokat a csatlakozás pontokat az izolált teszteléshez, amelyekre a fejlesztendő funkciót rá kell húzni, így az szükségképpen tesztelhető lesz.

Loosely coupled kód

A fent említett csatlakozási pontok jellemzően úgy működnek, hogy a tesztelendő funkció a függőségeit paraméterként kapja, esetleg template metódusként van implementálva.

Akárhogyis, az implementált funkciónak minimum két eltérő környezetben kell működnie – egyrészt a unit teszt által felépített környezetben, másrészt az éles környezetben. Mivel a kód eleve úgy van felépítve, hogy több független környezetben is működőképes legyen, a függőségek működésében végbemenő változások kisebb eséllyel ütnek vissza a kódra.

A loosely coupled tulajdonság megint csak olyan dolog, amit el lehet érni TDD nélkül is, azonban a TDD kikényszeríti ezt a tulajdonságot.

Törékenység elleni védelem

Az egyik jellemző szoftverfejlesztési probléma, hogy a módosítandó kód törékeny – egy adott pontján történő módosítás hatással van olyan funkciókra, amelyre a programozó nem számít. A TDD a program működésének egy specifikációját és annak ellenőrző eszközét szolgáltatja. Emiatt ha egy módosítás hatással van bármire, ami azt okozza, hogy nem felel meg tovább a specifikációnak, azt egy vagy több teszt jó eséllyel jelezni fogja, pontosan rámutatva, hogy melyik funkciók sérültek.

Elvileg a törékenység elleni védelem nem TDD-s unit tesztekkel is elérhető. Ha azonban a kód nem a tesztelhetőség jegyében készült – és ez könnyen előfordulhat nem TDD-s fejlesztésnél – akkor lehet, hogy nincs mindenre unit teszt (csak a “fontos” funkciókra, jelentsen a “fontos” bármit), vagy lehet, hogy bizonyos funkciók közvetve teszteltek. A közvetett tesztelésnek az a problémája, hogy nem mutatja pontosan meg, hogy hol a hiba, a programozónak több időt kell tölteni nyomkövetéssel. A karbantartható kód nagyon fontos az agilis fejlesztéseknél, ennek érdekében a folyamatos refactor az agilis fejlesztések része. Mivel a folyamatos refactor a kód folyamatos átírását jelentheti, ez jelentősen növeli a véletlen hibázások veszélyét. Emiatt agilis fejlesztéseknél szinte elengedhetetlen, hogy a funkciók tesztekkel le legyenek fedve – amit pedig a TDD fejlesztés szabályaiból természetes módon adódik.

Másik oldalról láttuk, hogy a TDD-s tesztek hatékonyságukban gyengébbek a rendes unit teszteknél – ez abból adódik, hogy a tesztelendő funkció karakterének jellemzően kisebb részét fedi le, mint egy gondosan megírt unit teszt készlet.

Tesztek karbantartása

Az eddigiek meggyőzőnek tűnhetnek. Az árnyoldal, hogy a TDD-s teszteknek ugyanúgy megvan a karbantartási igénye, mint a többi programkódnak. Ha egy követelmény megváltozik, akkor egy többlépcsős folyamat indul el.

Egyrészt a megváltozott követelményhez meg kell írni az új teszteket. A tesztekhez módosítani kell a kódot – ez eddig nem különbözik olyan sokban az új funkciók implementálásától.

A módosítások azonban néhány tesztet eltörnek, amelyeket újra kell értelmezni a régi és az új követelmények szemszögéből. Néhányat törölni kell, néhányat módosítani, esetleg pár tesztet összevonni. Elvileg ennek nem kellene problémát okoznia, ugyanakkor a gyakorlat azt mutatja, hogy néha utólag nehéz összekötni a teszteket az eredeti követelményekkel, illetve értelmezni őket az új követelmények kontextusában.

Unit tesztelhető, de csúnya

Szó volt róla, hogy a TDD olyan kódot generál, amin megfelelő csatlakozás pontok vannak a teszteléshez. További előny, hogy ezek a csatlakozási pontok nem csak teszteléshez használhatóak, hanem flexibilissé teszik a kódot azáltal, hogy a kód környezetét tetszőlegesen lehet cserélgetni, a kód loosely coupled.

Valójában a TDD egyik előnyeként szokták említeni, hogy helyes irányba segíti az architektúrát. Ez a kijelentés három dologból táplálkozik. Az egyik, hogy TDD alatt a fejlesztő csak a minimálisan szükséges kódot írja meg, emiatt a kód a lehetőségekhez képest egyszerű marad. A másik, hogy a TDD ciklusának elemi része a refactor, azaz a fejlesztő (elvileg) nagyon rövid időszakonként átnézi a kódját, és szükség szerint rendbe teszi. Harmadjára pedig a loosely coupling miatt a kód flexibilis, ami nagy előny a karbantarthatóság szempontjából.

Vannak azonban ellenző hangok, amelyek szintén nem értelem nélküliek, így megfontolandóak.

Az egyik legfőbb ellenérv, hogy a TDD nagyon sok interfészt és ezáltal absztrakciót vezet be – a kód így szétaprózódik ezernyi absztrakció mentén, amit azután nehéz fejben egy másik programozónak összerakni. Hogy miről van szó, talán érdemes egy példán megnézni.

Példa hagyományos megoldása

A feladat legyen egy “file mergelő” – nem igazi mergelésről lesz szó – ami a következőképpen működik:

  • adott egy target file, ennek a tartalmához lehet mergelni több más file-t.
  • a file-okat egyesével lehet hozzámergelni a target file-hoz.
  • egy file mergelése a targethez a következőt jelenti:
    • a két file tartalmát össze kell XOR-olni.
    • ha valamelyik file rövidebb, a másik file maradék tartalmát módosítás nélkül az XOR-olt rész mögé kell másolni – ami egyébként megfelel annak, mint ha a továbbiakban a 0 értékez XOR-olnánk tovább.

Hagyományos módon a program megírásának a következő módon esnénk neki:

  1. Létrehozunk egy FileMerger osztály vázat, ez implementálja majd a funkciót.
  2. A fenti leírás megnevez egy target-file-t, ehhez kell a többi file-t mergelgetni. Emiatt a FileMerger megkapja konstruktorban a targetFile nevét.
  3. A mergelés file-okként történik, emiatt a létrehozott osztály kap egy MergeWith() metódust, ami paraméterként megkapja a mergelendő file nevét.
  4. Minden egyes mergelés felfogható egy befejezett műveletnek, emiatt állapotokat az osztálynak nem kell nyilvántartania. A target file megnyitható a művelet elején, és lezárható a végén, emiatt nem kell IDisposable a file lezárásának érdekében.

Az osztály tehát így néz ki:

class FileMerger
{
    private string targetFileName;

    public FileMerger(string targetFileName) ...
    public void MergeWith(string sourceFileName) ...
} 

A konstruktor egyszerűen eltárolja a target file nevét, ennek implementációja most nem érdekes.

A MergeWith több lépcsőben dolgozik:

  • nyilván meg kell nyitni a target és a source file-t.
  • A target file tartalmát írni is és olvasni is kellene az XOR-hoz. Ugyanakkor ez a .NET lehetőségeit nézve furcsa lenne. Egy file olvasás automatikusan előre lépteti a file mutatót, ami miatt folyamatosan seek-elni kellene vissza egyet az íráshoz. Jobbnak tűnik egy ideiglenes állományt írni, majd azt visszamásolni a target file helyére. Ennek a második megoldásnak az az előnye is megvan, hogy hiba esetén könnyebb lehet a recover – hiszen az inkonzisztens állapot rövidebb ideig áll fent, így kisebb eséllyel marad kusza adattartalom.

Az eddig elmondottak így néznek ki:

public void MergeWith(string sourceFileName)
{
    var tempFileName = Path.GetTempFileName();

    try
    {
        using (var tempResultFile = File.OpenWrite(tempFileName))
        using (var targetFile = File.Open(this.targetFileName, FileMode.OpenOrCreate, FileAccess.Read))
        using (var sourceFile = File.OpenRead(sourceFileName))
        {
		  ... az xor-ozás
        } // using

        // eredmény az ideiglenes file-ban, csere a targettal:
        File.Delete(this.targetFileName);
        File.Move(tempFileName, this.targetFileName);
    }
    catch
    {
        File.Delete(tempFileName);
        throw;
    } // catch
} // MergeWith()

Ezután meg kell valósítani a konkrét mergelési műveletet, ami egy egyszerű ciklus:

int targetByte;

while ((targetByte = targetFile.ReadByte()) >= 0)
{
    int sourceByte;
    if ((sourceByte = sourceFile.ReadByte()) >= 0)
    {
        // ekkor még mind a két stream tartalmazott adatot
        // xor, és írás az ideiglenes file-ba
        var result = sourceByte ^ targetByte;
        tempResultFile.WriteByte((byte)result);
    }
    else
    {
        // itt a source már üres. Van egy beolvasott byte
        // a targetByte változóban, illetve némi tartalom a targetFile-ban
        // Ezeket kell az ideiglenes file-ba írni.
        tempResultFile.WriteByte((byte)targetByte);
        targetFile.CopyTo(tempResultFile);
        break;
    } // else
} // while

// ha esetleg a source még nem üres (mert a target a rövidebb)
// a source tartalmát XOR nélkül az ideiglenes file-ba másolni.
sourceFile.CopyTo(tempResultFile);

Ennek az osztálynak a célját és a működését könnyű megérteni. Ha egy programozónak hozzá kell nyúlni, rövid idő után egyetlen egy képernyőt nézve meg lesz a teljes koncepció, hogy mit csinál az osztály.

A tesztelés is megoldható, bár kicsit körülményesebb egy unit tesztnél. Mi a körülményes benne? Lokális gépen dolgozva talán nem gond, de egy build szerveren futtatott teszteknél már az lehet, hogy a teszt file műveleteket végez, ami jogosultsági problémákkal jár, illetve némi kockázattal. A teszthez file-okat kell létrehozni, és bármi történik törölni kell őket, különben idővel tele lesz szeméttel a háttértár. De tegyük fel, hogy ezen túllendültünk, és összességében elégedettek vagyunk. Nézzük most meg, mit generál egy TDD-s fejlesztés.

Példa TDD megoldása

Elsőként meg kell fogalmaznunk a fenti követelmények közül egyet, hogy megírjuk hozzá a kódot. Kezdjük a speciális esettel, amikor a két file egyforma. Azonban gyorsan rá kell jönnünk, hogy nem fogunk tudni file-okkal dolgozni. Miért nem?

Izolált és stabil tesztek

Egyrészt, mert az már nem unit teszt lesz, TDD-ben pedig unit tesztelünk. File-okat, hálózatot, hasonló “külső” dolgokat nem szabad unit tesztbe belevenni, mert megtöri azt a törekvést, hogy a kódot izolálva teszteljük. Mit jelent az izoláció? Hogy a kód ne függjön más egységektől, mégpedig azért ne, hogy a teszt eredménye csak a tesztelendő kód viselkedésétől függjön. Ezt úgy tudjuk elérni, hogy a függőségeket, amivel a tesztelendő unit kapcsolatot tart, kicseréljük egy könnyűsúlyú másolatra, egy úgynevezett Test Double-ra, aminek a működésére közvetlenül tudunk hatni a tesztből, és csak a tesztből hatunk rá – más nem befolyásolja a működésüket.

A file vagy hálózat azért rossz, mert túl sok minden hat rájuk, ami sikertelenné teheti a tesztelést. Mi hathat rájuk? Jogosultsági problémák, párhuzamosan futó tesztek, timeout problémák a hosszabb lefutás miatt.

Gyors tesztek

Másik oldalról, egy nagyobb program több ezer unit teszttel bírhat, amit egy build szerver (de akár egy lokális build) végre fog hajtani. Ha ezek a tesztek hálózatot és file-t kezelnek, irdatlan hosszúra meg tudják nyújtani a tesztek lefutását. Nincs baj akkor a teszt futásidejével, ha azok x naponta például regressziós tesztként el vannak indítva, és mindenki tudja, hogy másnap reggelre, vagy két nap múlva lesz kész. De akkor sokan néznek morcosan, ha az a szabály, mielőtt valamit feltöltünk a source control alá, az összes TDD-s tesztnek zöldnek kell lennie. Ilyenkor nem örülnek a programozók az órás futásidőnek, amit ki kell várniuk – főleg, ha az agilis módszerek által megkövetelt Continuous Integration-t is gyakorolják.

Stabil függőségek

Ott tartunk tehát, hogy a file-ok nem jók, Test Double kell – de van még egy lehetőség. Nincs baj akkor, ha “stabil függőség”-e van egy unit-nak, és ez a stabil függőség viszonylag könnyűsúlyú. Mi lehet stabil függőség? Olyan osztályok, amelyek kiteszteltnek tekintettek, és nem változnak. Tipikusan a .NET keretrendszer osztályai ilyenek. Persze, itt sem lehet mindent használni, figyelembe kell venni például az erőforrás igényeket, illetve a konzisztens viselkedést – azaz a teszt minden egyes lefutásakor a stabil függőség ugyanúgy viselkedik. Egy ellenpélda erre a DateTime.Now property használata.

Visszatérve az eredeti problémára, ha nem használhatunk file-t, de használhatunk stabil függőséget, akkor egy olyan megoldást kell találni, ami file-szerűen működik, és a .net keretrendszer része.

Stream mint stabil függőség

Erre az igényre természetesen adódik a Stream osztály használata, már csak azért is, mert ennek az osztálynak mind a teszteléshez, mind végső implementációjához rendelkezésre állnak leszármazottak. Teszteléshez a MemoryStream például kiváló stabil függőség, élesben pedig használhatóak FileStream-ek.

Egy TDD teszt írása

Ennyi bevezető után megírhatjuk az első tesztünket, a példában az Microsoft Unit Testing Framework-öt fogjuk használni.

A unit tesztek jellemzően három logikai részből állnak: az első részben felépítjük a teszt környezetét. A második lépésben végrehajtjuk a tesztelendő funkciót, a harmadik lépésben pedig ellenőrizzük, hogy a funkció a várt eredményt adta-e meg. Ezt a hármast gyakran Arrange/Act/Assert hármasnak mondják.

Mi a kontextus?

Mi most azt az esetet fogjuk tesztelni, amikor a két – most már nem file, hanem stream – azonos méretű adatot tartalmaz:

Ekkor az arrange rész így néz ki:

[TestMethod]
public void ShouldMergeStreamsWithSameDataLength()
{
    // Arrange
    var targetData = new byte[] { 0x3f, 0x8b, 0x29 };
    var sourceData = new byte[] { 0xa6, 0x74, 0x7b };

    var targetStream = new MemoryStream();
    var sourceStream = new MemoryStream();

    targetStream.Write(targetData, 0, targetData.Length);
    targetStream.Seek(0, SeekOrigin.Begin);

    sourceData.Write(sourceData, 0, sourceData.Length);
    sourceData.Seek(0, SeekOrigin.Begin);

Látható, hogy van egy ismétlődő rész: stream létrehozás, feltöltés adatokkal, seek 0. Erre be lehet vezetni egy segédfüggvényt valószínű később is használni kell majd.

Mit csinál a fejlesztendő kód?

Most eljött az a pillanat, hogy végig kell gondolnunk, hogyan néz majd ki a tesztelendő osztály. Az osztálynak azt kell tudnia, hogy adott egy target stream, és rendelkezik egy metódussal, ami a target stream-re mergeli az átadott source stream-et. Ez így szimmetrikus a nem TDD-s megoldásunkkal, ráadásul majd könnyen fölé húzhatunk egy olyan adapter osztályt, ami stream-ek helyett file nevekkel működik, megkapva az eredeti interfészt – de ezeket a szempontok az incremental design miatt most nem kell figyelembe venni. Tehát ahogy az osztályt majd használni szeretnénk, az valami ilyesmi:

// act
var suv = new StreamMerger(targetStream);
suv.MergeWith(sourceStream);

Mit várunk el?

Amit ellenőriznünk kell, az az, hogy a targetStream valóban az XOR-olt értékeket tartalmazza. Ehhez előbb egy számológéppel ki kell számolni, mi lesz az eredmény. Valakit csábíthat az ötlet, hogy számológép helyet a tesztbe ír egy for ciklust, ami kiszámolja az eredményt, de ezt lehetőleg ne csináljuk. Miért ne? Azért, mert könnyű megjósolni, hogy az implementálandó kód is ciklus lesz – és így könnyűszerrel vétjük ugyanazt a hibát, ami azzal jár, hogy a teszt lefut, mivel hibásan teszteli a hibás metódust. Nem biztos, hogy így lesz, de van kockázata. Ezért a windows calculator programmal be kell pötyögni a pár értéket, ekkor meg lesz az elvárt tömb, amit pedig összehasonlítunk a targetStream-ben létrejött eredménnyel:

// Assert
var expectedData = new byte[] { 0x99, 0xff, 0x52 };
CollectionAssert.AreEqual(expectedData, targetStream.ToArray());

A teljes teszt kód végül így néz ki:

[TestMethod]
public void ShouldMergeStreamsWithSameDataLength()
{
    // Arrange
    var targetData = new byte[] { 0x3f, 0x8b, 0x29 };
    var sourceData = new byte[] { 0xa6, 0x74, 0x7b };

    var targetStream = SetupMemoryStream(targetData);
    var sourceStream = SetupMemoryStream(sourceData);

    // Act
    var suv = new StreamMerger(targetStream);
    suv.MergeWith(sourceStream);

    // Assert
    var expectedData = new byte[] { 0x99, 0xff, 0x52 };
    CollectionAssert.AreEqual(expectedData, targetStream.ToArray());
}

A funkció implementálása

Az implementálandó osztály váza a fentiek alapján így néz ki:

public class StreamMerger
{
    private Stream targetStream;

    public StreamMerger(Stream targetStream)
    public void MergeWith(Stream sourceStream)
} // class StreamMerger()

Ez eddig nagyon hasonló az eredeti megoldáshoz. A konstruktor kódja triviális, a MergeWith() kódjánál viszont gondban leszünk. Mi a gond? Az, hogy van két összefűzendő stream, és megint csak seek-elgetni kellene – vagy pedig kell egy ideiglenes stream. Az ideiglenes stream pedig probléma. Miért probléma? Azért, mert a StreamMerger absztrakt Stream osztállyal dolgozik, viszont az ideiglenes stream-nek konkrét implementáció kell – hiszen létre akarjuk hozni, teljes funkcionalitásában használni akarjuk. Arról pedig nincs információnk, hogy milyen konkrét példányt kell létrehozni – illetve van, mivel eredetileg File-okkal akartunk dolgozni. De egyrészt egy FileStream létrehozása megint tesztelhetetlenné tenné az osztály-t, mivel olyan függősége lesz, ami a file rendszert módosítja. Másrészt – bár ez ízlés kérdése – ha eljutottunk egy absztrakciós szintre, mint a Stream, akkor valahogy csúnya lenne ezt gyengíteni a metódusban egy konkrét implementációval. De a tesztelhetetlenség miatt ez amúgy sem járható út, szóval lényegtelen, ki mennyire látná egyébként csúnyának itt a FileStream példányt.

Kell valami? Majd “Future me” megoldja!

A problémát metóduson – osztályon belül nem tudjuk feloldani, emiatt a felelősséget ki kell tolni az osztályon kívülre – így a továbbiakban elvárjuk, hogy egy olyan valamit kap a StreamMerger példány, ami szolgáltatni tudja az ideiglenes területet. Ez a valami egy ITempStreamProvider néven keresztül működik. Annyit tud, hogy visszaad egy stream-et, ami le bír kezelni annyi adatot, amennyi a mergeléshez kell – hogy mennyi adatról van szó, és hogy ehhez milyen stream kell, majd az osztály használója tudja.

Lesz még egy követelmény, mégpedig az ideiglenes stream nem hagyhat szemetet, például ha file alapú, akkor törölje a stream mögötti file-t. De ez egy új követelmény, a jelenleg implementálandó kódnak nem témája. Később erre visszatérünk. Ezek alapján:

public interface ITempStreamProvider
{
    Stream GetTemporaryStream();
}

A StreamManager pedig ilyen lesz:

public class StreamMerger
{
    private Stream targetStream;

    public StreamMerger(Stream targetStream, ITempStreamProvider tempStreamProvider)
    public void MergeWith(Stream sourceStream)
} // class StreamMerger()

Dependency Inversion Principle

Megtörve a fejlesztés menetét, egy pillanatra álljunk meg, mert egy érdekes momentumnak lehetünk tanuja. Be lett vezetve egy interface, és ennek az interfésznek van egy lényeges tulajdonsága: a StreamMerger osztály szemszögéből készült. Miért érdekes ez? Azért, mert megvalósításnál majd annak kell alkalmazkodni, aki a StreamMerger-t használja. Ezáltal a StreamMerger, ami egy üzleti logikát megfogalmazó kód teljesen független a környezettől, így az implementációs részletektől. Ez a Dependency Inversion Principle egy lényeges eleme. Az igazi DIP-hez persze az kellene, hogy az ITempStreamProvider csak is kizárólag a StreamMerger szolgálatában álljon, más osztály ne épüljön rá – ellenkező esetben egy külön irányba induló evolució nagyobb refactor igény elé állít minket.

…Vissza a teszthez…

Az új interfész bevezetésével már nem elégséges a tesztünk sem, azt újra kell írni. Mindenekelőtt kell egy konkrét TempStreamProvider. Szerencsére nem kell túlzottan sokat dolgozni, hogy legyen egy használható implementáció:

class TestStreamProvider : ITempStreamProvider
{
    public Stream GetTemporaryStream()
    {
        return new MemoryStream();
    }
}

A teszt metódus Act része pedig a változásoknak megfelelően így néz ki:

// Act
var suv = new StreamMerger(targetStream, new TestStreamProvider());
suv.MergeWith(sourceStream);

…Vissza a funkcióhoz…

Most már el lehet kezdeni a MergeWith() törzsét:

public void MergeWith(Stream sourceStream)
{
    var tempResultStream = this.tempStreamProvider.GetTemporaryStream()

    int targetByte;

    while ((targetByte = this.targetStream.ReadByte()) >= 0)
    {
        int sourceByte = sourceStream.ReadByte();
        var result = sourceByte ^ targetByte;
        tempResultStream.WriteByte((byte)result);
    } // while

    this.targetStream.SetLength(0);

    tempResultStream.Seek(0, SeekOrigin.Begin);
    tempResultStream.CopyTo(this.targetStream);
} // MergeWith()

Első lépésként a metódus szerez egy ideiglenes stream-et, ebbe fogja kiszámolni a merge-elt eredményt. Ezután indul egy ciklus, ami a targetet olvassa. Olvashatná a source-ot is, igazából mindegy. A ciklus magja már nem ellenőriz a source stream olvasásánál, hogy van-e input. A teszt követelményeiből kiderül, hogy a két stream adattartalma egyforma méretű, és TDD szerint a legegyszerűbb kódot kell megadni. A különböző hossz kezelését majd más tesztek kikényszerítik. Ugyanígy, hibakezelés nem témája a tesztnek, így nem foglalkozik vele a kód sem. Emiatt a ciklus lépésenként kiszámolja a target stream új tartalmát, amit egyelőre nem volna praktikus visszaírni a seek-elés miatt, emiatt azt az ideiglenes stream-be tölti.

Ha lefutott a ciklus, akkor jön el az ideje a kiszámolt tartalmat visszaírni a target stream-be. Ehhez a target stream méretét nullára állítja – ezt egyébként nem minden stream támogatja, és ezt akár ellenőrizni is lehetne – amihez új TDD-s teszt kell, ami megnézi, hogy viselkedik a metódus különböző képességű stream-ek esetén. De ez nem a mostani feladat. Végül a metódus rááll az ideiglenes stream elejére, és a tartalmát a target-re másolja.

További követelmények

Az első lépésen tehát túl vagyunk – kell még teszt a különböző hosszúságú stream-ekre, illetve az előbb előbukott egy új követelmény – mégpedig az, hogy az ideiglenes stream-nek jelenzni kell, hogy törölheti magát. Erre kézenfekvő megoldás a Dispose() használata, tehát a követelményünk ennek hívását írja majd elő.

Foglalkozzunk előbb a törléssel. Ennek kifejezésére egy új teszt kell, és kicsit okosítani kell a StreamProviderünket is, illetve nem ússzuk meg azzal, hogy szimplán MemoryStream-et használunk. Nézzük előbb a tesztet:

Az Arrange és Act rész nagyon hasonló az előbbihez, azt leszámítva, hogy nem érdekelnek az adatok, viszont majd kell nekünk, hogy a TestStreamProvider mit adott oda a StreamManager-nek. Ezért őt eltároljuk egy változóba:

// Arrange
var targetStream = SetupMemoryStream(new byte[0]);
var sourceStream = SetupMemoryStream(new byte[0]);
var streamProvider = new TestStreamProvider();

// Act
var suv = new StreamMerger(targetStream, streamProvider);
suv.MergeWith(sourceStream);

Az Assert résznél rá kellene ellenőrizni, hogy az a stream, amit a streamProvider adott, most Dispose()-olva lett. Ezzel két gond van. Az egyik, hogy a TestStreamProvider nem tudja visszaadni, hogy mivel szolgálta ki az őt hívót. A másik, hogy a most használt MemoryStream nem mondja meg, hogy Dispose-olva lett. Ki lehet deríteni, mert képes exception-t dobni, ha dispose után használni próbáljuk, de ez egy kicsit drasztikus és gányolós egy tesztben.

Létre kell hát hozni a megfelelő osztályokat. Az egyik egy olyan MemoryStream leszármazott, ami megadja az állapotát Disposed szempontjából:

class TestMemoryStream : MemoryStream
{
    public bool IsDisposed { get; private set; }

    public TestMemoryStream() : base() 
    {
        this.IsDisposed = false;
    }       

   protected override void Dispose(bool disposing)
    {
        base.Dispose(disposing);
        this.IsDisposed = true;
    }
}

Illetve fel kell okosítani a stream provider-t, hogy visszaadja az elkért streamet:

class TestStreamProvider : ITempStreamProvider
{
    public TestMemoryStream LastRequested { get; set; }

    public Stream GetTemporaryStream()
    {
        this.LastRequested = new TestMemoryStream();
        return this.LastRequested;
    }
}

Most már lehet implementálni a teszt Assert részét:

// Assert
Assert.IsTrue(streamProvider.LastRequested.IsDisposed);

A teszt természetesen nem fut le. Ehhez módosítani kell a MergeWith() metódust:

public void MergeWith(Stream sourceStream)
{
    using (var tempResultStream = this.tempStreamProvider.GetTemporaryStream())
    {
       ...
    }
} // MergeWith()

A módosítás után a teszt már lefut.

Most meg kell írni a target stream nagyobb / source stream nagyobb eseteket, sőt a többszörös mergelést. Ennek megfelelően módosul a program, ezek itt most kevéssé érdekes lépések. A lényeg, hogy a végén lesz egy működő StreamMerger osztály – ami majdnem jó, csak nem erre volt eredetileg szükség. A továbbiakban is egy FileMerger osztályt szeretnénk. Ezt el lehet készíteni a StreamMerger segítségével, ugyanakkor mivel a FileMerger file alapú lenne, nem lehet unit tesztelni. Erre még visszatérünk, de előbb gyorsan nézzünk meg pár érdekesebb kérdést.

Honnan jönnek a specifikációk?

Egyfelől világos, hogy a fejlesztendő termék megrendelőjének van elképzelése, hogy milyen programot szeretne, és ez generál valamiféle specifikációt. Az is világos, hogy a megrendelő specifikációján még sokat kell dolgozni, mire egy fejlesztő számára emészthető formájú lesz. Erre megvannak a kialakult módszerek TDD nélkül is, és ezek a módszerek adnak egy jó inputot a TDD-s fejlesztéshez.

Korábban említésre került, hogy a TDD tördeli a specifikációkat – hiszen a specifikáció tesztre fordítása közben fennáll az az igény, hogy az elkészült teszt csak egy dolgot mérjen.

Másrészt a korábbi példában láttuk, hogy fejlesztés közben is előugranak “specifikációk” – például hogy kell egy ideiglenes stream, és annak meg kell hívni a dispose-zát használat közben. Két alapvetően különböző specifikáció halmazzal van tehát dolgunk: az egyik, amit a szoftver megrendelője támaszt, a másik, amit a szoftver menet közben formálódó architektúrája. Ráadásul ezek a specifikációk “darabokra törnek”.

A TDD-s fejlesztés inputja így nem egy konstans valami. A gyakorlatban a specifikációkkal úgy kell eljárni, hogy egy listába rendezzük őket. Amikor leveszünk egyet a listából, akkor több dolog történhet.

Lehet, hogy specifikáció-tördelés lesz, ami azt okozza, hogy a listába visszateszünk most már kisebb specifikációkat – egyet leszámítva, amit éppen fejlesztünk.

Máskor, a lefejlesztett teszthez készített program generál új követelményeket. Ezeket szintén a listára kell írni, hogy a következő körökben feldolgozásra kerüljenek.

Hogy ez a lista valamilyen szoftveres eszközzel támogatott, vagy egy papír az asztalunkon, a TDD szempontjából lényegtelen.

Tesztek tesztelése

A példa alapján feltűnhetett, hogy már ahhoz osztályokat kell készíteni, hogy a tesztek egyáltalán leírhatóak legyenek. Jogosan merül fel a kérdés, hogy a TDD-s tesztek teszt osztályait TDD jellegűen fejlesszük, azaz például a TestMemoryStream osztályhoz kellenek-e tesztek. A válasz az, hogy gondolkodjunk el rajta. A TestMemoryStream csak egy teszt osztály, ráadásul pár soros. Másik oldalról, ha az IsDisposed rosszul működik, akkor azok a tesztek is rosszul működnek, amelyek a TestMemoryStream segítségével vannak felépítve. Harmadik oldalról, a Red/Green ciklus miatt, ha a TestMemoryStream rosszul működik (pl mindig false, mert nem úgy hívódik meg a Dispose, ahogy gondolom), akkor a teszt nem fog Green-be menni (vagy nem lehet Red-be rakni). Ráadásul, ha a tesztekhez is írok tesztet, azzal ráhatványozok a karbantartandó kód mennyiségére. Mégis, mivel a TestMemoryStream már viszonylag bonyolult mechanizmust használ (A MemoryStream-ben implementált Dispose pattern-ba épül bele egy virtuális metóduson keresztül), ebben az esetben nem árt tesztelni külön sem, hátha nem látom át jól a működését. Más egyéb esetben, ha a kódon egyértelműen látszik, hogy működik, akkor elég az a biztonság, hogy ha mégsem működne, az őt felhasználó teszteken keresztül úgyis valószínű kiderül. Az eredeti kérdésre, miszerint kell-e teszt kódot tesztelni, a válasz az, hogy a teszt kód bonyolultságától függ.

Meddig váltsd ki a függőségeid?

Van egy veszélye a TDD-nek, mégpedig az, hogy ha valaki nagyon belejön az absztrakciókba, nehezen veheti észre, mikor kell leállni. A konkrét példában, a most szükséges FileMerger elkészítéséhez file-ok használata szükséges, ami miatt nem unit tesztelhető, ami akadályozza a TDD-t. Valakinek eszébe juthatna létrehozni egy absztrakt file rendszert, amin keresztül utána mégis lefejlesztheti a FileMerger osztály TDD megközelítéssel. Ez valahogy úgy nézhetne ki, hogy a .Net-es File classhoz hasonló felület egy limitált funkcióhalmazzal lenne megvalósítva:

abstract class AbstractFile
{
  Stream Open(string fileName)...
  Stream OpenOrCreate(string fileName)...  
}

Ennek lehetne egy megvalósítása a tesztekhez (például MemoryStream-re építve), és lehetne egy olyan, ami továbbhív a megfelelő File class metódusokba. Ekkor TDD módon elkészíthető lenne egy FileMerger osztály, ami a StreamMerger-t használja – de mit nyernénk ezzel?

Ekkor is szükség lesz egy AbstractFile leszármazottra, ami a valós file rendszert használja. Ennek a fejlesztése már nem megy könyv szerinti TDD-vel, mert nem lehet unit tesztelni. Ez az osztály várhatólag csak annyit csinál, hogy a hívásaiban továbbhív a File osztályba, és visszaadja az eredményt – azaz várhatólag 1-2 soros metódusai lennének. De mennyivel tartalmaz többet egy olyan FileMerger implementáció, ami nem használ AbstractFile-t, hanem közvetlenül a .NET File class-t? Egy ilyen megoldás körülbelül azt tenné, amit az AbstractFile: használja a File osztályt, hogy létrehozzon egy stream-et, amit tovább delegál a StreamMerger-nek. A teszteletlen kód mennyisége (pontosabban TDD teszteletlen kód mennyisége) tehát kb akkora, mint az AbstractFile esetében, de legalább nem kell tovább bonyolítani a képet egy újabb absztrakcióval. Így ebbe az irányba mozdulunk tovább.

TDD vs TDD jellegű

A TDD unit teszteket generál, amelyeket vezetik a fejlesztést, illetve eszközt adnak arra, hogy ellenőrizzük, az implementáció megfelel a specifikációnak. A most készítendő FileMerger osztályt azért nem lehet TDD-ben elkészíteni, mert olyan függősége van, ami nem unit tesztelhető.

De mi van, ha eltekintünk attól, hogy csak a unit teszt a jó? Mit vesztünk ezzel? Az elkészített teszteket nem futtathatjuk jó szívvel a build serveren, illetve többet kell várni, ha lokálisan futtatom őket. Ezen kívül? Ezen kívül nem vesztünk semmit. A TDD-nek nem az a lényege, hogy a nagykönyv szerint csináljuk. A TDD-nek van pár előnye, ami a TDD folyamatából adódik – ezeket tekintettük át eddig, és ezek sokszor megmaradnak akkor is, ha eltérünk az eredeti mantrától.

Emiatt, ha úgy érzem, hogy ad valami pluszt, miért kellene az előnyökről lemondanom csak a unittesztelhetőség hiánya miatt? Megcsinálhatom ugyanazt integrációs jellegű tesztekkel is.

Nézzünk egy egyszerű példát. A FileMerger használatához szükség lesz egy olyan ITempStreamProvider implementációra, amely olyan stream-et ad vissza, ami File alapú, ugyanakkor Dispose()-ra törli a Stream mögött levő file-t. Kell tehát egy öntörlő stream. Ezzel szemben az első természetes igényem az, hogy amíg nincs Dispose() hívás, addig megőrzi a beleírt tartalmat.

A teszt felépítése lehet teljesen hasonló a unit tesztekéhez. A feltűnő különbség az lesz, hogy nagyobb figyelmet kell fordítani a takarításra. Ha a teszt bárhol elszáll, akkor sem szemetelheti tele a file rendszert ideiglenes file-okkal:

[TestMethod]
public void ShouldHoldDataUntilNotClosed()
{
    string temporaryName = null;

    try
    {                           
        // Arrange
        temporaryName = Path.GetTempFileName();
        var temporaryData = new byte[] { 0x01, 0x02 };
               
        // Act
        var temporaryStream
                = new TemporaryFileStream(
                            temporaryName,
                            FileMode.OpenOrCreate,
                            FileAccess.ReadWrite,
                            FileShare.None);

        var contentHeld = new byte[2];
        bool noMoreData;

        using (temporaryStream)
        {
            temporaryStream.Write(temporaryData, 0, temporaryData.Length);
            temporaryStream.Seek(0, SeekOrigin.Begin);

                
            temporaryStream.Read(contentHeld, 0, 2);

            noMoreData = temporaryStream.ReadByte() == -1;
        } // using

        // Assert
        CollectionAssert.AreEqual(temporaryData, contentHeld);
        Assert.IsTrue(noMoreData);
    }
    finally
    {
        if (temporaryName != null)
        {
            File.Delete(temporaryName);
        } // if
    }
}

Ehhez kell adni egy implementációt. Feltűnhet, hogy a követelményben semmi nincs, amit a FileStream ne tudna. Ezért egyelőre elég a következő implementáció:

public class TemporaryFileStream : FileStream
{
    public TemporaryFileStream(string path, FileMode mode, FileAccess access, FileShare share)
        : base(path, mode, access, share)
    {
    } // TemporaryFileStream()
} // class TemporaryFileStream

Most jön a lényegesebb követelmény, a file rendszerből tünjön el a file, miután a stream dispose-olva lett:

[TestMethod]
public void ShouldRemoveFileBehindAfterDispose()
{
    string temporaryName = null;

    try
    {
        // Arrange
        temporaryName = Path.GetTempFileName();
        var temporaryData = new byte[] { 0x01, 0x02 };

        // Act
        var temporaryStream
                = new TemporaryFileStream(
                            temporaryName,
                            FileMode.OpenOrCreate,
                            FileAccess.ReadWrite,
                            FileShare.None);

        using (temporaryStream)
        {
            temporaryStream.Write(temporaryData, 0, temporaryData.Length);
            temporaryStream.Flush();
        } // using

        // Assert
        Assert.IsFalse(File.Exists(temporaryName));
    }
    finally
    {
        if (temporaryName != null)
        {
            File.Delete(temporaryName);
        } // if
    }
}

Hogy ez a teszt lefusson, bele kell nyúlni a dispose mechanizmusába:

public class TemporaryFileStream : FileStream
{
    public TemporaryFileStream(string path, FileMode mode, FileAccess access, FileShare share)
        : base(path, mode, access, share)
    {
    } // TemporaryFileStream()

    protected override void Dispose(bool disposing)
    {
        base.Dispose(disposing);
        File.Delete(this.Name);
    } // Dispose()
} // class TemporaryFileStream

Így készen van a TemporaryFileStream osztály, TDD jellegűen – bár a hozzá tartozó teszteket csak alkalmi jelleggel lehet futtatni. Mégis, a kódunk valamilyen szinten tesztelt. Hasonló TDD jellegűen elkészíthető lenne a FileMerger osztály is, de most nézzük csak az implementációt:

public class FileMerger : IDisposable
{
    private StreamMerger merger;
    private Stream targetStream;

    public FileMerger(string targetFileName)
    {
        this.targetStream = File.Open(
                                targetFileName, 
                                FileMode.OpenOrCreate);

        this.merger =
                new StreamMerger(
                    this.targetStream,
                    new TempFileProvider());
    } // FileMerger()

    public void MergeWith(string sourceFileName)
    {
        using (var sourceStream = File.OpenRead(sourceFileName))
        {
            this.merger.MergeWith(sourceStream);
        } // using
    } // MergeWith()

    public void Dispose()
    {
        if (this.targetStream != null)
        {
            this.targetStream.Dispose();
            this.targetStream = null;
        } // if
    } // Dispose()
} // class FileMerger

TDD vs hagyományos fejlesztés eredménye

Van tehát két implementációnk, az egyik hagyományos módon készült, a másik TDD által vezérelve – így össze lehet hasonlítani a kettőt.

Átláthatóság

Az azonnal látszik, hogy a nem TDD-s kód jóval kompaktabb, egy képernyőn elfér, elég egyetlen osztályt áttekinteni. Emiatt valószínű sokkal könnyebben megérthető. A TDD-s megoldásban négy osztály dolgozik együtt: a StreamMerger, a FileMerger, a TemporaryFileStream és az itt ki sem részletezett TempFileProvider. Ráadásul ha a fejlesztő a StreamMerge irányából kezdi a vizsgálódást, akkor ott csak egy ITempStreamProvider interfészt lát, amihez meg kell keresnie a konkrét implementációt – az F12 nyomkodása a visual studio-ban itt nem fog működni. És ez csak egy egyszerű példa volt.

Működés

Figyelmes olvasó észreveheti, hogy a nem TDD-s megoldás működésében van egy elég alapvető különbség. A nem TDD-s verzió csak addig tartja nyitva a file-t, amíg dolgozik. Ráadásul file move-t használ az ideiglenes eredmény visszamásolására, ami gyorsabb (alacsony szinten az operációs rendszer nem végez tartalom mozgatást, csak a könyvtárbejegyzésekben végez módosításokat). A gyorsaság és az olcsó művelet miatt a hibáknak is kisebb az esélye. A TDD-s megoldásnál a Stream tulajdonságai miatt ilyen megoldást nem lehet adni. A Stream alapú megoldás nyitvatartja a target streamet, ami növeli a hibák kockázatát. Ezen a szinten simán előfordulhat, hogy bár a Stream Merge végzett, a bufferelés miatt a tartalom még nincs kiírva, vagy csak részben van kiírva. Egy hiba sokkal nagyobb valószínűséggel talál félkész file-t a filerendszerben. Ráadásul a Stream alapú megoldást Dispose-olni kell – különben kiszámíthatatlanná válik az idő, amíg a target file nyitva marad.

Az optimalizáció hiánya nem csak a példában véletlenül előbukkanó jelenség. Ez abból adódik, hogy a TDD absztrahált fogalmakkal dolgozik, mivel a függőségek jellemzően interfészen vagy absztrakt osztályon keresztül érhetőek el. Ez annyit jelent, hogy csak a lényeges tulajdonságok vannak meg egy adott szinten. Ebből következik, hogy a speciális tulajdonságok nem látszódnak, így nem lehet speciális tulajdonságokat kihasználni az optimalizációra.

Másik oldalról ez nem feltétlenül baj. A korai optimalizáció általában felesleges, ugyanakkor intim tudást vezet be a kódba – növelve ezzel a függőségek mértékét, csökkentve ezzel a karbantarthatóságot.

Ahol a TDD visszavág

Eddig nem tűnik úgy, hogy a TDD hozza az elvárt előnyöket. De a helyzet az, hogy nem olyan környezetben vizsgálódunk, ami a TDD terepe. A TDD terepe ugyanis a változó környezet – ez vezetett a kidolgozásához.

Single Responsibility és a hagyományos kód

Tegyük fel, hogy megjelenik az az igény, hogy “A” helyzetben azok a mergelési szokások, amit eddig láttunk. “B” helyzetben viszont nem kell a hosszabb file tartalmát a targetre másolni, hanem csak a közös szakasz lehet része az eredménynek (tehát az eredmény akkora lesz, amekkora rövidebb adat volt).

Mit csinálhatunk a nem TDD-s megoldásban? Több lehetőség van: a legelvetemültebb ember beletenne egy if-et a ciklus közepébe. Ez még talán túlélhető, de a harmadik, negyedig if már egyre veszélyesebb – főleg, ha utolsó szúrásként jön egy módosítási igény a merge-ölendő adatok forrásával kapcsolatban is – például az eddigi fileName nem feltétlenül a háttértáron egy file neve, hanem tartalmazhat séma információkat (“ftp://x.com/alma.bin”). A file-t megnyitó kódokról van itt szó, ami együtt van az XOR ciklussal. Ha mind a két rész kapja a módosítási igényeket, akkor megindul két független evolúció egy osztályon belül. Itt már a Single Responsibility áthágásának a következményei jönnek, ugyanis az eredeti megoldás egybegyúrta az adatok forrásának a kezelését az merge-lő algoritmussal.

Egy fokkal kulturáltabb, de hasonló sebből vérző megoldás lehet, ha template method jellegűen az XOR-t végző ciklust a leszármazottak valósítják meg egy virtuális metódussal. Itt látszólag a file kezelés és az algoritmus elválik. Másrészt az öröklődési fa által még mindig túl szoros a kapcsolat az XOR algoritmus és az adatok betáplálása között – az adatforrás módosítása minden leszármazottra kihat, így ha file-ok megnyitásánál keletkezik módosítási igény, akkor maradnak a csúnya if-ek. Az ilyen jellegű problémák miatt alakult ki a Composition Over Inheritance alkalmazása.

Ameddig csak az XOR logika változik, a template method bevezetése egy olcsó refaktor. Abban az esetben azonban, ha változási igény keletkezik az adatforráson (file nyitogatáson), az osztály mindenképpen szét kell vágni két külön osztályra, így végül a TDD által generált kódhoz hasonló megoldást kapunk.

Single Responsibility és a TDD-s kód

Ezzel szemben a TDD eleve olyan kódot generáltatott, ahol a két felelősség – az adatforrás kezelése és az XOR-ozás külön osztályokkal van megoldva, és kompozíció teszi őket egybe. A FileMerger osztály felelős az adatforrásért, az StreamMerger az algoritmusért – igen, egy következő refactor átnevezhetné a StreamMerger-t. Így bármely részen van módosítási igény, kisebb módosítások elegendők.

A TDD-s FileMerger ugyan jelenleg konkrét típusként hozza létre a StreamMerger-t (tehát nem loosely coupled, nem hiába, ez nem TDD-vel készült), de amint olyan módosítási igény lép fel, hogy egy StreamMerger típus nem tudja ellátni a funkciókat, a FileManager könnyen átírható, hogy ezt valamilyen dinamikus módon valósítsa meg – például factory-t használva (dependency injection itt nem jó, mert a StreamMerger-nek kell a Stream, amit a FileMerger hoz létre).

Amennyiben az adatforrás kezelésére van változtatási igény, akkor a StreamMerger-t valami más, a FileMerger-t kiváltó osztály fogja magába foglalni. Az egész megoldás tehát legó módjára variálható.

Kód mobilitása

De ne csak a bővítési igényeket nézzük. Tegyük fel, hogy ahova installálják a szoftvert, rendszeresen előfordul, hogy a user-nek nincs joga ideiglenes állományt létrehozni valami operációs rendszert érintő konfigurációs probléma miatt. A rendszergazdák szeretnének egy sanity test jellegű tool-t, amit installálás után lefuttatnak, hogy ellenőrizzék a fent leírt hibalehetőségeket.

Hogy működne egy ilyen teszt a nem TDD-s példában? A teszt valószínűleg hívna egy path.GetTempFileName()-et, hiszen ez létrehoz egy ideiglenes file-t is – ugyanezt teszi az eredeti kód is.

A TDD-s példában a sanity teszt a TempFileProvider használatát duplikálná, ami látszólag nem jelent nagy előnyt – egészen addig, amíg valamiért, például jogosultsági beállítások miatt a Path.GetTempFileName() többet nem használható, és valamilyen egyedi megoldás kell. Ekkor a TDD által vezérelt kódban a custom megoldást elég a TempFileProvider osztályon belül implementálni – mind a sanity teszt, mind a FileMerger ezt használja, így a módosítás minkét helyre kihat. A hagyományos kódnál viszont két helyen kell átírni a módosítás miatt, ami hiba forrása lehet. Ez megint csak a példa speciális esetének tűnhet, de itt általánosságban a kód mobilitásáról van szó. A TDD egy teljesen független komponenst kényszerített ki az ideiglenes file-ok készítésére, a nem TDD-s megoldás viszont az egy szem osztályba tette ezt a funkciót is.

Egyéb TDD-s furcsaságok.

A mock-olás és a vezérlési logika duplikálása

A példaprogram fejlesztése közben szükség volt arra, hogy az egyik függőséget – a mi esetünkben az ideiglenes területet biztosító stream providert – megvalósítsuk úgy, hogy a működése a tesztekből kontrollálható és ellenőrizhető legyen. Ez gyakori igény tesztelésnél, a függőségeket kiváltó implementációkat általánosságban “test doubles”-nak hívjuk. Ezeknek több fajtája lehet, attól függően, hogy mennyire szolgáltatnak élethű működést, illetve milyen eszközöket adnak az ellenőrzéshez.

A test double-ök egyik típusa a mock objektumok. Ezek előre programozott válaszokat képesek adni bizonyos hívásokra, illetve lehetővé teszik annak ellenőrzését, hogy bizonyos hívások bizonyos paraméterekkel megtörténtek-e. Használatuk az elérhető mocking framework-öknek köszönhetően nagyon kényelmes, ha adott egy interface, a mocking framework minimális gépelési igénnyel képes használható implementációt szolgáltatni, ezáltal tehermentesítve a fejlesztőt.

Nézzünk egy példát. Egy funkciót akarunk fejleszteni, ami inputként egy id-t fogad. Az id-hoz rendelkezésre állnak háttéradatok valamilyen repository-ban. Ezekből a háttéradatokból kell két adat-szelet. A két adat-szeleten a fejlesztendő funkció elvégez valamilyen műveletet, ami műveletet egy modul valósít meg, tehát ebbe kell belehívni. Ha ez így kusza volt, mindjárt jobban látszik, miről van szó:

Az adatokat egy IRepo interfészen keresztül lehet megszerezni, ami a következő módon néz ki:

interface IRepo
{
   Data GetRestOfData(int id);
}

a kívánt művelet, amit a fejlesztendő funkcióból használni kell, az IUseful interfészen keresztül érhető el:

interface IUseful
{
  void Hallelujah(string a, string b);
}

A feladatleírás alapján tehát egy olyan megvalósítást akarunk, ami megszerzi az adatokat, és végrehajtja a Hallelujah műveletet. Ezt a következő teszt ellenőrzi, a teszt forrása után végigvesszük, mi történik:

// Arrange
var fullDataSet = new Data
                        {
                            Alma = "alma",
                            Korte = "korte",
                            Barack = "barack"
                        };

var id = 15;

var repo = new Mock<IRepo>();
repo.Setup(r => r.GetRestOfData(It.IsAny<int>())).Returns(fullDataSet);

var useful = new Mock<IUseful>();
useful.Setup(u => u.Hallelujah(
                    It.IsAny<string>(),
                    It.IsAny<string>()));

var repa = new Repa(repo.Object, useful.Object);
            
// Act
repa.Retek(id);

// Assert
repo.Verify(r => r.GetRestOfData(id));
useful.Verify(u => u.Hallelujah(fullDataSet.Alma, fullDataSet.Korte));

Ahhoz, hogy a funkciót tesztelni tudjunk, szükség van egy mock-olt repository-ra, és egy mock-olt useful osztályra. Emiatt a mocking framework-ot használjuk arra, hogy generáljon valamit az IRepo és IUseful interfészekre.

Elsőként azt várjuk, hogy az implementálandó metódus, amit fejlesztünk, lekéri az adatokat a mock-olt repository-ból, átadva neki a paraméterben kapott id-t. A repository erre visszaad egy adathalmazt. Ehhez a következő módon készíthetjük fel a mocking framework-öt:

Szükség van egy konzerv adatra:

var fullDataSet = 
       new Data
           {
              Alma = "alma",
              Korte = "korte",
              Barack = "barack"
           };

Ezután készíttetünk az IRepo-hoz egy implementációt:

var repo = new Mock<IRepo>();

A mocking framework lehetőséget ad arra, hogy működéssel ruházzuk fel az implementációt. Ebben az esetben majd azt szeretnénk, hogy visszaadja a konzerv választ. Mivel a mocking framework lehetőséget ad arra, hogy ellenőrizzük, hogy a repo milyen paraméterrel lett meghívva (tehát például az id-val lett-e meghívva), az implementáció lehet nagyon buta: adja vissza minden hívásra ugyanazt az adatot:

repo.Setup(r => r.GetRestOfData(It.IsAny<int>())).Returns(fullDataSet);

Hasonlóan, de még egyszerűbben lehet elkészíteni az IUseful-t megvalósító implementációt. Itt arra van szükség, hogy engedje meghívni a Hallelujah() metódust, mindegy, hogy mivel:

var useful = new Mock<IUseful>();
useful.Setup(u => u.Hallelujah(
                      It.IsAny<string>(),
                      It.IsAny<string>()));

Az IUseful.Hallelujah() látszólag nem csinál semmit, de a mocking framework annyi funkcionalitást beletesz, hogy a teszt végén ellenőrizni tudjuk, hogy hívták-e a funkciót, és milyen adatokkal.

A generált implementációkat a repo.Object és useful.Object property-ken keresztül teszi elérhetővé a framework, ezekkel lehet létrehozni a Repa példányt:

var repa = new Repa(repo.Object, useful.Object);

A tesztelendő művelet a következő egy sorban végrehajtható:

repa.Retek(id);

Végül pedig ellenőrizni szeretnénk, hogy az implementáció a helyes Id-val kérdezte le az adatokat a repository-ból:

repo.Verify(r => r.GetRestOfData(id));

Illetve az implementáció a visszakapott adatokkal hívta az IUseful Hallelujah() metódusát:

useful.Verify(u => u.Hallelujah(fullDataSet.Alma, fullDataSet.Korte));

A Repa implementációkat ezek után könnyű megadni:

public class Repa
{
    IRepo repo;
    IUseful useful;

    public Repa(IRepo repo, IUseful useful)
    {
        this.repo = repo;
        this.useful = useful;
    }

    public void Retek(int id)
    {
        var data = this.repo.GetRestOfData(id);
        this.useful.Hallelujah(data.Alma, data.Korte);
    }
}

Mi a gond mindezzel? Egy példa alapján talán nem feltűnő, de folyamatos mock-olásnál a fejlesztő nagyon gyakran járja végig a fenti utat: megadja a tesztben az implementálandó funkció működését azon a szinten, ahogy a hívások szekvenciáját elvárja – majd leírja ugyanezt az implementációban. Konkrétan ebben a példában a teszt elvárta, hogy a Repo.GetRestOfData() legyen meghívva az id-val, majd elvárta, hogy a Useful.Hallelujah() legyen meghívva a két adattal. Ezután az implementáció meghívta a Repo.GetRestOfData()-t az id-val, majd a Useful.Hallelujah()-t a két adattal.

Ezzel több probléma van:

Ha a fejlesztő kétszer két módon írja le ugyanazt, az nem ad nagy plusz biztonságot. Itt már a tesztben le lett fordítva a specifikáció konkrét implementációra, ami utána tükrözve lett a végső kódban. Ez az egy az egyes megfeleltetés magában hordozza azt a veszélyt, hogy hibás értelmezés esetén a tesztben implementált hiba ugyanolyan módon átkerül a valós kódba – de természetesen a teszt működni fog.

Másik gond, hogy a teszt maga túl részletes, emiatt nagy a karbantartási költsége. Bármilyen implementációs részlet megváltozik az ellenőrzött kódban, az magával hozza a teszt ugyanolyan mértékű változtatását.

Van más mód is arra, hogy lássuk, az implementálandó metódus a megfelelő Id-hoz szerez értéket. Ezt például egy manuális teszt úgy derítené ki, hogy a program futása során egy későbbi szakaszban az Id-hoz tartozó adatok jelennek meg. Ezt a trükköt mi is használhatjuk a következő módon:

Állítsuk be úgy a Mock-olt repository-t, hogy csak egyféle Id-ra adja a később ellenőrzendő adatot, a többi Id-ra pedig más adatot adjon:

var repo = new Mock<IRepo>();
repo.Setup(r => r.GetRestOfData(It.IsAny<int>())).Returns(fullDataSetBad);
repo.Setup(r => r.GetRestOfData(It.Is<int>(i => i == id))).Returns(fullDataSetGood);

(A példában használt mocking framework végighívja az összes definíciót, és az utolsó által visszaadott érték nyer, ezért kell a második helyre írni a speciális esetet.)

Ezután elég ráellenőrizni arra, hogy a Hallelujah() metódus a megfelelő adatokkal lett meghívva – hogy az implementálandó metódus ezt hogy szerzi meg az Id alapján, többet már nem érdekes:

useful.Verify(u => u.Hallelujah(fullDataSetGood.Alma, fullDataSetGood.Korte));

Persze sokkal nem jutottunk előrébb, hiszen az Arrange részben világosan látszik, hogy még mindig a GetRestOfData() hivására készülünk – erre van felkészítve az IRepo mock. De legalább a híváslánc duplikálása annyira nem kifejezett. Sajnos a lineárisan lefutó, “adat-tologató” kódok jellemzően ilyen teszteket igényelnek – mégis, amennyire lehet, érdemes törekedni arra, hogy a teszt a bemenet alapján elvárt végeredményt tesztelje, és kevéssé legyen belehuzalozva implementációs részlet, mint adott metódusok hívási szekvenciája.

Privát metódusok tesztelése

Érdemes a privát metódusok tesztelésével kapcsolatban rákeresni az internetre. Egyfelől látható lesz, milyen hitbeli elvakultsággal állnak neki fejlesztők a témához – ami szomorú, de jó példa hogy ne álljunk hozzá más véleményéhez. Másfelől nagyon jó érvek és ellenérvek is ütköznek, amin keresztül sokat meg lehet tanulni a TDD filozófiájáról.

Amikor egy teszthez írjuk a funkcionalitást, a teszt tipikusan meghív egy publikus metódust, az implementációt pedig abban a metódusban kezdjük el megvalósítani. Ha a további tesztek a metódust kezdik túltölteni programkóddal, vagy már eleve olyan bonyolult volt a funkció megvalósítása, akkor a TDD refaktor fázisában kódokat kiemelünk privát metódusokba.

Kell ekkor a privát metódusnak külön tesz? Nem, hiszen a refaktor során nem adtunk új funkcionalitást, a meglévő funkciók pedig tesztelik azt a kódot, amit most kiemeltünk. Legtöbb esetben így keletkeznek privát metódusok, emiatt közvetlen tesztelésre legtöbb esetben nincs szükség.

Nézzünk azonban egy példát, amin keresztül látszódik, hogy a helyzet nem mindig ennyire egyszerű:

Tegyük fel, hogy egy olyan metódust akarok írni, amelyik két objektumot tartalmilag hasonlít össze. A tartalmilag összehasonlítás azt jelenti, hogy veszi az objektum publikus property-eit, azokat névegyezőség szerint párosítja, és primitív típusok esetén elvégzi az érték összehasonlítását az Equals metódust hívva, egyébként pedig rekurzívan a két azonos nevű property-t összehasonlítja tartalmilag.

Egy plusz szabály van, ha a baloldali típus felülírja az Equals metódust, akkor azzal kell elvégeztetni az összehasonlítást.

Anélkül, hogy belemennénk a konkrét implementációba, a következő helyzettel találjuk magunkat szemben: eljutunk oda, hogy megfogalmaztunk egy tesztet, ami azt nézi, hogy ha olyan példányt dobunk be a metódusnak, aminek van felülírt Equals()-sza, akkor az meg lesz hívva. Ezután elkezdjük írni az implementációt, és rájövünk, hogy nem is annyira triviális ráellenőrizni, hogy az Equals felül lett-e írva, vagy nem. Nem is különösen bonyolult, de bizonytalan helyzetbe hozza a fejlesztőt: fog működni akkor is, ha mélyebb a leszármazási fa, és az Equals nem a legutolsó leszármazotton van felülírva? Fog működni értéktípusoknál? Megzavarja-e az ellenőrzést, ha egy overload-olt Equals van a típuson?

Ezen a ponton a fejlesztő késztetést érez aziránt, hogy tesztekkel biztosítsa, hogy különböző módon rendelkezésre álló Equals implementációkkal is működik a megoldása. Mi a gond most? Az hogy a tesztelendő funkcionalitás vagy egy másik metódus közepén helyezkedik el – így nem tesztelhető izoláltan, vagy már ki lett emelve privát metódusra, ami nem érhető el egyszerűen. .NET-ben nem olyan nehéz private accessor-t gyártani, de sokaknak ez már gányolásnak tűnik. A másik gond – ami komolyan megfontolandó, hogy ha egy funkció olyan bonyolult, hogy saját tesztet igényel, akkor az feltehetőleg elérte azt a komplexitást, hogy külön osztályként létezzen – ekkor pedig nem lesz szükség privát metódusok tesztelésére.

Az esetek legnagyobb részében a funkció tényleg arra érett, hogy egy refactor keretében saját osztályt kapjon, így ez az egész most feszegetett téma nem érdekes. De a mi példánkban egy rövidke funkcióról van szó, a bonyolultságát inkább az okozza, hogy olyan dolgokat kellene átlátni a .NET működésében, amit nem használunk minden nap, emiatt bizonytalanságot okoz. Hogy lehessen látni miről van szó, a megvalósító kód ennyi:

private static bool IsEqualsOverridden(Type type)
{
    var equalsMemberInfos =
          type.GetMethods(BindingFlags.Instance | BindingFlags.Public)
            .Where(m => string.Equals(m.Name, EqualsMethodName, StringComparison.Ordinal)) // Get all "Equals" implementation
            .Where(m => m.DeclaringType != typeof(object))                                 // not defined by object
            .Where(m => m.GetParameters().Count() == 1)                                    // having only one parameter 
            .Where(m => m.GetParameters().First().ParameterType == typeof(object));        // with type of object

    return equalsMemberInfos.Count() == 1;
} // IsEqualsOverridden()

Ezt kirakni egy osztályba talán overkill. Lehetne valamilyen utility osztályba tenni, de azokat funkció szerint csoportosítani szokták, ennek az egy szemnek pedig jó eséllyel nem lenne testvére – azaz megint csak saját osztályként szerepelne. Ha rászokok arra, hogy az ilyen helyzeteket osztályba való kiemeléssel oldom meg, akkor esetleg teliszórom a kódomat apró kis osztályokkal, növelve az átláthatatlanságát. Emiatt – és ez személyes vélemény, amivel nem kell egyetérteni – számomra a privát metódusban tartás a szimpatikus út, private accessor-os teszteléssel.

Tegyük fel, hogy más is emellett döntött. Sajnos a döntéssel nem szabadulunk meg attól, hogy a fejlesztendő kód ordibáljon utánunk, hogy valami nincsen rendben. Mik lesznek a tünetek?

Ha ránézünk az implementálandó osztály tesztjeire, akkor azt vehetjük észre, hogy a tesztek egy része azt fogja leírni, hogy a tartalmi összehasonlítással kapcsolatban mik az elvárások. Ezután lesz néhány olyan teszt, ami önmagában a privát metódusunkat teszteli, és azt írják le, hogy mikor kell az Equals metódust meghívni – két teljesen más típusú teszt halmaz egy adott osztállyal szemben, a Single Responsibility Principle megtörésének a jele.

Másik oldalról viszont, az IsEqualsOverridden metódus, miután elkészült, stabil. Nem fog változni, ha csak a .NET keretrendszer nem változik olyan módon, amire még erőltetett példát sem lehet mondani. Azaz a Single Responsibility Principle lényege, ami miatt bevezették, hogy ne fordulhasson elő, hogy két összefonódó funkció más irányba fejlődjön, és ez karbantarthatósági problémákat okozzon, itt nem áll fent.

Harmadik oldalról, az IsEqualsOverridden() tesztjei nem hagyományos TDD tesztek. A fejlesztő leírta a megoldást, amikor jött az eredeti követelmény, hogy az Equals() felülírását ellenőrizni kell. Ezután elbizonytalanodott, és írt néhány tesztet – ami azonnal zöld volt – sérült a “write failing test first”. Mint ilyen teszteket, akár ki is lehetne dobni. Megbizonyosodtunk róla, hogy a funkció jó, tudjuk, hogy stabil, nem fog változni, emiatt nem kell regressziótól tartani.

Negyedrészt azonban, ha valaki mégiscsak akármiért belenyúl, vagy kiderül, hogy egy nyakatekert esetet nem fed le, akkor jól jöhetnek ezek a tesztek.

Valószínűleg sokáig lehetne még folytatni az érveket és ellenérveket. A fenti kódot a cikk írása előtt 7-8 hónappal készítettem, és azóta bizonytalan vagyok, mi a helyes megoldás. Ennek a résznek a mondanivalója inkább az, hogy mint sok más terület / módszertan / bármi más, nem fekete-fehér, és nem vakon követhető szabályokból áll.

Kifejezhetetlen igények

Van pár dolog – akár követelmény – amit TDD-s tesztekkel nem lehet leírni. Egy algoritmus elvárt komplexitása például – azaz, hogy nagy adatokra is gyors legyen. Ilyen esetekben a test first megközelítés nem fogja a megfelelő irányba vezetni a fejlesztést. Ekkor, ha van egy TDD által kidolgozott, csak nem megfelelően hatékony implementáció, egy refaktor keretében azt ki lehet cserélni a kívánt hatékonyságú megoldásra. A TDD tesztek szerepe itt a regresszió ellenőrzése. Ha nincsenek TDD-s tesztek, ilyen esetekben ezt a megközelítést nem kell erőltetni – mivel nem ad plusz előnyt. A kívánt algoritmust le kell fejleszteni és tesztelni hagyományos módon.

Konklúzió

Láttuk, hogy a test first megközelítés milyen igények mentén merült fel, és melyek azok a helyzetek, amelyekben előnyt hoz. Vannak természetesen olyan programozási feladatok, amelyeknél a test first nem hatékony. Ezért nincs értelme általánosságban arról beszélni, hogy a TDD jó vagy rossz, sőt, gyakran egy fejlesztésen belül előfordulhatnak olyan helyzetek, ahol a TDD-t nem érdemes erőltetni. A TDD céljainak megértésével remélhetőleg könnyebb lesz a megfelelő megközelítést alkalmazni.

  1. #1 by rlaci on 2013. March 19. - 18:50

    Nagyon jó írás – mint mindig.🙂
    Az elején azt hittem, hogy TDD hasznosságát bemutató post lesz, de végülis hasznos előnyök/hátrányok lista lett keverve egy kis mock-olással.

    Egyelőre csak ismerkedem a TDD-vel. Azt tapasztalom, hogy de attól még, hogy a post elején levő kérdésekre tudom a választ, még nem tudok TDD-ben dolgozni. Sok gyakorlás, elméleti háttér (többek között IoC), különböző eszközök ismerete (mock, unit testing framework) kell hozzá, valamint olyan csapat, ami ezt el is fogadja és együtt használja.

    A file-műveletek, DateTime.Now és emelett szinte bármi🙂 tesztelésére ajánlok figyelmedbe egy kis “Black Magic”-et:

    Fakes, Isolating Unit Tests, Jonathan De Halleux, http://vimeo.com/43549084
    Replace any .NET method with a delegate. Stubs & shims. Included in Visual Studio Ultimate.

    Itt a teszt elég szorosan össze van “kapcsolva” a tesztelendő kóddal.

    • #2 by Tóth Viktor on 2013. March 20. - 11:06

      Köszi! Megnéztem a videót, elég jónak tűnik! Kár, hogy csak ultimate-tel van.

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s

%d bloggers like this: