Unit tesztelés Androidon

A unit tesztelés Androidon egy hagyományosan problémás témakör, mivel a nehézségek a platform fejlesztőinek alapvető tervezési döntéseiben gyökereznek. Ebben a cikkben azt szándékozom körüljárni, hogy mit jelent maga a unit tesztelés, milyen problémák adódnak ezzel kapcsolatban a kedvenc mobilplatformunkon, és hogyan lehet ezeket megszüntetni.

A bejegyzés alapjának számító angol nyelvű előadás a februári Android Budapest Meetupra készült, a felvétel itt látható:


Egy unit teszt anatómiája

A unit tesztelés egy szoftvertesztelési módszer, ami a forráskód egy izolált részének elvárt viselkedését vizsgálja. A Java esetében ez az izolált rész vagy egy osztály vagy egy metódus. Újabban, a Java 8 támogatás bevezetése óta az interfészek default metódusait is lehet tesztelni, amelyek azonban alapvetően nem különböznek az osztálymetódusoktól. Ezeket hívhatjuk osztály- és metódusteszteknek is. Az izoláció azért nagyon fontos, mert a unit teszt lényege éppen egy jól körülhatárolható egység tesztelése: ha a teszt elbukik, akkor rögtön egyértelműnek kell lennie, hogy a hiba honnan ered. Ez azt is jelenti, hogy unit tesztben nem szabad olyan működő kódra hivatkozni, ami nem esik a vizsgált programrész hatálya alá. Például a tesztelttől eltérő másik osztály, framework, külső könyvtár vagy bármilyen más által írt kód. Normális esetben mások kódját nem akarjuk tesztelni, a fejlesztő felelőssége elsősorban a saját maga által írt forráskódra terjed ki.

Három fő lépésből áll egy unit teszt: előállítjuk a tesztelni kívánt helyzetet (arrange step v. setup step), utána invokálunk (act step v. action step), végül pedig megvizsgáljuk a végeredményt, hogy megegyezik-e az elvárásainkkal (assert step v. verification step).

A gyakorlatban természetesen a kép összetettebb, hiszen a tesztelt objektumnak lehetnek dependenciái, amiket szintén példányosítani kell, vagy akár lehet több assertion step, ha nemcsak a végeredményt, de a kezdeti állapotot is tesztelni szeretnénk, stb. Ez a fenti csak egy egyszerű példa, ami mégis szemlélteti egy unit teszt általában szükséges részeit. Azért használtam az általában szót, mert ha mondjuk egy konstruktort tesztelünk, akkor az arrange step és act step egy és ugyanaz lesz, és még egyebek, amire most nem térek ki.

Hogy a metódusinvokáció kimenetele találkozik-e az elvárásainkkal, azt négy módon vizsgálhatjuk.

Publikusan elérhető tulajdonság

Ez ugyanaz az egyszerű példa, amit az előbb láttunk. A lényeg, hogy ilyenkor a verification stepben a kimenet helyességét egy publikusan elérhető tulajdonság segítségével vizsgáljuk (ami lehet egy publikus mező vagy getter metódus). A példában a csecsemő az elvárásaink szerint viselkedik, ha sírni kezd, miután elvettük tőle a játékát.

Visszatérési érték

Ez szintén egy egyszerű dolog. Ellenőrzéskor az action stepben visszakapott érték alapján vizsgáljuk a helyes viselkedést. Itt a helyes kimenet az, hogy a csecsemő nem kaphatja meg a játékot, ha a játék egy üres referencia, vagyis null.

Változás a dependenciában

Itt azt vizsgáljuk, hogy a csecsemő meghívja-e a Bottle objektum consume() metódusát, ami az ő helyes működése. Ez a példa szintén egyszerű, viszont érdekes is. Az egyik érdekes dolog, hogy nem az eredeti Bottle nevű osztályt használjuk a tesztben, hanem abból leszármazunk, és egy MockBottle osztállyal helyettesítjük. A helyettesítés azért szükséges, hogy a unit tesztelés által támasztott izolációs követelmény ne sérüljön. A teszt nem bukhat el a Bottle valamilyen működése miatt, mert jelen esetben az az osztály nem érdekel minket, itt csak a Baby viselkedése az érdekes számunkra. Éppen ezért a MockBottle nem csinál semmi mást, mint a consume() metódus meghívásakor beállít igazra egy logikai értéket, amit az assertion stepben egy nyilvános getterrel meg tudunk vizsgálni.

A másik dolog, ami érdekes, hogy ez szintén példa a Dependency Injectionre. A DI nagyon hasznos a forráskód karbantarthatósága, bővíthetősége és tesztelhetősége szempontjából is, így erről fogok hosszabban írni lentebb.

Exception

Végül vizsgálhatjuk azt is, hogy megtörtént-e egy kivétel. Ebben a példában a csecsemőnek kivételt kell dobnia, ha el akarjuk tőle venni a játékát anélkül, hogy korábban adtunk volna neki egyet. Azt hiszem, ez nem egy szokványos dolog, és talán nem is egy jó példa. Normális esetben, ha a kódunk kivételt dob, akkor az applikációnk kifagy. De ennek a vizsgálatnak is lehetséges érvényes felhasználása. Ilyen, amikor mondjuk egy libraryt, frameworköt vagy akár SDK-t írunk, és azt szeretnénk, hogy a felhasználóink a kivételeket egy másik szinten tudják lekezelni, a saját applikációjuk szintjén, ahelyett, hogy ennek a módját helyettük eldöntenénk. Ilyen esetekben szeretnénk tudni, hogy adott körülmények között a kódunk dob-e egy bizonyos kivételt, amire előzetesen számítunk. Továbbá mindig jobb, ha az alacsonyabb szintű kód a hibakezelést delegálja a magasabb szintű kód felé.

Dependency Injection

Ezt a fogalmat nem fordítanám le, mert nem sokan értenék úgy meg. A DI egy viszonylag kacifántos kifejezés, ami azonban egy egyszerű dolgot takar. Azt jelenti, hogy amikor egy objektumnak szüksége van egy másik objektumra a működéshez, akkor ezt az objektumot átadjuk neki vagy a konstruktorban vagy egy setter metódussal. Ez segíti a karbantarthatóságot, a bővíthetőséget és a tesztelhetőséget egyaránt. Ha ugyanis meg szeretnénk változtatni — a korábbi példánál maradva — azt a módot, ahogy a csecsemő táplálkozik, pl. nagy üveg helyett egy kis üveget szeretnénk neki adni, vagy tej helyett almalevet, akkor nem kell hozzányúlnunk az eredeti osztályhoz, csak kívülről kicserélni az adott dependenciát. Ez a lecserélhetőség unit tesztek írásakor is nagyon hasznos, ahogy azt a fenti példában láttuk. További előnye annak, hogy a függőség kívülről adódik át, hogy a git commit diffünk is átláthatóbb lesz a változtatásaink után.

A két változat közül az első, azaz a constructor injection a preferált, és ha lehetséges, válasszuk azt az opciót. Ha konstruktorban adjuk át a dependenciákat, akkor mindegyik azonnal rendelkezésre fog állni, amint létrejött az objektum, továbbá Javaban az ezeket tartalmazó mezőket elláthatjuk a final kulcsszóval. Így ez a módszer mindig megbízhatóbb.

Rossz példa

Ne csináljunk ilyet! Itt, ha meg akarjuk változtatni a Baby viselkedését, tesztelés vagy más egyéb miatt, akkor bele kell nyúlnunk az eredeti kódba (illetve csak a tesztelés idejére megejteni a változtatást így nem is nagyon lehetséges).

Probléma Android platformon

Ha hagyományos módon állunk a fejlesztéshez, akkor a unit tesztek írásakor nagy nehézségekbe fogunk ütközni. A hagyományos mód alatt azt értem, hogy úgy fejlesztünk applikációt, ahogyan azt a hivatalos dokumentáció ismerteti. Az android.com oldalon olvasható dokumentációban az összes példakód a logikát Android-osztályokon belül tartalmazza, pl. Activity, Service, Fragment stb. A tesztelés szempontjából ezzel az a probléma, hogy a szóban forgó osztályok példányait a rendszer hozza létre és kezeli. Vagy a manifest fájlban vannak definiálva, vagy egy God objektum factory metódusán keresztül lehet elkérni őket, vagy argumentumként kapjuk meg őket egy metóduson belül. Ez lehetetlenné teszi egy unit teszt lépéseinek teljesítését.

Általában így állnánk hozzá egy unit teszt megírásához, ahogy az a fenti példában látható. De természetesen így csinálni Androidon teljesen lehetetlen. A példakód azt vizsgálná, hogy ha az átadott Bundle tartalmaz egy cikkazonosítót, akkor kitöltődik-e az ArticleViewActivity megfelelő mezője. Ám, mivel egy Activity-t a rendszer hoz létre, ezért nem tudjuk teljesíteni az arrange stepet, nem lesz az objektumra referenciánk, valamint a rendszer hívja az onCreate(Bundle) metódust is, ezért az act stepet sem tudjuk teljesíteni. Persze Fragment esetében képesek vagyunk mi példányosítani, de ha az ember jót akar magának, akkor amúgy sem fogja azt az osztályt használni. Ezenfelül csak ahhoz, hogy hozzáférésünk legyen ezekhez az objektumokhoz, emulátort vagy fizikai eszközt kell használnunk, és ez elsősorban instrumentált tesztekkel lehetséges.

Instrumentált tesztek

Az instrumentált tesztek megmutatják, hogy a kód hogyan viselkedik Androidos környezetben, tehát maga a rendszer egy függőségként jelenik meg. Éppen ezért, az instrumentált tesztek inkább integrációs tesztek, mert a teszteredményt befolyásolhatja maga az eszköz vagy emulátor, annak viselkedése, állapota, beállításai vagy akár egy bug magában az Androidban is. Az instrumentált teszteket ne keverjük össze a unit tesztekkel!

Unit tesztek

Akkor miért írjunk unit teszteket, ha egyszer instrumentált teszteket hagyományosan sokkal könnyebb megírni? A unit tesztek egyik nagyon lényeges előnye a sebesség. A legtöbb esetben egy unit teszt egy másodpercen belül lefut, és minél gyorsabb egy teszt, annál valószínűbb, hogy gyakran le fogjuk futtatni. A másik előnyük a megbízhatóságuk, hiszen a JVM-en kívül nincs másra szükség a használatukhoz. Nem kell emulátort konfigurálni, nem kell a környezet kialakításával bíbelődni, ha virtuális képernyőn szeretnénk emulátort futtatni, nem kell fizikai eszközt csatlakoztatni, várni arra, hogy rákerüljön az APK, stb. Nagyon könnyen használhatóak és így a CI szerver folyamataiba is egyszerűen beilleszthetőek.

Hogyan írjuk unit tesztelhető kódot

A POJO egy közismert fogalom, amit Martin Fowler vezetett be a köztudatba. A betűszó jelentése Plain Old Java Object, vagyis ez olyan objektum, aminek nincs más dependenciája, mint amit maga a Java nyelv ki tud elégíteni. A mi esetünkben a lényeg, hogy a POJO egy olyan osztály példánya, amely nem rendelkezik Androidos függőséggel. Tehát, ha az üzleti logikát (illetve bármilyen nem-UI logikát) POJO-kba rakunk, és ezeket a fent említett módokon asszertálhatóvá tesszük, akkor a kódunk unit tesztelhetővé válik. Természetesen nem lehetséges teljes mértékben elválni az Androidtól, hiszen a manifestben eleve meg kell adnunk egy Activity-t, továbbá szükségünk van olyan dolgokra, mint prezentáció, interakció, navigáció, szenzoradatok, hálózati kapcsolat stb. Ennek ellenére használhatjuk ezeket az osztályokat, mint puszta belépési pontokat a saját kódunkba.

Mock android.jar

Az Android SDK tartalmaz egy mockolt android.jar fájlt, ami tartalmazza a platform által nyújtott osztályok mockolt változatát. Ezek az osztályok nem tartalmazzák az eredeti implementációt, hanem a metódusaik hívás esetén RuntimeException-t dobnak. Három alapeset van, amikor ezek az osztályok hasznosak lehetnek. Az egyik, amikor puha dependenciáink vannak (soft dependencies), és szeretnénk, hogy a tesztünk leforduljon. A puha dependencia egy olyan dependencia, ami szükséges ahhoz, hogy egy forráskód leforduljon, de sosem hívódik meg. Ilyen például, amikor szükségünk van egy adott osztályra, hogy kitöltsük egy konstruktor összes argumentumát, viszont nem szükséges ahhoz, hogy egy metódust teszteljünk.

A másik eset az, amikor Android-osztályt szeretnénk mockolni, ahogy az alábbi példában látható.

Ebben a példában a teszt során arra van szükségünk, hogy át tudjunk adni valaminek egy Context objektumot, amin keresztül az a valami el tud kérni egy String erőforrást (string resource), aminek a tartalma azonban a teszt szempontjából itt mindegy.

Végül pedig — mivel a mockolt android.jar metódusai RuntimeException-t dobnak, ha nem mockoljuk őket — hasznosak abban az esetben, ha ellenőrizni szeretnénk, hogy a kódunk milyen mértékben függ az Androidtól, hol és mikor referál Androidos kódra, és invokálja ezeket, ami a unit tesztekkel szemben támasztott izolációs elvárás miatt érdekes.

Kész tervezési megoldások

Szerencsére számos olyan, nagy koponyák által kitalált tervezési minta áll rendelkezésünkre — a szokásos öt alapvető OOP elven túl (SOLID tervezési elvek) -, amik segítenek minket abban, hogy az appunk forráskódját POJO osztályokba szervezzük és jól tesztelhető, jól karbantartható és jól skálázható struktúrát alakítsunk ki. Ezeket elég csak megértenünk, és azonnal használhatjuk is őket. Pár, manapság gyakori architecturális, a teljesség igénye nélkül:

  • MVP (Model-View-Presenter)
  • MVVM (Model-View-ViewModel)
  • VIPER (View-Interactor-Presenter-Entity-Router)
  • MVI (Model-View-Intent)

Ezeket nagyon sokat tárgyalják, és jól dokumentáltak, ezért könnyen megismerhetőek. A legnépszerűbbnek ma az MVP tűnik, a Google maga is közzétett egy példát arról a saját oldalán, hogy hogyan írhatunk a segítségével tesztelhető kódot (ez az Android Testing Codelab című lapon található). Azonban ne zavarjanak minket össze ezek a rövidítések, nem kell betű szerint ragaszkodnunk az általuk leírtakhoz. A lényeg továbbra is az, hogy a logikát POJO osztályok használatával tegyük tesztelhetővé; tiszta, jól bővíthető és karbantartható kódot írjunk, és nem az, hogy az architektúrát hogyan hívják. Ezért mindenkit arra bátorítanék, hogy ismerje meg ezeket, értse meg, és aztán a tanultak alapján bátran kísérletezzen és alakítson ki magának egy olyan architektúrát, ami pontosan illik a saját igényeihez, ami pl. a projekt élettartama, projekt mérete, karbantarthatósági elvárások, változtatások gyakorisága stb.

A tervezési minták mellett meg kell említeni a Clean Architecture-t, ami Robert C. Martin által kidolgozott elvek gyűjtőneve. Ezek segítenek az alkalmazásunk struktúráját úgy megtervezni, hogy az független tudjon lenni mindenféle külső rendszertől, legyen az akár keretrendszer, adatbázis, adatátviteli protokoll stb. Több más paradigma vagy átfogó elképzelés létezik, ami ugyanezt célozza, de a CA talán a legelterjedtebb ezek közül. Éppen ezért a fentebb felsorolt architekturális minták is nagyrészt vagy teljesen ezeken az elvekben gyökereznek.

Összegzés

Ha úgy fejlesztünk Android appot, ahogy a hivatalos dokumentáció ismerteti, akkor a kódunk döntő többségét lehetetlen lesz unit tesztelni.

  • Ne keverjük össze a unit teszteket az instrumentált tesztekkel.
  • Minden olyan logikát, amit tesztelhetővé szeretnénk tenni, rakjuk POJO osztályokba.
  • Ahhoz, hogy ezt milyen módon tegyük meg, kész, kipróbált, széleskörűen tárgyalt és jól dokumentált tervezési minták állnak a rendelkezésünkre, amelyeket felhasználhatunk vagy meríthetünk belőlük.
  • Ezeket az osztályokat tegyük asszertálhatóvá, hogy tesztelhető legyen az ebben a cikkben leírt módokon.
  • Használjunk Dependency Injectiont, ami nemcsak a tesztelhetőséget segíti, de általában a kódunk karbantarthatóságát és bővíthetőségét is. Írjunk unit teszteket, mert gyorsak és megbízhatóak.

Végül pedig muszáj megemlíteni, hogy egy rendszerben minden új bevezetett elem új problémákat hoz magával. Természetesen a unit tesztekre is igaz ez, hiszen mindig frissíteni kell őket, amikor a kódunk változik. Ha ezt nem tesszük meg, akkor a korábbi tesztjeink valahol elbukhatnak, és ez plusz munkát jelent. Ugyanakkor ennek pozitív hozadéka is van, hiszen a teszt hibája mindig a kódban történt változtatásokat fogja tükrözni, így lehetőséget nyújt arra, hogy a módosításokat ellenőrizzük, és meggyőződjünk arról, hogy tényleg megfelelnek a szándékunknak.

Rólunk

A Mitóban az ügynökségi munkák mellett komoly fejlesztéseket is végzünk, büszkék vagyunk rá, hogy többek közt az OTP Bank, a Magyar Telekom, a Deutsche Telekom, a Wizz Air, a Szerencsejáték Zrt., valamint az izlandi lottótársaság számára szállíthatunk webes és mobil megoldásokat.

http://mito.hu és http://mito.hu/jobs