A tesztelés elveszett előnye
Eredetileg közzétéve: 2020. január 16.
Archív bejegyzés
Ez a cikk 2020-ben jelent meg a letscode.hu korábbi, azóta megszűnt verziójában. A benne szereplő eszközök, verziószámok vagy gyakorlatok azóta elavulhattak - történeti értékét megőrizve, változtatás nélkül tesszük újra elérhetővé.
Unit tesztelni jó. Rengeteg előnnyel jár, de valahogy nagyon sokan vannak akik még mindig utálják. Ezért nem a tesztelést kell hibáztatni, hanem azt, ahogy végezzük.
(A cikkben több helyen a mock/mockolás szavakat használom, habár a test double/teszt dublőr lenne a megfelelő, de sok esetben ez kényelmesebb volt)
Na de nézzük hát azokat az előnyöket:
- Jobb design — magától értetődő, hogy ahhoz, hogy unit tesztet írjunk egy adott egységhez, akkor azt úgy kell megírni, hogy könnyen elválasztható legyen a kód többi részétől.
- Dokumentáció — a tesztek neve önmagában dokumentálja, hogy is működik az adott unit. Még egy rosszul elnevezett tesztmetódus esetén is rá tudsz jönni mit csinál, ha belenézel a belsejébe.
- Hibakeresés — még ha nem is erre szolgálnak, tesztek segítségével regressziós hibákat tudunk megfogni.
- Költségek hosszú távú csökkentése — Azokon a projekteken, amik tovább tartanak pár hétnél és komplexebbek egy landing oldalnál, a tesztek segíthetnek hogy elkerüljük azt a káoszt, ami később extra munkaórát okozna, ezzel pénzt spórolva meg nekünk.
- Biztonságos refaktorálás — ez a cikk erre fog fókuszálni.
Mi az a refaktorálás?
A refaktorálás az a folyamat, amiben átalakítjuk a kód szerkezetét anélkül, hogy megválzotatnánk annak külső működését.
Na de mit értünk azon, hogy “anélkül, hogy megváltoztatnánk a külső működését”? Ez azt jelenti, hogy kiválasztunk egy kódrészletet és úgy változtatjuk meg, hogy a pre- és posztkondíciók nem változnak. A kód ugyanazt fogja tenni, mint eddig. Ha ez a bizonyos kódrészlet egy metódus, ez azt jelenti, hogy az interfész nem változik, csak a belseje.
Hogy biztosítjuk azt, hogy semmi sem változott? Na itt jönnek azok a bizonyos tesztek a képbe.
Ha voltak tesztjeink arra a kódrészletre, akkor csak futtatjuk őket és bármilyen hiba azt jelzi, hogy megváltoztattuk azok működését.
Refaktorálás vs. új funkciók
A refaktorálás az a folyamat amikor úgy struktúráljuk át a kódot, hogy szándékosan NEM módosítjuk a működést.
Új funkció hozzáadása pedig mikor szándékosan módosítjuk a működést.
Az emberek többsége összekeveri a refaktorálást azzal, hogy új funkciókat ad a rendszerhez. Ez a folyamat gyakran nem áll meg egy osztály szintjén, hanem egy nagy adag kódot átírunk, ami még tovább ront a helyzeten. A végén mikor futtatjuk a teszteket egy rakás hibára fut. Na de mi ennek az oka? A refaktorálás? Vagy az a logika amit hozzáadtunk az új funkcióhoz valahogy nem illik a rendszerbe? Ki tudja? Ezért az emberek végül visszavonják a módosításaikat, vagy - rosszabb esetben - átírják a teszteket, hogy megfeleljenek az új körülményekhez, még akkor is, ha nem értik azt.
Egy csomó teszt eltört, mikor refaktoráltam a kódot, így azokat is utána kellett húzzam…
Akkor mi lehet mégis a gond? A tény, hogy "egy csomó teszt"-et említ elgondolkodtat, hogy mégis hogy is vannak struktúrálva ezek a tesztek? Persze azért is lehet, mert újraírta a fél rendszert - tehát a refaktorálás volt túl nagy - vagy csak szimplán olyan csúnyaságokat csinált, mint túl generikus teszteket írt, de erről majd lesz szó később.
A lényeg, hogy felismerte a problémát, ami sajnos sok fejlesztő esetében elmarad.
Akkor mi lehet mégis ennek az egésznek az oka?
- Félreértések a refaktorálásról
- A tesztelendő egység fogalma
- Törékeny tesztek
Nézzük meg ezeket akkor külön-külön!
Félreértések a refaktorálásról
A tesztelendő egység fogalma
Mi az a unit?
Intuitively, one can view a unit as the smallest testable part of an application. — Wikipedia
Intuitively, azaz ösztönszerűen. Pont ez a szó, ahol minden félrement. Ha bárki beszél vagy ír a unit tesztelésről, akkor biztosan metódust, osztályt, vagy éppen függvényt ért alatta.
Minden egyebet izolálunk, megvizsgáljuk a belsejét a kis kódrészletnek. Persze ha használunk statikus kódanalizálókat, Sonart és hasonlókat, akkor igen könnyen apró metódusoknál kötünk ki, kis komplexitással, rövid osztályok, amik csak egy apró dolgot csinálnak. De ez a csöppnyi osztály a pár kódsorral mégis mit jelent az üzlet számára? Egy if-else ág reprezentál egy funkcionalitást? Nézzük meg, hogy a legtöbb fejlesztő hogy ír teszteket!
A tipikus tesztjeink
Tegyük fel, hogy van három ilyen osztályunk:
- UserService
- EmailValidator
- UserRepository
Akkor nyugodtan mondhatjuk, hogy van három ilyen tesztünk is:
- UserServiceTest
- EmailValidatorTest
- UserRepositoryTest
Problémát jelent mindez? Nem, egészen addig amíg nem módosítunk valamin. Réges-rég megtanultuk, hogy mennyire fontos a loose coupling, de a tesztjeinkben valahogy mégis elfelejtettük azt.
A fenti megközelítéssel hozzáragasztjuk a tesztjeink struktúráját az éles osztályokhoz, ami még jobban megnehezíti a refaktorálást. Habár mindkét csoport ugyanazt a működést hivatott leírni vagy épp megvalósítani, a struktúrájuk eltérhet.
Úgy gondolom - és ezzel lehet sokan nem értenek egyet - hogy az osztály nem a legkisebb tesztelhető egység. Az esetek többségében már a legegyszerűbb feladatokra is több osztályt hozunk létre. Véleményem szerint az ilyen osztály csoportokat izolálva kapjuk meg azt, amit unitnak tudunk hívni.
Nézzük meg hogy is struktúrálnánk a tesztünket. A korábbi egy osztály - egy teszt elvet fogjuk követni az elején.
Ezután létrehozzuk az első teszteket hozzá. Egy a validációra, egy a transzformációra és egy pedig arra, hogy ellenőrízzük, valóban elmentettük az átalakított objektumot.
Tehát megvannak a tesztjeink, amiket ha lefuttatunk, akkor mind zöld. Ez a mi kezdő pontunk.
Kezdjük el refaktorálni a kódot. Először csak kiemeljük a logikát privát metódusokba:
Megjegyzés: Egyértelmű, hogy privát metódusokat külön nem, hanem a publikus metódusokon keresztül teszteljük.
Módosítottuk az éles kódot. Újrafuttatjuk a teszteket, amik még mindig zöldek, de hát ez nem újdonság.
Menjünk tovább a refaktorálással. Mi lenne, ha a validátot kimozgatnánk egy új osztályba?
Létrehozunk egy új osztályt, egy metódussal, ami a szolgáltatásból átvett kódot tartalmazza:
De nem lesznek.
elvesztettük azt a biztonságot, amit a tesztek nyújtottak.
De még semmi sincs veszve, vonjuk vissza a módosításainkat és menjünk vissza oda, mikor privát metódusokba emeltük ki a logikát. A tesztek zöldek, tehát elkezdhetünk refaktorálni. Első lépésként létrehozunk egy factory-t a szolgáltatásunk számára:
Cseréljük hát le a konstruktort a setup metódusból:
Ezután nincs más hátra, mint megismételni a legutóbbi lépésünket. Emeljük ki a validációs logikát egy új osztályba - ami legutóbb fordítási hibát okozott - és adjuk hozzá a példányosítást a factoryhoz, ami még mindig éles kód, ugye? Tehát nyugodtan megtehetjük:
Ha szigorúan mockista vagy, akkor most körülbelül így festhetsz:
De továbbra is szükségünk van tesztdublőrökre az olyan esetekben, amikor a kimenet nem determinisztikus, mint a repository esetén.
Folytassuk a refaktorálást!
Kimozgatjuk a transformert:
Mi a helyzet azokkal az osztályokkal, amiket a unitokon átívelően használunk? Újra és újra teszteljük őket? Ugyanazokkal az esetekkel? Bizony. Ám ezek az esetek különböző kontextusban történnek. Ennélfogva, mikor refaktorálunk egy ilyen kollaborátort, ami két tesztelt unitban is használva van, a végén pedig az egyikhez tartozó tesztek eltörnek, akkor tudjuk, hogy olyan módosítást vittünk végbe, ami nem igazodik ahhoz a kontextushoz.
Megjegyzés a mockolásról
Mi történne másképp, ha mockokat használunk valós implementációk helyett? Menjünk vissza ahhoz a ponthoz, amikor mock validatort használtunk:
De mi a helyzet akkor, ha időközben valaki odaszambázott és módosította a validátor kódját? Megbízunk benne, hogy a tesztet is módosította az új működésnek megfelelően? Mi van akkor, ha mostantól csak logol egy üzenetet minden egyes alkalommal, ahelyett, hogy kivételt dobna? Lehet, hogy utánahúzták a kódot a hozzá tartozó XYValidatorTestben, de itt egy olyan esetet fogunk tesztelni, ami sosem történik meg. Természetesen egy magasabb szintű teszt ezt a hibát elkapná, de ha ránézünk a tesztpiramisra, akkor tudjuk, hogy kevesebb magasszintű tesztet hozunk létre és talán ez a hiba éles környezetben köt ki. A nagyobb probléma az inkább, hogy nehéz lesz megtalálni, mert tönkretettük a legjobb dokumentációnkat, a tesztünket. Mostantól az is hazudik, ugyanúgy ahogy a dokumentáció szokott.
Összegezve:
- redukálhatjuk a mockok létrehozásával töltött időt
- a konstruktorunkat elválasztjuk a teszttől
- az eredeti implementációkat használjuk, ahol lehet
Mindezek segíthetnek, hogy egy olyan környezetet teremts, ahol a biztonságos refaktorálás már nem álom csupán.
