Kihagyás

Tesztek fejlesztése

1. Bevezetés

Egy projekt kódbázisa a funkcionalitás növekedésével rohamosan nő. Értelemszerűen így a manuális regressziós tesztelés időigénye is hasonló arányban növekszik.

Ez mind a fejlesztőkre, mind a tesztelőkre extra terhet ró, ugyanis a fejlesztés és a tesztelés során is fokozottan ügyelni kell arra, hogy a korábbi funkcionalitást (hacsak nem ez a konkrét igény) ne módosítsák az újabb fejlesztések.

Annak érdekében, hogy csökkenteni tudjuk ezt a terhet, az automata tesztek írása minden új funkció esetén elvárt a fejlesztőktől.

Hiányzó tesztek megírása

Minden projekten előforduló eset, hogy valamely üzleti esetre nem készült a funkció fejlesztésekor automata teszt. (Pl.: valamilyen edge case-re nem gondolt a funkció lefejlesztője.)

Ilyenkor a hiányt azonosító fejlesztő kötelességi körébe tartozik jelezni a Project Architect felé, hogy hiányzó teszteket azonosított. Ezt követően a PA mérlegeli, hogy a hiányzó tesztet/teszteket az éppen aktuális ticketben vagy pedig szeparált ticketben szükséges-e lefejleszteni.

Test Driven Bugfixing

Számos esetben egy hibajelentés kapcsán derül ki, hogy valamely üzleti esethez nem készült automata teszt. Ilyenkor jó gyakorlat a teszt megírásával kezdeni a hibajavítást. Ezáltal az automatatesztünkkel tudjuk reprodukálni a hibát, majd a fix lefejlesztése után visszatesztelni, hogy sikerült-e a javítás.

Az automata tesztek legfontosabb előnyei:

  • Már a fejlesztő ellenőrizni tudja, hogy az új fejlesztésekkel nem változtatta-e meg a korábbi üzleti működést.
    • Ezáltal a fejlesztő is magabiztosabban tudja garantálni a fejlesztés minőségét.
    • Ezáltal kevesebb hiba jut el az ügyfél oldalra. (Ez növeli az ügyfél elégedettséget.)
    • Ezáltal kevesebb hibajegy keletkezik, és hosszútávon több idő marad a funkcionalitás bővítésére.
  • Megkönnyíti a jövőbeli refaktorálási feladatokat is, ugyanis a teszteknek köszönhetően magabiztosabban nyúlhatunk a korábbi implementációkba is.

Megjegyzés

A fejlesztés során nem csak a tesztek számmossága, hanem azok olvashatósága, struktúráltsága, illetve könnyű értelmezhetősége is fontos. Más szavakkal: a tesztek kódminősége, legalább olyan fontos mint a tesztelni kívánt kódé.

2. Az implementálandó tesztek fajtái

2.1 Unit teszt

  • Cél: Egy konkrét szerviz vagy utility osztály működésének tesztelése.
  • Prioritás: Alacsony. Csak akkor írjunk unit tesztet ha az általunk implementált osztályt jellemzően sok más szerviz fogja függőségként felhasználni. (Pl.: Util osztályok tipikusan ilyenek.)

2.2 Integration teszt

  • Cél: Egy konkrét szerviz és annak függőségeivel, illetve az adatbázissal való együttműködésének tesztelése.
  • Prioritás: Magas. Jelenleg ezeket a teszteket részesítjük előnyben.

Fontos

Általános szabály, hogy ne definiáljunk teszteseteket olyan működések ellenőrzésére amiket már korábbi Unit/Integration tesztekben lefedtünk.

Pl.: Ha a PasswordValidationServiceIntegrationTest.java-ban leteszteltük, hogy mi történik ha túl rövid jelszót ad meg a felhasználó, akkor a UserRegistrationServiceIntegrationTest.java-ban erre már ne hozzunk létre újabb teszt esetet. Továbbá, ha az említett eset nincs tesztelve a PasswordValidationServiceIntegrationTest.java osztályban, akkor is inkább oda hozzuk létre, hiszen logikialag is oda tartozik.

2.3 End to End teszt

  • Cél: Az alkalmazás tesztelése egészen a kliens oldalról indítva a kéréseket. Ezáltal az E2E tesztek célja az api és az alsóbb rétegek együttes működésén túl, a frontend - backend integráció tesztelése is.

  • Prioritás: Alacsony. Az End to End tesztek futtatása és/vagy karbantartása költséges lehet, ezért minden esetben gondoljuk át mikor van rá ténylegesen szükség. Vegyük sorra mik azok az esetek amiket Unit/Integration tesztekkel nem tudtunk lefedni és csak ezekre írjuk meg az End to End teszteket.

Fontos

Semmiképp ne essünk abba a hibába, hogy az End to End tesztekkel olyan működést tesztelünk ami tökéletesen lefedhető Unit/Integration tesztekkel.

Pl.: Beírt jelszó ellenőrzése a password policy-ban lefektetett szabályok alapján.

3. A tesztekre vonatkozó általános alapvelvek

  • A definiált tesztek legyenek függetlenek egymástól: Egy teszt sikeressége ne függjön attól milyen sorrendben futtatjuk a teszteket. Következésképp, semmiképp ne írjunk olyan tesztet aminek előfeltétele, hogy egy másik teszt nála korábban lefusson vagy csak utána fusson le.

  • Az Integration/Unit tesztek ne függjenek a tesztelt működést nem befolyásoló funkcionalitástól: Teszt íráskor igyekezzünk jól meghatározni a tesztelni kívánt működés határait. Koncentráljunk arra a funkcionalitásra amit valóban tesztelni akarunk és lehetőleg a teszt előfeltételeinek megteremtéséhez ne használjunk fel olyan funkciókat melyek befolyásolhatják a tesztünk sikeres lefutását.

    Például

    • Integration tesztek esetén, ha egy olyan funkciót tesztelünk amihez csak bejelentkezett felhasználó férhet hozzá, akkor a tesztadatokkal hozzuk létre az ehhez szükséges felhasználói session-t. Ne hívjuk meg például a bejelentkezéshez megírt funkciót a session létrehozásához, ugyanis utóbbi esetben a tesztünk akkor is el fog törni, ha valaki egy (akár nem szándékos) módosítással elrontja bejelentkezés funkciót, ezáltal pedig a tesztünk nem egyértelműen az általunk tesztelni kívánt funkció hibáit fogja detektálni.
    • Unit tesztek esetén tegyük fel, hogy van egy IbanService.java osztályunk az alábbi metódusokkal: Iban generateIban(Bic bic, AccountNumber accountNumber), validateIban(Iban iban). Ekkor amennyiben a validateIban(Iban iban) metódushoz írunk tesztet semmiképp ne a generateIban metódust használjuk a bemenő adatok előállításához, ugyanis ha a generateIban működése hibás akkor a megírt tesztek ellenére a validateIban helyes működésében sem lehetünk biztosak.
  • A tesztek legyenek újrafuttathatóak: Egy tesztet egymás után bármennyiszer le kell tudnunk futtatni. Ne fordulhasson elő, hogy adott teszt egy korábbi teszt miatt nem tud lefutni vagy olyan állapotot hagy az adatbázisban, ami miatt nem tud második indításra lefutni.

  • Integration tesztekben ne használjunk mock-olt adatokat: Minden esetben az adatbázisból, szervizeken keresztül érjük el a tesztekhez szükséges adatokat. Ha szükséges szúrjuk be adatbázisba a tesztadatokat.

  • A tesztekben is kerüljük el a kód duplikációt: A tesztek számára közös funkcionalitást mozgassuk be valamilyen közös segéd osztályba a test package alá. Semmiképp ne definiáljunk ugyanazokat a segéd metódusokat minden tesztosztályba.

  • Ne írjunk teszt specifikus kódot a production kódba: A kizárólag a tesztek kontextusában használt @Bean-ek és metódusok a test package alatt legyenek implementálva. Azaz ne írjunk a production kódba olyan metódusokat amiket csak tesztekből hívunk és ne definiáljunk production kódba olyan @Service-eket amiket csak tesztekben injektálunk stb.

    Teszt specifikus queryk

    A teszt specifikus kódra az egyik legjobb példa, amikor az assertekhez szükségünk van egy új repository hívásra amivel kinyerhető a teszt szempontjából specifikus adat az adatbázisból.

    Ilyen esetben két lehetőség kínálkozik:

    • Java oldalon a meglévő repository metódusok eredményét, hogy kiszűrjük a teszthez szükséges konkrét adatot.
    • Egy új repository metódust írunk ami visszaadja a teszthez szükséges konkrét adatot.

    Nyilván az utóbbi megoldás a kézenfekvőbb, hisz kevesebb kódolással jár. Azonban nem akarunk olyan kódot írni a production kódba amiket csak a tesztosztályaink fognak felhasználni.

    Ennek feloldására azt a megoldást tudjuk használni, hogy a test package alatt létrehozunk egy test Repository osztályt amibe tetszőleges metódusokat írhatunk és csak a tesztosztályokba fogjuk injektálni.

  • Az assertekhez AssertJ-t használjunk: A tesztek írásakor a org.assertj.core.api.Assertions package assert metódusait használjuk. Ennek oka, hogy számtalan könnyen használható előre definiált metódust kínál az AssertJ, amivel jól olvasható asserteket és könnyen értelmezhető hibaüzeneteket kapunk a tesztek írásakor/futtatásakor. Az AssertJ kikényszerítése érdekében az org.junit.jupiter.api.Assertions-t importálása esetén hibaüzenetet dob a CheckStyle validációnk.

  • A tesztek ne támaszkodjanak nem determinisztikus adatokra: Mindig figyeljünk arra, hogy a tesztjeink ne tudjanak „billegni“. Ez azt jelenti, hogy a teszteket úgy kell megírni, hogy bármikor is futtatjuk őket, ugyanazon előfeltételek esetén (pl.: ugyanaz az adatbázis állapot), ugyanazzal az eredménnyel végződjenek.

    Példa 1.: Listaelemek assertje

    Amikor egy vagy több listaelemet akarunk assertálni, akkor SOHA NE a listabeli index alapján vegyük ki azokat a listából:

    AuthUserEntity expectedAuthUserEntity = users.get(0);
    

    Ugyanis a fenti példával az a probléma, hogy a listákban az elemek sorrendje nem garantált. Emiatt megtörténhet, hogy a 0. elem nem az lesz amit elvárnánk. Ez sok esetben nem is nálunk okoz hibát, hanem más fejlesztőknél a CI futása során, ugyanis lehet hogy nálunk véletlenül tényleg a 0. listaelem helyére kerül amit assertálni akarunk, de egy későbbi futtatás során másik AuthUserEntity kerül a 0. helyre a listában.

    Épp ezért amennyiben egy vagy több lista elemet szeretnénk assertálni, mindig olyan megoldással vegyük ki a szükséges listaelemeket, ami garantálja, hogy azt az elemet fogja a teszt vizsgálni, amit elvárunk.

    Például, ha tudjuk hogy a testUser1 felhasználónévvel rendelkező felhasználót szeretnénk assertálni (és a felhasználó név egyedien azonosítja a felhasználókat a rendszerben):

    AuthUserEntity expectedAuthUserEntity = findByUsername("testUser1", users);
    
    Ahol a findByUsername implementációja azt az elemet adja vissza, ahol a AuthUserEntity.username = testUser1.

    Példa 2.: Dátumoktól függő tesztek

    Amikor egy olyan működést tesztelünk, amit befolyásol/befolyásolhat, hogy milyen dátumra futtatjuk a tesztet, (Pl.: Melyik konkrét évben, melyik hónapban, adott hónap melyik napján, adott hónap 15. napja előtt/után stb. fut a teszt.) olyankor SOHA NE az aktuális dátumot használjuk!

    Ugyanis ilyenkor előfordulhat, hogy a teszt megírásakor még lefut a teszt, de bizonyos idő múlva, vagy bizonyos dátumokon nem fog lefutni.

    Például, egy olyan funkcióhoz írunk tesztet, amiben minden hónap 15. napjáig engedünk fájlokat feltölteni.

    Egy ilyen teszt esetén mindig biztosítsuk, hogy a feltöltés dátumaként a teszt mindig egy fix dátumot használjon, és ne dinamikusan az aktuális dátumtól függjön.

4. Teszt metódusok elnevezési konvenciója

A tesztek célja nem csupán a helyes működés ellenőrzése, hanem dokumentációs szerepük is van.

Épp ezért fontos, hogy a megírt teszt metódus neve egyértelműen tartalmazza:

  • milyen metódust tesztel
  • mi az elvárt eredménye
  • illetve milyen előfeltételek esetén kapjuk az elvárt eredményt

A legkifejezőbb metódusnév elérése érdekében a következő sablont javasolt követni: test{ATeszteltMetódusNeve}_should{AzElvártEredmény}_when{ATeszteléshezHasználtParaméterek}

Példa:

@Test
public void testCreateUser_shouldReturnUserCreatedResponse_whenUsernameIsUnique {
  //test code
}

5. Tesztadatok előállítása tesztekhez

  • A tesztek előfeltételeit Java-ból szúrjuk be az adatbázisba, és nem SQL scripteken keresztül. Így könnyebben olvasható, nem adatbázisfüggő, továbbá könnyebb általános metódusokat építeni.

    Például

    Bejelentkezett user session-t ne a login endpoint meghívásával hozzunk létre, hanem Java-ból szúrjuk be -> ne függjön a teszt a login-tól hacsak nem pont azt teszteljük.

  • Minden teszt metódus futása előtt automatikusan lefut a ResetDatabase.deleteEverythingExceptMasterData metódus, aminek a célja, hogy visszaállítsa kezdőállapotra az adatbázist. Ha új táblát hozunk létre, akkor ezt a metódust bővítsük azzal, hogy alapértelmezett állapotba kerüljön az adatbázis.

  • A teszt futása előtt a DatabasePopulator osztály valamelyik metódusával töltsük be a tesztadatokat az adatbázisba.

    Például

    Ha valaki már írt metódust felhasználók beszúrásához, akkor használjuk a már megírt metódust és lehetőleg ne újat hozzunk létre felhasználók beszúrására.

  • Új metódust csak akkor hozzunk létre, ha biztosak vagyunk benne, hogy adott tesztadat beszúrásához nem tudjuk felhasználni a korábban létrehozottat.

    Például

    Regisztráció tesztelésénél, a bejelentkezés funkció teszteléséhez használt usereket beszúró metódus esetleg újrahasználható stb.