Kihagyás

11. gyakorlat

Tesztelés

Az elkészült forráskódunkat az eddigiekben csak átolvasással (code review), valamint a main függvényben való próbálgatással tesztelhettük ki. A valóságban természetesen ezek is hasznos módszerek, ugyanakkor nagyobb szoftverek esetén ezek a megközelítések nem igazán hatékonyak. Annak érdekében, hogy a tesztelésünk kicsit összeszedettebb legyen, valamint az emberi tényezőt is kivonhassuk belőle, automatizált tesztelésben célszerű gondolkodni. Ennek egyik eszköze az egységtesztek (unit tesztek). Unit teszteléssel a kód egyes egységeit tesztelhetjük (az egység definíciója tetszőleges, érdemes a lehető legkisebb egységeket készíteni), melynek során objektumokat hozunk létre, metódusokat hívunk meg, majd megvizsgáljuk a visszaadott értéket és/vagy az objektum állapotát. A tesztekben előre definiáljuk azokat az értékeket, amiket elvárunk egy-egy metódushívás hatására. Amennyiben nem megfelelő értékeket kapunk a metódushívás után, akkor valaki olyan módosítást végzett a kódban, amely hatására az eredeti működés módosul.

Érdekesség

Napjainkban egyre népszerűbb a TDD (Test-Driven Development). A módszertan lényege az, hogy a fejlesztők először megírják a teszteket, majd addig dolgoznak a forráskódon, amíg nem felelnek meg az előre megírt teszteknek. Érdemes kipróbálni, hogy milyen így a fejlesztés.

A gyakorlatban ez általában úgy néz ki, hogy egy adott osztályhoz tartozik egy teszt osztály. A tesztekben mindig szélsőértékekben célszerű gondolkodni, hiszen ha egy szám prímségét eldöntő algoritmust tesztelünk 100 darab nem prím számmal (például 4 többszöröseivel), az nem túl sokat mond az elkészült algoritmus helyességéről.

A tesztelés során mindig azt szeretnénk, hogy a kódunk lehető legnagyobb része ellenőrizve legyen (le legyen fedve tesztesetekkel), azonban ennek is számos szintje lehet:

  • osztály szintű lefedettség: a tesztelés során minden osztály le van fedve tesztesetekkel.
  • metódus szintű lefedettség: a tesztelés során minden metódus le van fedve tesztesetekkel.
  • végrehajtási ág szintű lefedettség: a tesztelés során a metódusok minden végrehajtási ága le van fedve tesztesetekkel (például egy if-nek két végrehajtási ága lehet, amikor az if feltétele igaz, és az, amikor nem).
  • feltétel szintű lefedettség: minden végrehajtási ághoz kapcsolódó minden feltétel le van fedve tesztesetekkel (a short circuit evaluation miatt például az if-en belül nem minden feltétel értékelődik ki).

Természetesen ezeken felül másfajta lefedettséget is mérhetünk (számos egyéb is létezik még), a lefedettségről bővebben például itt olvashatsz. A felsoroltak közül sem mind hasznos minden esetben (az, hogy minden osztályra van egy tesztesetünk az igaziból nem feltétlenül mond sokat arról, hogy az osztály mennyire van kitesztelve). A gyakorlatban széles körben használt mérőszám a végrehajtási ág (branch) szintű lefedettség figyelése, mérése. A gyakorlati jegyzetben is erre fogunk figyelni, a metódusaink minden végrehajtási ágát megpróbáljuk lefedni tesztekkel, valamint a kötelező programban is erre érdemes figyelni.

JUnit 5

A Java nyelv alapból nem támogatja az egységtesztelést, nincsenek beépített osztályok erre (léteznek olyan nyelvek, ahol vannak ilyenek, például a Pythonban). Ahogy egy korábbi házi feladatban láthattuk, kézzel is írhatunk olyan metódusokat, amelyek segítségével kipróbálhatjuk a megírt kódjainkat, azonban erre léteznek olyan, már előre megírt külső függvénykönyvtárak, amelyek széles körben használtak, és a későbbiekben is vélhetően találkozhatunk ezekkel. Ezen könyvtárak egyike a JUnit, amelyből a legújabb, 5-ös főverzióval fogunk foglalkozni.

Megjegyzés

A JUnit egy egységtesztelő keretrendszer, számos ehhez hasonló keretrendszer is létezik (például a TestNG). Továbbá léteznek olyan keretrendszerek is, amelyek a tesztelést segítik, és kiegészítik valamely módon az egységtesztelést, például a JBehave a Behaviour-Driven Development (BDD) módszertanában nyújt segítséget, a Selenium automatizált UI tesztelésben nélkülözhetetlen, a Mockito segítségével pedig mock objektumokat készíthetünk a tesztjeinkhez.

Telepítés

Mivel a JUnitot nem mi készítettük, így azt először le kell töltenünk, és a projektünkhöz kell adni, hogy használhassuk egy fejlesztőkörnyezetből. Először is látogassunk el erre az oldalra, és töltsük le a junit-jupiter-api-5.8.2.jar fájlt, ami tartalmazza a teszteléshez szükséges dolgokat. Majd, látogassunk el ide, ahonnan töltsük le a tesztek futtatásához szükséges fájlokat, melyek a junit-platform-engine-1.8.2.jar és junit-platform-commons-1.8.2.jar fájlok.

A letöltés után célszerű lehet olyan helyre tenni, ahol szem előtt van, és könnyedén mozgathatjuk a projektünkkel, például a projekt mappájában egy libs vagy felhasznalt_libek könyvtárba. Ezt követően még a projekthez hozzá kell adnunk, amelyet az alábbiak szerint tehetünk meg.

  • IntelliJ Idea esetén: a megnyitott projekt ablakában a File > Project Structure... menüpontra kell kattintani. Ezt követően a bal oldali panelon a Project Settings-en belül a Module almenüpontra kell kattintani. Itt megtalálhatjuk a Module SDK sort, és alatta egy "+" jelet láthatunk. Erre kattintva felugrik egy menü, itt válasszuk a JARs or Directories menüpontot. A felugró tallózó segítségével talózzuk be a jar kiterjesztésű fájlokat egyesével.

Segítség a tallózáshoz

Helyesen betallózott függőségek

  • Eclipse esetén: TODO

Ezt követően a fejlesztői környezetből készíthetünk és futtathatunk teszteket, az IDE ismerni fogja a teszteléshez szükséges dolgokat, a megszokott kódkiegészítés is működni fog. A tesztek futtatásában majd segít az IDE, az elkészült teszt osztályon jobb klikk, és Run AdottOsztály végrehajtja a teszteket, az eredményeket pedig a felületen fogja mutatni.

Amennyiben parancssorból is használni szeretnénk a tesztjeinket, ahhoz a Console Laucher osztályt használhatjuk. Ennek működéséről az előadásanyagban, valamint a linkelt oldalon lehet tájékozódni, a gyakorlaton ezzel nem foglalkozunk.

Használat

Ennyi előkészület után végre ott tartunk, hogy készíthetünk teszteket is az osztályainkhoz. Ahhoz, hogy legyen mit tesztelnünk, létrehozunk egy Torta osztályt, ez egy tetszőleges tortát reprezentál. A korábban látot sütis példát bevetjük ebben az osztályban.

public class Torta {
    private int osszesSzelet;
    private int jelenlegiSzelet;
    private String iz;

    public Torta(int szelet, String iz) {
        setOsszesSzelet(szelet);
        this.jelenlegiSzelet = szelet;
        this.iz = iz;
    }

    /**
     * Megadja, hogy elfogyott-e az adott torta
     * @return true, ha elfogyott a torta
     */
    public boolean elfogyott() {
        return jelenlegiSzelet == 0;
    }

    /**
     * Eszünk néhány szelet tortát.
     * @param mennyi megenni kívánt torta.
     * @return hány szelet tortát fogyasztottunk el.
     */
    public int tortaszeletekElfogyasztasa(int mennyi) {
        int elfogyaszthatoSzeletek = Math.min(mennyi, jelenlegiSzelet);
        jelenlegiSzelet -= elfogyaszthatoSzeletek;
        return elfogyaszthatoSzeletek;
    }

    /**
     * Eszünk egy szelet tortát
     */
    public void tortaszeletElfogyasztasa() {
        if (jelenlegiSzelet == 0) {
            System.out.println("Nincs mit elfogyasztani!");
            return;
        }
        this.jelenlegiSzelet--;
    }

    /**
     * Megadja, hogy a pénzünkért hány darab szelet tortát vehetünk.
     *
     * Az első szelet ára 0.1 fabatka.
     * Ezt követően minden további szelet 0.1 fabatkával drágább, mint az előző.
     *
     * @param penzunk amennyi pénzünk van tortára.
     * @return mennyi pénzünk maradt a végén
     */
    public double szeletVasarlas(double penzunk) {
        int megvettSutik = 0;
        for (double ar = 0.1; penzunk >= ar && megvettSutik < jelenlegiSzelet; ar += 0.1) {
            penzunk -= ar;
            ++megvettSutik;
            tortaszeletElfogyasztasa();
        }
        return penzunk;
    }

    // Getterek és szetterek.

    public int getOsszesSzelet() {
        return osszesSzelet;
    }

    public void setOsszesSzelet(int osszesSzelet) {
        if (osszesSzelet < 0) {
            osszesSzelet = 0;
        }
        this.osszesSzelet = osszesSzelet;
        this.jelenlegiSzelet = osszesSzelet;
    }

    public int getJelenlegiSzelet() {
        return jelenlegiSzelet;
    }

    public String getIz() {
        return iz;
    }

    public void setIz(String iz) {
        this.iz = iz;
    }
}

Ez jó sok tesztelni való dolog, így kezdjünk is bele. Nagyobb projektek esetén, ahol valamilyen build (építő) rendszerrel támogatják a fejlesztést, más struktúrája van a kódnak, de mivel mi még ott nem tartunk, ezért csak egyszerűen az src mappába hozzunk létre egy test nevű csomagot, amibe minden osztályhoz lesz egy teszt osztályunk.

A teszt osztályok elnevezése az osztály nevéből, valamint a név elé írt Test szócskából áll. Ez konvenció, igaziból bármi lehetne, de célszerű ránézésre is könnyen beazonosítható nevekkel dolgozni. Egyes közösségek az osztálynév után írt Test szócskás formátumot preferálják.

public class TestTorta {

}

Importok

A tesztek használatához természetesen először is importálni kell a megfelelő csomagokból az osztályokat, ezekben az IDE készségesen segít, de ha kényelmesebb először beírni az importokat, akkor az alábbi sorokkal megoldható minden szükséges komponens importálása.

import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;

A kódolás végén IntelliJ IDEA esetén a Ctrl + Alt + O (vagy Code > Optimize Imports menüpont) rendbeszedi az importokat, és a felesleges .* importokat kiszedi, helyette egyesével importálja be a szükséges részeket.

Tesztek előtt/után lefutó metódusok

A tesztelés során készíthetünk olyan metódusokat, amelyek minden tesztelő metódus előtt (vagy után) lefutnak. Például, ha a teszt osztályunkban használunk a Torta osztályból egy példányt, azt minden teszt metódus futtatás előtt újra szeretnénk inicializálni, hogy pontosan tudjuk, hogy az objektum milyen állapotban van, és elkerüljük a tesztelő metódusok közötti sorrendi, vagy bármely egyéb függőséget.

Fontos!

A teszt metódusokat úgy kell megírnunk, hogy azok egymástól ne függjenek, sem pedig attól, hogy milyen sorrendben hajtom végre a teszteket. Sőt, arra is célszerű figyelni, hogy minden egyes teszt metódus lehetőség szerint pontosan egy funkcionalitást teszteljen. Ez a valóságban sajnos nem mindig kivitelezhető. A probléma feloldását jelentheti például mockok használata, amelyekről ezen a kurzuson nem lesz szó.

Ha ilyen metódust szeretnénk készíteni, a metódus felett @BeforeEach annotációval jelezhetjük, hogy az adott metódusnak minden tesztelő metódus előtt le kell futnia. Ha teszt metódus után szeretnénk futtatni egy metódust, akkor ahhoz a @AfterEach annotációt kell használnunk. Ilyen metódusokba célszerű az objektum kezdeti inicializálását elvégezni, vagy például a teszt utáni takarítást végrehajtani (például létrehozott fájlok törlése).

public class TestTorta {
    private Torta peldany;

    @BeforeEach
    public void setUp() {
        peldany = new Torta(8, "csoki");
    }
}

Olyan metódusok is léteznek, amelyek az osztály tesztelésének megkezdése előtt (vagy után) futnak le. Erre a célra a @BeforeAll és @AfterAll annotációkat használhatjuk, melyeket szintén a metódus felé kell kitennünk. Azonban ebben az esetben, mivel az egész tesztelés megkezdése előtt szeretnénk végrehajtani a kívánt műveleteket, így ezen metódusoknak szükséges, hogy statikusak legyenek (static kulcsszó). Ilyen metódusokban általában az osztály teszteléséhez szükséges, ám hosszabb életű dolgokat szokták itt beállítani/kitakarítani, legyen szó akár adatbázis kapcsolatról, vagy teszt-mappaszerkezetről.

    @AfterAll
    public static void tearDown() {
        System.out.println("Teszteles befejezve!");
    }

A fentebb is említett inicializáló és "takarító" metódusok elnevezései változatosak, de általában itt is beszédes nevet szokás adni a metódusnak (például setUp, tearDown, initDatabase, cleanup, stb).

Teszt metódusok

Most már van egy olyan metódusunk, ami minden teszt metódus előtt inicializálja nekünk a tortát, azonban teszt metódusaink nincsenek. Hozzunk létre párat! A példa egyszerűségéért létrehozunk egy egyszerű konstruktor és getter tesztet. A teszt metódusok neve is tetszőlegesek lehetnek, de célszerű itt is beszédes elnevezésekre hagyatkozni, valamint itt az sem baj, ha a metódus neve jó hosszú, lényeg, hogy érthető legyen, hogy mi történik.

    @Test
    public void testConstructorsAndGetters() {

    }

Ha kész a teszt metódus, jobb klikk a metódus fejlécének sorában és kattintsunk a Run testConstructorsAn...() menüpontra.

Lefedettségi eredmény

Ahogy látjuk a tesztelés sikeresen befejeződött, a teszt metódusunk lefutott, a fentihez hasonló dolgokat kell látnunk az IDE-ben. Ha a tesztelés során sok pirosat látunk, akkor valószínűleg nem jól csináltuk meg a függőségek hozzáadását. A teszt lefutott, de igaziból nem is teszteltünk semmit, itt az ideje, hogy a teszt valóban tesztelje is a nevében említett dolgokat. A tesztelés során különböző, garantáltan helyes értékekkel hasonlítjuk össze azt, amit az objektumunk visszaad. Tehát tudjuk, hogy getIz metódus a létrehozott torta ízét adja vissza (amit mi a konstruktorban beállítottunk), akkor ha "sajt" szöveget adtunk meg a konstruktorban ízként, akkor feltételezhetjük, hogy a getIz ebben az esetben a "sajt" szöveget adja vissza.

Assertek

Ennek vizsgálata egy egyszerű if-fel is történhetne, de akkor a keretrendszer adta metódusokat, funkcionalitást nem használjuk ki (például, hogy ha az összehasonlítás sikertelen, akkor hibát kellene dobni). Így a legtöbb esetben a JUnit5 különböző assert metódusait használjuk. Az alábbiakban bemutatunk néhányat ezek közül, de természetesen ennél több van, amelyenk feltérképezése a hallgató feladata.

  • assertTrue(érték[, üzenet]): azt várjuk, hogy a megadott logikai érték igaz lesz. Amennyiben mégsem igaz, hibát kapunk. Ha szeretnénk, második paraméterben átadhatunk egy üzenetet, amit hiba esetén látni fogunk. Például az assertTrue(false) garantáltan hibát eredményez, hiszen a false érték nem igaz.
  • assertFalse(érték[, üzenet]): azt várjuk, hogy a megadott logikai érték hamis lesz. Amennyiben mégsem hamis, hibát kapunk. Ha szeretnénk, második paraméterben átadhatunk egy üzenetet, amit hiba esetén látni fogunk. Például az assertFalse(false) tökéletesen lefut, míg az assertFalse(true) garantáltan hibát eredményez, hiszen a true érték nem hamis.
  • assertNotNull(érték[, üzenet]): azt várjuk, hogy az átadott paraméter nem null. Ha mégis null, hibát kapunk. Megadhatjuk a hiba esetén kiírandó üzenetet is.
  • assertNull(érték[, üzenet]): azt várjuk, hogy az átadott paraméter null. Ha mégsem null, hibát kapunk. Megadhatjuk a hiba esetén kiírandó üzenetet is.
  • assertEquals(elvárt, kapott[[, delta], üzenet]): azt várjuk, hogy az elvárt és a kapott értékek megegyeznek. Fontos, hogy itt számít a sorrend, hiszen ha a két érték mégsem egyezne meg, olyan hibaüzenetet kapunk, amely figyelmeztet minket arra, hogy mi lett volna az elvárt érték, és a program milyen eredményt produkált. És ezek felcserélése elég komoly értelmezési hibákat eredményezhet. Megadhatjuk a hiba esetén kiírandó üzenetet is.

Nagyon fontos!

Lebegőpontos értékek esetén mindenképp használjuk az assertEquals olyan overloadját, amely egy delta értéket is vár, ez egy tetszőlegesen kicsi érték lehet. Ebben az esetben, ha az abszolút különbség az elvárt és a kapott között legfeljebb delta, akkor a két érték egyformának tekintendő. Például, az assertEquals(1.2, 1.2001, 0.001) esetében az assert igaz lesz, hiszen a két érték közötti különbség kisebb, mint a megadott delta. Ugyanakkor ezt csak a lebegőpontos számábrázolás-beli hibák kiküszöbölésére használjuk, arra ne, hogy a "nagyjából jó" eredményeket elfogadjuk.

  • assertNotEquals(elvárt, kapott[[, delta], üzenet])): azt várjuk, hogy az elvárt és a kapott értékek nem egyeznek meg. Minden egyéb, ami az assertEquals-nál szerepel, igaz lesz itt is (sorrend, delta). Megadhatjuk a hiba esetén kiírandó üzenetet is. Megadhatjuk a hiba esetén kiírandó üzenetet is.
  • assertArrayEquals(elvárt, kapott[, üzenet]): azt várjuk, hogy a két tömb megegyezik, nem csak a benne tárolt értékekben, de azok sorrendjében is, tehát a assertArrayEquals(new int[]{1, 2, 3, 5}, new int[]{5, 2, 3, 1}); nem lesz igaz, hibát kapunk. Megadhatjuk a hiba esetén kiírandó üzenetet is.
  • assertSame(elvárt, kapott[, üzenet]): azt várjuk, hogy a két objektum konkrétan ugyanaz (megegyezik a referenciájuk). Ezt felírhatnánk assertTrue(elvárt == kapott) formában is, azonban ennek az assertnek a hibaüzenete kevésbé félrevezető. Megadhatjuk a hiba esetén kiírandó üzenetet is.
  • assertNotSame(elvárt, kapott[, üzenet]): azt várjuk, hogy a két objektum referenciája nem egyezik meg. Ezt felírhatnánk assertFalse(elvárt == kapott), esetleg assertTrue(elvárt != kapott) formában is, azonban ennek az assertnek a hibaüzenete kevésbé félrevezető. Megadhatjuk a hiba esetén kiírandó üzenetet is.
  • fail([üzenet]): ha a tesztet valami miatt garantáltan el kell buktatnunk (például bizonyos alapfeltételek nem igazak), a fail segítségével tehetjük meg, amely semmi mást nem csinál, csak elbuktatja az adott tesztet. Megadhatjuk a kiírandó üzenetet is.

Az első igazi teszt metódusunk

Ezek ismeretében végre tudunk teszteket is készíteni. Szóval teszteljünk: elvileg a létrehozott példányunk iz adattagja nem kellene, hogy null legyen, valamint ha a "sajt" ízt adtuk át a konstruktorban, akkor ennek az adattagnak a getter függvénye elvileg azt adja vissza. Hasonlóképp, ha a beállított szeletszám 2, és nem fogyasztottunk belőle, akkor 2 az összes szelet, és 2 a jelenlegi szeletek száma is. Írjuk meg az asserteket. Hogy maximálisan biztosak legyünk a tesztünkben, készítsünk egy másik példányt más adatokkal, és teszeljük le azt is.

    @Test
    public void testConstructorsAndGetters() {
        peldany = new Torta(2, "sajt");
        assertNotNull(peldany.getIz());
        assertEquals("sajt", peldany.getIz());
        assertEquals(2, peldany.getOsszesSzelet());
        assertEquals(2, peldany.getJelenlegiSzelet());

        peldany = new Torta(12, "turo");
        assertEquals("turo", peldany.getIz());
        assertEquals(12, peldany.getOsszesSzelet());
        assertEquals(12, peldany.getJelenlegiSzelet());
    }

Futtassuk le a tesztet! Ha szeretnénk neki még beszédesebb nevet adni, akkor azt megtehetjük a @DisplayName annotációval.

Készítsünk egy másik tesztet, hiszen az előző tesztben minden érték megfelelő volt, a kódban látható szeletszámra vonatkozó ellenőrzést nem próbáltuk ki. Ellenőrizzük, hogy lehet-e -5 szeletes tortát csinálni. Ebben az esetben azt várjuk, hogy mind az osszesSzelet, mind pedig a jelenlegiSzelet adattag értéke 0 lesz.

    @Test
    @DisplayName("Konstruktorok és getterek tesztelése nem helyes értékre")
    public void testConstructorsAndGetters2() {
        peldany = new Torta(-5, "sajt");
        assertEquals("sajt", peldany.getIz());
        assertEquals(0, peldany.getOsszesSzelet());
        assertEquals(0, peldany.getJelenlegiSzelet());
    }

Miután elkészültünk vele, futtassuk le ezt a tesztet is. Akár az osztály nevének sorára, akár a metódus sorára kattinthatunk, majd pedig a Run testConstructorsAn...() menüpontra kattintva megtörténhet a tesztelés.

org.opentest4j.AssertionFailedError: 
Expected :0
Actual   :-5
<Click to see difference>

    at org.junit.jupiter.api.AssertionUtils.fail(AssertionUtils.java:55)
    at org.junit.jupiter.api.AssertionUtils.failNotEqual(AssertionUtils.java:62)
    at org.junit.jupiter.api.AssertEquals.assertEquals(AssertEquals.java:150)
    at org.junit.jupiter.api.AssertEquals.assertEquals(AssertEquals.java:145)
    at org.junit.jupiter.api.Assertions.assertEquals(Assertions.java:527)
    at TestTorta.testConstructorsAndGetters2(TestTorta.java:38) <31 internal lines>
    at java.base/java.util.ArrayList.forEach(ArrayList.java:1511) <9 internal lines>
    at java.base/java.util.ArrayList.forEach(ArrayList.java:1511) <25 internal lines>

Remélhetőleg hasonló kimenetet kapunk, az biztos, hogy valami nem jó. Ahogy látszik, AssertionFailedError típusú hibánk van, tehát valamelyik assert nem megfelelő eredményt adott, mégpedig úgy, hogy mi 0-t várunk, de az eredmény -5 lett. Ahogy látszik, a at TestTorta.testConstructorsAndGetters2(TestTorta.java:38) sorból, a TestTorta 38. sorában van a hibás assert, ami persze az otthoni kód esetében másik sor lehet, de az biztos, hogy a jelenlegi szelet értékét lekérő sorban van a baj. Nem a getter a hibás, hiszen az generált. Azonban a kód áttekintésével megtalálhatjuk a hibát, jó keresgélést! Amennyiben nem találtad meg, hogy mi a probléma, a lenyitható blokkban megtalálod.

A hiba helye

A hiba egészen konkrétan ott van, hogy a konstruktorban meghívjuk a szeletet beállító setter metódust, ami beállítja az osszesSzelet adattagott, majd pedig a jelenlegiSzelet adattag értékét is nullára. Azonban a konstruktorban a setter meghívása után a jelenlegiSzelet adattagot módosítjuk a paraméterből érkező értékre. Ezen sor törlésével megjavíthatjuk az apró baklövésünket a kódban, melyet követően a tesztek tökéletesen lefutnak majd. A konstruktor helyesen tehát:

    public Torta(int szelet, String iz) {
        setOsszesSzelet(szelet);
        this.iz = iz;
    }

Készítsük el a többi tesztet is! Először is teszteljük a setter függvényeket (ezeket ritkán szokás tesztelni, mivel általában generáltak). Az előzőekből tanulva itt már szélsőértékekre is teszteljük a settereket.

    @Test
    public void testSetters() {
        assertEquals("csoki", peldany.getIz());
        peldany.setIz("eper");
        assertEquals("eper", peldany.getIz());

        peldany.setOsszesSzelet(10);
        assertEquals(10, peldany.getOsszesSzelet());
        assertEquals(10, peldany.getJelenlegiSzelet());
        peldany.setOsszesSzelet(-6);
        assertEquals(0, peldany.getOsszesSzelet());
        assertEquals(0, peldany.getJelenlegiSzelet());
    }

Most pedig készítsünk tesztet az elfogyott metódushoz. Most szerencsénk van, hiszen könnyedén módosíthatjuk az adattagok értékeit a setter metódusok segítségével. Azonban, ha nem lenne ilyen szerencsénk, valamilyen más megoldással kellene ezt megoldani. Ehhez számos használható függvénykönyvtár van, például a Spring alkalmásokban is használt ReflectionTestUtils nevű osztály is használható, azonban ennek beüzemeléséről nem lesz szó ezen a kurzuson.

    @Test
    public void testElfogyott() {
        assertFalse(peldany.elfogyott());
        peldany.setOsszesSzelet(0);
        assertTrue(peldany.elfogyott(), "A tortának el kellett volna fogynia.");
    }

A tortaszeletekElfogyasztasa(int mennyi) függvény tesztje pedig az alábbiakban látható. Ahogy látszik is, a teszt gyakorlatilag egy teljes normális működést lefed, hiszen először 0 szelet tortát eszünk meg, aztán egyet, tehát kevesebbet, mint amennyink van, végül pedig többet szeretnénk elfogyasztani, mint amennyi rendelkezésre áll. Ezt a tesztet fel lehetne bontani 2-3 tesztre is akár, azonban mivel ilyen egyszerű működést tesztelünk, így jelen esetben egy tesztben van a teljes működést lefedő tesztelés is.

    @Test
    public void testTortaszeletekElfogyasztasa() {
        peldany = new Torta(2, "eper");
        // van 2 szelet, kerunk 0-t
        int elfogyasztott = peldany.tortaszeletekElfogyasztasa(0);
        assertEquals(2, peldany.getJelenlegiSzelet());
        assertEquals(0, elfogyasztott);

        // van 2 szelet, kerunk 1-et
        elfogyasztott = peldany.tortaszeletekElfogyasztasa(1);
        assertEquals(1, peldany.getJelenlegiSzelet());
        assertEquals(1, elfogyasztott);

        // van 1 szelet, kerunk 2-t
        elfogyasztott = peldany.tortaszeletekElfogyasztasa(2);
        assertEquals(0, peldany.getJelenlegiSzelet());
        assertEquals(1, elfogyasztott); // csak egy szelet van, annyit tudunk fogyasztani.
    }

Hoppá! Megint nem gondoltunk arra, hogy direkt rossz értékkel hívjuk meg ezt a metódust. Mi történne, ha meghívnám -2 szelettel is? Próbáljuk ki. Sajnos nem lenne megfelelő a kód működése, hiszen így "kapnánk" két szeletet, ahelyett, hogy valamiféle hibát kapnánk, esetleg nem csinálnánk semmit. Javítsuk ki a hibát a kódban, és készítsünk hozzá egy újabb tesztesetet. A javítás egyszerű, ne engedjük, hogy negatív legyen a függvény beérkező paramétere. Ehhez használhatunk if szerkezetet, vagy a Java beépített Math.min és Math.max függvényeit is, amelyek két elemből visszaadják a minimum, vagy maximum elemeket.

A javított metódus:

    /**
     * Eszünk néhány szelet tortát.
     * @param mennyi megenni kívánt torta.
     * @return hány szelet tortát fogyasztottunk el.
     */
    public int tortaszeletekElfogyasztasa(int mennyi) {
        mennyi = Math.max(0, mennyi);
        int elfogyaszthatoSzeletek = Math.min(mennyi, jelenlegiSzelet);
        jelenlegiSzelet -= elfogyaszthatoSzeletek;
        return elfogyaszthatoSzeletek;
    }

A hozzá tartozó teszteset pedig:

    @Test
    public void testTortaszeletekElfogyasztasaNegativ() {
        peldany = new Torta(2, "eper");
        int elfogyasztott = peldany.tortaszeletekElfogyasztasa(-2);
        assertEquals(2, peldany.getJelenlegiSzelet());
        assertEquals(0, elfogyasztott);
    }

Következzen az előzőhöz nagyon hasonló, de paraméter nélküli tortaszeletElfogyasztasa metódus tesztelése. Itt is figyelni kell arra, hogy ha 0 szelet tortánk vannak, akkor már ne tudjunk többet elfogyasztani, hiszen negatív szeletű torta nincs.

    @Test
    public void testTortaszeletElfogyasztasaParameterNelkuli() {
        peldany = new Torta(5, "eper");
        assertEquals(5, peldany.getJelenlegiSzelet());
        peldany.tortaszeletElfogyasztasa();
        assertEquals(4, peldany.getJelenlegiSzelet());
        peldany.tortaszeletElfogyasztasa();
        assertEquals(3, peldany.getJelenlegiSzelet());
        peldany.tortaszeletElfogyasztasa();
        assertEquals(2, peldany.getJelenlegiSzelet());
        peldany.tortaszeletElfogyasztasa();
        assertEquals(1, peldany.getJelenlegiSzelet());
        peldany.tortaszeletElfogyasztasa();
        assertEquals(0, peldany.getJelenlegiSzelet());
        peldany.tortaszeletElfogyasztasa();
        assertEquals(0, peldany.getJelenlegiSzelet());
    }

Ez a kód így jól működik, de sajnos nem a legszebb. Attól, hogy tesztet írunk, még programozók vagyunk, így az elkészült tesztjeinket nézzük kritikus szemmel, és ha szükséges, refaktoráljuk őket! Az előző kódot kicsit szebben is megírhatjuk.

    @Test
    public void testTortaszeletElfogyasztasaParameterNelkuli() {
        peldany = new Torta(5, "eper");
        for (int i = 5; i >= 0; i--) {
            assertEquals(i, peldany.getJelenlegiSzelet());
            peldany.tortaszeletElfogyasztasa();
        }
        assertEquals(0, peldany.getJelenlegiSzelet());
    }

A következő metódus, a szeletVasarlas metódus lesz, aminek segítségével megmondhatjuk, hogy adott fabatka értékben mennyi szelet tortát vásárolhatnánk, feltéve, hogy az első szelet torta 0.1 fabatka, és minden rákövetkező szelet 0.1 fabatkával drágább, mint az előző. Írjunk egy egyszerű tesztet, egy fabatka értékben hány szelet tortát tudnánk vásárolni. Mondjuk, hogy két szeletes a tortánk, így 1 fabatkából két szeletet tudunk vásárolni, 0.1 és 0.2 fabatkáért, tehát 0.7 fabatkánk marad.

    @Test
    public void testSzeletVasarlas() {
        peldany = new Torta(2, "eper");
        double maradekPenz = peldany.szeletVasarlas(1.0d);
        assertEquals(0.7d, maradekPenz, "Nem annyi pénzünk maradt, amennyinek kellene, hogy maradjon!");
    }

Az elkészült tesztet lefuttatva ismét hibát kapunk, hiszen a visszatérési értéke a függvénynek 0.7 helyett 0.9 lett. A hiba a megírt kódunkban keresendő, lemaradt egy egyenlőségjel. A javított szeletVasarlas metódus:

    /**
     * Megadja, hogy a pénzünkért hány darab szelet tortát vehetünk.
     *
     * Az első szelet ára 0.1 fabatka.
     * Ezt követően minden további szelet 0.1 fabatkával drágább, mint az előző.
     *
     * @param penzunk amennyi pénzünk van tortára.
     * @return mennyi pénzünk maradt a végén
     */
    public double szeletVasarlas(double penzunk) {
        int megvettSutik = 0;
        for (double ar = 0.1; penzunk >= ar && megvettSutik <= jelenlegiSzelet; ar += 0.1) {
            penzunk -= ar;
            ++megvettSutik;
            tortaszeletElfogyasztasa();
        }
        return penzunk;
    }

A javítást követően máris jó lett a tesztünk. Úgy tűnik, hogy mégis van értelme a tesztelésnek, még akkor is, ha nagyon időigényesnek tűnik elsőre, ám a hibák jelentős részét megtalálhatjuk megfelelő teszteléssel. Nézzünk egy másik tesztesetet, amikor több szelet süti van, mint pénzünk, így a for ciklus végigmegy. Ha mondjuk van 1 fabatkánk, akkor ebből 4 sütit tudunk venni, 0.1, 0.2, 0.3, 0.4 fabatka összegekért.

    @Test
    public void testSzeletVasarlasTobbSzeletVanMintPenzunk() {
        peldany = new Torta(20, "eper");
        double maradekPenz = peldany.szeletVasarlas(1.0d);
        assertEquals(0.0d, maradekPenz, "Nem annyi pénzünk maradt, amennyinek kellene, hogy maradjon!");
    }

Kész a teszt, futtassuk le. Megint hibába ütköztünk, hiszen a lebegőpontos számábrázolás hibája miatt nem jött össze a dolog. Ez bosszantó, de előfordulhat ilyesmi is, éles kódban, ha fontos dolgok szeretnénk ábrázolni (például valuta), akkor arra speciális típust kell használnunk. Itt most annyit tehetünk, hogy az 1 helyett egy nagyon picit nagyobb számot adunk át tesztelésre, és ezt felvesszük az összehasonlításban delta értéknek. A példában, az egyszerűség kedvéért 1e-10 (vagyis 0.0000000001) értéket adunk hozzá az 1.0 értékhez, valamint az assertEquals-ban is ezt a deltát használjuk. Ezzel már megjavul a tesztünk.

    @Test
    public void testSzeletVasarlasTobbSzeletVanMintPenzunk() {
        peldany = new Torta(20, "eper");
        double maradekPenz = peldany.szeletVasarlas(1.0d + 1e-10);
        assertEquals(0.0d, maradekPenz, 1e-10, "Nem annyi pénzünk maradt, amennyinek kellene, hogy maradjon!");
    }

Lefedettség mérése

Elkészültek a tesztjeink, de vajon mennyire jók ezek? Könnyedén ellenőrizhetjük a munkánkat, ha az osztálydeklaráció soránál nyomunk egy jobb klikket More Run/Debug > Run 'TestOsztaly' With Coverage menüpontra kattintunk, ami végigfuttatja a teszteket, és lefedettséget is mér. A korábbiakban említettek szerint a lefedettség adja meg, hogy a kód hány százaléka volt lefedve legalább egy teszt által.

Lefedettség mérének indítása

Lefedettségi eredmény

Ahogy láthatjuk is, a tesztjeink a kódsorok 100 %-át lefedik, ahogy a metódusok és osztályok is 100 százalékosan le vannak tesztelve. Természetesen éles, nagy kód esetén a tesztlefedettség általában közel sem 100 százalékos, de azért a projekt fő komponenseit általában alaposan tesztelik. Az egységtesztelés egy elég egyszerű módja a kód tesztelésének, ugyanakkor a komponensek közötti kommunikációról nem sokat mond meg, ezek teszteléséhez általában integrációs teszteket szokás készíteni, amely szintén nem képzi jelen kurzus tananyagát.


Utolsó frissítés: 2024-03-27 14:54:36