Kihagyás

JUnit tesztelés

Unit tesztelés

Ahhoz, hogy biztosak lehessünk abban, hogy a kód, amit írunk megfelelően működik, szükséges ellenőriznünk, avagy tesztelnünk annak működését. Ezt természetesen megtehetjük úgy, hogy egyszerűen kipróbáljuk a programot, és megbizonyosodunk arról, hogy látszólag azt csinálja, amit kell.
Amikor elkezdünk megvalósítani egy-egy funkcionalitást, akkor gyakran tesszük azt kezdő korunkban, hogy szimplán kipróbáljuk úgy az adott implementációt, hogy az adatainkat kézzel beolvassuk valahogy, majd a képernyőre kiiratott eredményt mi magunk ellenőrizzük. Sokkal egyszerűbb azonban, ha valamilyen automatizált eszközt használunk erre a célra. Amikor egy problémát specifikálunk, meg tudjuk adni azokat az elvárásokat, amiket az adott probléma megoldásával teljesíteni kell. Az egység, vagy idegen szóval unit tesztelés célja az, hogy ezeket az elvárásokat rögzítsük általa, és a funkció kifejlesztése közben automatikusan ellenőrizni tudjuk ezek teljesülését.

Természetesen a unit tesztelésnek is van hátránya, hiszen sokkal több kódot kell megírnunk, magukat a teszteket is le kell implementálni. Ugyanakkor ha van egy megfelelően használható tesztelő keretrendszerünk, akkor azért ez nem fog gondot okozni. A befektetett idő, amit a teszt írására szántunk pedig megtérülhet az által, hogy kevesebb időt kell arra pazarolnunk, hogy a nem megfelelő módon működő kódunkben megtaláljunk egy apró hibát. Ráadásul a későbbiekben is nagy-nagy hasznát vehetjük ezeknek a teszteknek, hiszen egy későbbi módosításakor a programnak ellenőrizhetjük, hogy a korábban már jól működő részei a programnak változatlanul megfelelően működnek-e. Így a programunk minőségét könnyebben tudjuk garantálni.

JUnit

A JUnit egy, a Java programok számára kifejlesztett unit tesztelési keretrendszer. A JUnit a Java reflection képességét használja ki annak érdekében, hogy a Java programok saját magukat ellenőrizni tudják. A programozó számára a JUnit lehetőséget biztosít és segíti:

  • saját tesztek készítését és futtatásáta,
  • a program követelményeinek formalizálását és az architektúra tisztázását,
  • a program írását és nyomkövetését (debugolását),
  • a kód integrálását és minőségének biztosítását.

A tesztelés legfőbb terminológiáit használva megkülönböztetjük az úgynevezett unit tesztet, ami egy osztály egységeinek (unitjainak, avagy metódusainak) tesztelésére szolgál. Egy teszt eset felelős egy egyedi unit, avagy metódus tesztelésére egy adott input által. Természetesen bonyolultabb esetben lehet, sőt kell is több teszt esetet adni egy egyszerű teszt esethez. Önmagában a unit tesztek nem elegendőek arra, hogy teljes mértékben validálják a program helyes működését. Kiegészítheti ezeket az integrációs tesztelés, ahol azt teszteljük, hogy a rendszer egyes elemei (osztályok és metódusok) hogyan dolgoznak együtt, de ez már nem része a JUnitnak. Így ezzel egyelőre nem is foglalkozunk.

A jelenleg elérhető legújabb JUnit a JUnit 5, amely magába foglalja a JUnit platformot, a JUnit Jupiter és JUnit Vintage modulokat. A JUnit 5 tesztek eltérnek a JUnit 4-ben használt tesztektől, azonban azt elmondhatjuk, hogy a JUnit 4 tesztjei futtathatóak a JUnit 5 keretrendszer alatt is, elvárás csupán az, hogy a JUnit 5 már legalább JDK 8-at elvár, szemben a JUnit 4 JDK 5-ös elvárásával. Mivel manapság azért a JUnit 5 elvárása a legtöbb esetben teljesül, illetve a kurzusnak nem célja megismertetni a JUnit tesztelésben rejlő összes lehetőséget, így a továbbiakban mi a JUnit 5 alapjait ismertetjük csak.

Assert osztály metódusai

A unit tesztelés folyamata során a feladatunk az, hogy meghívjuk a tesztelendő metódust bizonyos paraméterekre, majd megbizonyosodjunk arról, hogy a kapott eredmények az elvárt viselkedésnek megfelelőek. Ehhez úgynevezett asserteket írunk, amelyek ellenőrzik a kívánt viselkedést, tulajdonságot.

Az assert metódus nem más, mint a JUnitnak egy olyan metódusa, amely egy ellenőrzést képes megvalósítani, és abban az esetben, ha az ellenőrzés sikertelen, egy AssertionError kivételt dob, ami jelzi, hogy az adott teszt eset kiértékelése elbukott. Természetesen a programot addig kell javítani, illetve a teszteket újra futtatni, míg valamennyi teszt hiba nélkül le nem fut.
Amikor egy teszt elbukik, és dob egy AssertionError kivételt, akkor a JUnit keretrendszer ezt a hibát elkapja, és jelzi a programozó felé.

Assert metódusok fajtái:

Metódus Leírás
void assertTrue(boolean test, [message]) Ellenőrzi, hogy a logikai feltétel igaz-e.
void assertFalse(boolean test, [message]) Ellenőrzi, hogy a logikai feltétel hamis-e.
void assertEquals(expected, actual, [message]) Az equals metódus alapján megvizsgálja, hogy az elvárt és a tényleges eredmény megegyezik-e.
assertEquals(expected, actual, tolerance, [message]) Valós típusú elvárt és aktuális értékek egyezőségét vizsgálja, hogy belül van-e tűréshatáron.
assertArrayEquals(expected[], actual[], [message]) Ellenőrzi, hogy a két tömb megegyezik-e.
assertNull(object, [message]) Ellenőrzi, hogy az ojektum null-e.
assertNotNull(object, [message]) Ellenőrzi, hogy az objektum nem null-e.
assertSame(expected, actual, [message]) Ellenőrzi, hogy az elvárt és a tényleges objektumok referencia szerint megegyeznek-e.
assertNotSame(expected, actual, [message]) Ellenőrzi, hogy az elvárt és a tényleges objektumok referencia szerint nem egyeznek-e meg.
fail([message]) Feltétel nélkül elbuktatja a metódust. Annak ellenőrzésére használhatjuk, hogy a kód egy adott pontjára nem jut el a vezérlés, de arra is jó, hogy legyen egy elbukott tesztünk, mielőtt a tesztkódot megírnánk.

Annotációk

A JUnit 5 különböző annotációk segítségével teszi egyszerűbbé és rugalmasabbá a tesztek megírását. A leggyakoribb teszt annotációkkal megadhatjuk az egyes metódusok szerepét a tesztelés során:

Annotáció Leírás
@Test Az adott metódus teszt metódus.
@BeforeEach Az adott metódus lefut minden olyan metódus előtt lefut, amely a @Test, @RepeatedTest, @ParametrizedTest vagy @TestFactory annotációkkal vannak ellátva, szerepe a teszt esetek inicializálása.
@AfterEach Az adott metódus lefut minden olyan metódus után lefut, amely a @Test, @RepeatedTest, @ParametrizedTest vagy @TestFactory annotációkkal vannak ellátva, feladata az ideiglenes adatok törlése.
@BeforeAll Az adott metódus egyszer fut le még azelőtt, hogy bármelyik @Test, @RepeatedTest, @ParametrizedTest vagy @TestFactory annotációkkal ellátott metódus futna, illetve az azokat megelőző @BeforeEach metódusok előtt. Itt lehet egyszeri inincializációs lépéseket megtenni. Ezzel az annotációval ellátott metódusnak statikusnak kell lennie.
@AfterAll Az adott metódus egyszer fut le azután, miután mindegyik @Test, @RepeatedTest, @ParametrizedTest vagy @TestFactory annotációkkal ellátott metódus lefutott, illetve azok @AfterEach metódusai lefutottak. Egyszeri tevékenységet ellátó utasítások helye, amelyek általában a @BeforeAll metódus által allokált erőforrások felszabadítására hivatottak. Ezzel az annotációval ellátott metódusnak statikusnak kell lennie.
@Disabled Adott teszt metódus letiltása.

Ha valaki JUnit 5 helyett Junit 4 teszteket írna, akkor ezekhez nagyon hasonló annotációkat tud használni, az elnevezésekre azonban ügyelni kell.

Példa

Annélkül, hogy most ténylegesen egy Java osztályt tesztelnénk, készítsünk egy olyan tesztet, amin az alapvető tesztelési elemeket megfigyelhetjük és kipróbálhatjuk.

import static org.junit.jupiter.api.Assertions.fail;
import static org.junit.jupiter.api.Assumptions.assumeTrue;

import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;

class StandardTests {

    @BeforeAll
    static void setUpAll() {
        System.out.println("setUpAll method is running.");
    }

    @BeforeEach
    void setUp() {
        System.out.println("setUp method is running.");
    }

    @Test
    void succeedingTest() {
        System.out.println("succeedingTest method is running.");
    }

    @Test
    void failingTest() {
        System.out.println("failingTest method is running.");
        fail("a failing test");
    }

    @Test
    @Disabled("for demonstration purposes")
    void skippedTest() {
        System.out.println("skippedTest method is running.");
        // not executed
    }

    @Test
    void abortedTest() {
        System.out.println("abortedTest method is running.");
        assumeTrue("abc".contains("Z"));
        fail("test should have been aborted");
    }

    @AfterEach
    void tearDown() {
        System.out.println("tearDown method is running.");
    }

    @AfterAll
    static void tearDownAll() {
        System.out.println("tearDownAll method is running.");
    }

}

Lévén JUnit 5-ös tesztet írtunk most, így a teszteléshez felhaszált legtöbb elem az org.junit.jupiter csomagból származik, amely osztályokat így importálunk a teszt unit elején. A StandardTests osztályban annotációkkal megadjuk azokat a metódusokat, amelyek minden teszt előtt/után lefutnak, és megadjuk azokat is, amelyek mindegyik tesztet meg kell előzzék, le kell zárják. Érdekességképp az elnevezésekben (setUp, tearDown) megőriztük a korábbi (JUnit 3-as vagy előtti) elnevezéseket, ahol még az annotációk helyett a polimorfizmus adta lehetőségekkel oldották meg ezen inicializáló függvények megfelelő időpontban történő meghívását.

Összesen 4 teszt metódust valósítunk meg a példában. A succeedingTest metódus mivel ellenőrzést sem tartalmaz, így minden gond nélkül sikeresen kell lefusson. A failingTest a fail hívása által elbuktatja a tesztet. A skippedTest olyan teszt, amit aktuálisan nem szeretnénk futtatni, ezt érjük el a @Disabled annotáció által. Fejlesztés során előfordulhat, hogy van olyan ismert hiba, ami miatt egy teszt még nem tud lefutni, ilyenkor használhatjuk ezt a megoldást annak érdekében, hogy jobban tudjunk a működő funkcionalitások helyességére koncentrálni. Végezetül az abortedTest-ben egy olyan esetet mutatunk, ahol assert helyett egy assume utasítást használunk. Látszólag a két dolog ugyanazt csinálja, ha a feltételünk nem teljesül, a teszt végrehajtása befejeződik, hiba jelentkezik. Ugyanakkor fontos megkülönböztetni ezeket az eseteket. Az assume általában egy különálló feltételt vizsgál, amely ha nem teljesül, nincs értelme futtatni a tesztet, az funkcionalitásban nem lesz tesztelhető, ha ez az utasítás elbukik, akkor a teszt státusza is aborted lesz, failed helyett. Az assert ezzel szemben tényleg funkcionalitást fog tesztelni.

Amikor elkészült egy JUnit teszt fájl, nyilvánvalóan szeretnénk azt fordítani, és futtatni is. Az IDE eszközök biztosítják azt számunkra a legtöbb esetben, hogy ezt könnyedén megtegyük, maguk a tesztek is könnyen fordíthatóak. A build rendszerekhez is könnyedén hozzáadhatjuk a junit függőségeket.

Parancssori futtatáshoz a Console Launcher használható, ehhez szükséges, hogy letöltsük a junit-platform-console-standalone-x.y.z.jar (most junit-platform-console-standalone-1.8.2.jar) állományt, aminek segítségével fordíthatjuk a tesztünket:

javac -cp junit-platform-console-standalone-1.8.2.jar StandardTests.java

Most itt feltételezzük, hogy egy könyvtárban van a teszt és a jar. Majd futtathatjuk a launchert:

java -jar junit-platform-console-standalone-1.8.2.jar --class-path . --select-class StandardTests

A launchert a különböző opcióival teljesen igényeink szerint igazíthatjuk, és tetszőleges módon futtathatjuk általa tesztjeinket.

A tesztek futása után szépen láthatjuk a tesztfuttatás hibáit és statisztikáit is:

Kimenet

setUpAll method is running.
setUp method is running.
succeedingTest method is running.
tearDown method is running.
setUp method is running.
failingTest method is running.
tearDown method is running.
setUp method is running.
abortedTest method is running.
tearDown method is running.
tearDownAll method is running.

...

Test run finished after 81 ms
[ 3 containers found ]
[ 0 containers skipped ]
[ 3 containers started ]
[ 0 containers aborted ]
[ 3 containers successful ]
[ 0 containers failed ]
[ 4 tests found ]
[ 1 tests skipped ]
[ 3 tests started ]
[ 1 tests aborted ]
[ 1 tests successful ]
[ 1 tests failed ]

Példa 2.

Annak érdekében, hogy a JUnit tesztelésben rejlő valódi lehetőségeket is lássuk, természetesen egy osztályt, illetve annak funkcionalitását kell teszteljük. Legyen a továbbiakban egy Teglalap osztályunk, amelyben két adattag által reprezentálni tudjuk egy-egy téglalap objektum oldalhosszait. Legyen az osztályban egy terulet és egy kerulet függvény, amelyek az adott Teglalap objektumunk területét és kerületét adják vissza. Emellett legyen egy negyzete metódus is, amely igaz értékkel tér vissza, ha az adott téglalap objektum négyzet, ellenkező esetben hamissal tér vissza.

Mindezek mellett a Teglalap osztály rendelkezzen egy olyan nagyobb nevű osztály metódussal is, amely a paraméterként kapott két téglalap objektum közül visszatér azon téglalap referenciájával, amelynek nagyobb a területe. Ha a két terület egyenlő, akkor az első paraméterben kapott téglalap referenciája legyen a visszatérési érték.

class Teglalap {
     private double a, b;
     public Teglalap() {
         this.a = 0;
         this.b = 0;
     }

     public Teglalap(double a, double b) {
         this.a = a;
         this.b = b;
     }

     public double terulet () {
         return a*b;
     }

     public double kerulet () {
         return 2*(a+b);
     }

     public boolean negyzete () {
         return a == b;
     }

     public static Teglalap nagyobb(Teglalap t1, Teglalap t2) { 
         if (t1.terulet() >= t2.terulet())
             return t1;
         else return t2;
     }

Írjunk ehhez az osztályhoz JUnit tesztet! Bonyolultabb osztályok esetében persze előbb érdemesebb a tesztet megírni, amelyek teszt metódusainak mindaddig el kell buknia, amíg a megfelelő funkcionalitást nem tudtuk implementálni.

import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;

class TeglalapTests {
  private static Teglalap egyikTeglalap;
  private static Teglalap masikTeglalap;

  @BeforeAll
  static void setUpAll() {
    egyikTeglalap = new Teglalap(4.0, 4.0);
    masikTeglalap = new Teglalap(2.75034, 2.3699);
  }

  @Test
  void testTerulet() {
    assertTrue(egyikTeglalap.terulet() == 16.0, 
         "A teglalap terulete nem megfelelo");
    assertEquals(6.518030766, masikTeglalap.terulet(), 
         "A teglalap terulete nem megfelelo");

    /*FONTOS!!! 
     * double értékek összehasonlítására az előzőeket NE használjuk!!!*/
    assertEquals(6.52, masikTeglalap.terulet(), 0.01,
        "A teglalap terulete nem megfelelo");
  }

  @Test
  void testKerulet() {
    assertEquals(egyikTeglalap.kerulet(), 16.0, 0.001, 
        "A teglalap kerulete nem megfelelo");
  }

  @Test
  void testNegyzete() {
    assertTrue(egyikTeglalap.negyzete(), "Ez a teglalap negyzet");
    assertFalse(masikTeglalap.negyzete(), "Ez a teglalap nem negyzet");
  }

  @Test
  void testNagyobb() {
    assertSame(egyikTeglalap, Teglalap.nagyobb(egyikTeglalap, masikTeglalap), 
       "Nem a nagyobb teglalapot valasztottad");
  }
}

Fordításkor vegyük figyelembe, hogy a teszt fordításához a fordítási útvonalon kell legyen maga a Teglalap.java állomány is. Mivel a -cp kapcsolóval az alapértelmezett classpath-t módosítottuk, így most ehhez hozzá kell adnunk azt az utat, ahol a tesztelendő állomány van. Egyszerűség kedvéért ez most legyen az aktuális könyvtárunkból elérhető:

javac -cp junit-platform-console-standalone-1.8.2.jar;"." TeglalapTests.java

(A ; természetesen : lesz, ha windows helyett linuxon próbáljuk fordítani a tesztet.)

A teszt setUpAll metódusában létrehoztunk két téglalap objektumot. Valamennyi tesztben ezeket fogjuk felhasználni. Mivel maguk a metódusok nem változtatják ezen objektumok állapotát, így nem probléma, hogy ezeket nem inicializáljuk minden teszt futtatása előtt.

A Teglalap osztály terulet, kerulet és negyzete metódusai példánymetódusok, azaz őket egy-egy objektum példányon keresztül tudjuk meghívni. Az assertTrue és assertEquals metódusokkal látszólagosan ugyanazt érjük el, azonban alaposabban megvizsgálva ezeket láthatjuk, hogy az assertTrue esetében hiba esetén csak annyit tudunk meg, hogy az elvárt érték nem ugyanaz, mint a kapott érték. Ha tehát nem konkrétan logikai értékeket szeretnénk ellenőrizni, mint a testNegyzete metódusban a negyzete metódus tesztelésekor, akkor mindenképp érdemesebb assertEquals ellenőrzést használni.

A Teglalap osztály adattagjait double típusúnak választottuk. Bár jelen esetben nem okoz gondot, ahogy a 18. és 20. sorokban ellenőriztük az elvárt és kapott értékek azonosságát, általában azért igaz az, hogy double esetében a számítógép számábrázolásának pontatlansága miatt érdemesebb azokat az assert metódusokat használni, amelyek csak meghatározott közelítés mellett hasonlítják a valós számokat.

A Teglalap osztály egyetlen static, azaz osztály metódusa a nagyobb metódus. Mivel ez osztály metódus, ezért ezt a teszt metódusában az osztály példányosítása nélkül a Teglalap osztályon keresztül tudjuk meghívni. A teszteléshez használt téglalap objektumok ennek a metódusnak csupán a paraméterei lesznek.


Utolsó frissítés: 2022-03-16 08:55:57