A tesztelés legalacsonyabb szintje az egységtesztelés (unit testing), mivel a programnak a legkisebb, önállóan is értelmes egységeit teszteli. Ez OOP programnyelvekben a függvény és az osztály, de többnyire az utóbbi.

Az egységtesztelés célje, hogy meggyőződjünk arról, hogy a programunk építőkockái önállóan is helyesen működjenek – csak helyes elemekből lehet jól működő alkalmazást építeni.

Egy összetettebb alkalmazás sok kisebb komponensből áll, a teszteléshez ezért rendkívül sok bemeneti kombinációt kellene kipróbálni, ami hasonló mértékű kimenetet produkál. A tesztek száma egy közepes méretű alkalmazásban is minden gond nélkül elérheti a több száz, vagy akár ezer esetet is, amit emberi erőforrással már lehetetlen kivitelezni. Emiatt az egységtesztelés szinte kivétel nélkül automatizált.

A legnépszerűbb C#-hoz használt keretrendszeren keresztül, az NUnit segítségével mutatom be az egységtesztelés mikéntjét.

Az NUnit teljes dokumentációja angol nyelven elérhető.

Előkészület

Telepítsük az alábbi két nuget csomagot a projektünkhöz:

  • NUnit - a teszteléshez szükséges osztályok
  • NUnit3TestAdapter - Visual Studio integráció

A példa alkalmazásunkban az alábbi osztályt fogjuk tesztelni:

class Bank
{
    // Egy létező számlára pénzt helyez
    public void EgyenlegFeltolt(string szamlaszam, ulong osszeg)
    {
        // ...
    }

    // Új számlát nyit a megadott névvel, számlaszámmal
    public void UjSzamla(string nev, string szamlaszam)
    {
        // ...
    }

    // Két számla között utal.
    // Ha nincs elég pénz a forrás számlán, akkor
    public bool Utal(string honnan, string hova, ulong osszeg)
    {
        // ...
    }

    // Lekérdezi az adott számlán lévő pénzösszeget
    public ulong Egyenleg(string szamlaszam)
    {
        // ...
    }
}

// Nem létező számla esetén dobhatjuk bármely függvényből
class HibasSzamlaszamException: Exception
{
    public HibasSzamlaszamException(string szamlaszam)
        : base("Hibas szamlaszam: " + szamlaszam)
    {
    }
}

TODO: teljes projekthez git repó link

Tesztesetek írása

A teszteket az osztály specifikációja alapján tudjuk elkészíteni, amit a jelen esetben a függvényekhez írt kommentek fognak jelképezni (egy valódi specifikáció természetesen ennél reszletesebb).

A teszteket általában elkülönítjük a valódi programkódtól (pl. egy külön “Tests” mappába helyezzük őket) az átláthatóság kedvért. Tegyünk mi is így, a projektben hozzunk létre egy ilyen mappád, és azon belül egy “BankTest” osztályt:

Teszt mappa

Az osztályt jelöljük meg a TestFixture attribútummal, ezzel jelölve, hogy az osztály teszteseteket fog tartalmazni.

using NUnit.Framework;

[TestFixture]
public class BankTest
{
}

Az általános irányelv az, hogy egy osztály teszteléséhez egy tesztsor tartozik, de a projekttől függően természetesen ettől el lehet térni.

Az első tesztelendő funkciónk a számla létrehozása legyen - mégpedig, hogy új számla nyitásakor nem szabad, hogy pénz legyen a számlán.

using NUnit.Framework;

[TestFixture]
public class BankTest
{
    [Test]
    public void UjSzamlaEgyenlegNulla()
    {
        Bank b = new Bank();
        b.UjSzamla("TesztNev", "1234");
        Assert.AreEqual(0, b.Egyenleg("1234"));
    }
}

Az Assert osztály függvényei segítségével állításokat fogalmazhatunk meg a kóddal kapcsolatban, amiknek igaznak kell lenniük. Az Assert.AreEqual() metódusa például azt állítja, hogy a két paraméter egyenlő. Az első paraméter az elvárt érték, a második pedig a tényleges, a program által kiszámolt érték.

Ha a kettő tényleg egyezik, akkor a tesztünk sikeres, ellenkező esetben pedig sikertelen.

Vegyünk fel egy másosikat is:

using NUnit.Framework;

[TestFixture]
public class BankTest
{
    [Test]
    public void UjSzamlaEgyenlegNulla()
    {
        Bank b = new Bank();
        b.UjSzamla("TesztNev", "1234");
        Assert.AreEqual(0, b.Egyenleg("1234"));
    }

    [Test]
    public void PenzBetesz()
    {
        Bank b = new Bank();
        b.UjSzamla("TesztNev", "1234");
        b.EgyenlegFeltolt("1234", 5000);
        Assert.AreEqual(5000, b.Egyenleg("1234"));
    }
}

Minél több ilyen állítást fogalmazunk meg, minél több különböző bemenettel teszteljük az osztályunkat, annál kisebb az esélye, hogy a későbbiekben felderítetlen hibával találkozzunk.

A teszteket a “Test -> Run -> All Tests” segítségével futtathatjuk.

A sikeres teszteseteket szokás szerint zölddel, a sikertelen eseteket pirossal jelöli az IDE:

Sikeres és sikertelen tesztesetek

Sikertelen teszt esetén:

  • Ellenőrizzük, hogy a tesztet valóban jól írtuk-e meg;
  • Ha a teszt rendben van, akkor a programunkat (a Bank osztályt) kell javítani.

Egyéb Assert függvények

Az AreEqual metódussal elvileg mindent meg lehet oldani, de ha könnyen olvasható teszteket szeretnénk, akkor célszerű lehet az egyéb függvényeit is használni. A teljesség igénye nélkül, két példát mutatok be:

Assert.False

A teszt akkor sikeres, ha a paraméterként kapott érték hamis:

    [Test]
    public void TestSikertelenUtalas()
    {
        Bank b = new Bank();
        b.UjSzamla("TesztNev", "1234");
        b.UjSzamla("TesztNev2", "5678");
        var sikeres = b.Utal("1234", "5678", 10000);

        Assert.AreEqual(0, b.Egyenleg("1234"));
        Assert.AreEqual(0, b.Egyenleg("5678"));
        Assert.False(sikeres);
    }

A fenti példa azt ellenőrzi, hogy mi történik abban az esetben, ha a számlán nincs elég pénz a sikeres utaláshoz:

  • A számlák egyenlege nem változik (marad nulla);
  • A függvény visszatérési értéke hamis.

A teszt akkor sikeres, ha mindhárom Assert teljesül.

Assert.Throws<T>

A teszt akkor sikeres, ha a paraméterként átadott függvény a megadott típusú kivételt dobja:

    [Test]
    public void UtalasNemLetezoSzamlara()
    {
        Bank b = new Bank();
        b.UjSzamla("TesztNev", "1234");
        b.EgyenlegFeltolt("1234", 15000);
        b.UjSzamla("TesztNev2", "5678");

        Assert.Throws<HibasSzamlaszamException>(
            () =>
            {
                b.Utal("1234", "9999", 10000);
            }
        );
        Assert.AreEqual(15000, b.Egyenleg("1234"));
        Assert.AreEqual(0, b.Egyenleg("5678"));
    }

Az Utal függvény olyan számlára próbál utalni, amit nem hoztunk létre, ezért a függvény kivételt dob. Jelen esetben ez a helyes viselkedés. Ha az Utal függvény nem dobna kivételt, vagy más kivételt dob, akkor a teszt sikertelen lesz.

Természetesen azt is ellenőrizzzük, hogy a sikertelen utalás után a számlán lévő pénzösszegek nem változtak.

A () => { /* ... */ } kódrészlet egy lambda függvény, így tudunk hagyományos értékek/objektumok helyett kódrészletet átadni a Throws függvénynek.

A tesztesetekhez állítások megfogalmazására rengeteg módszer van, a teljes dokumentáció elérhető itt: https://github.com/nunit/docs/wiki/Assertions

SetUp és TearDown

Észrevehettük, hogy a bank osztályt minden teszteset elején létre kell hozni. Azért, hogy ezt a lépést ne kelljen minden alkalommal megismételni:

  • Felvehetjük a Bank objektumot privát változóként és
  • Minden teszteset elején létrehozhatjuk automatikusan.
using NUnit.Framework;

[TestFixture]
public class BankTest
{
    Bank b;

    [SetUp]
    public void Setup()
    {
        b = new Bank();
    }

    [Test]
    public void UjSzamlaEgyenlegNulla()
    {
        b.UjSzamla("TesztNev", "1234");
        Assert.AreEqual(0, b.Egyenleg("1234"));
    }

    [Test]
    public void PenzBetesz()
    {
        b.UjSzamla("TesztNev", "1234");
        b.EgyenlegFeltolt("1234", 5000);
        Assert.AreEqual(5000, b.Egyenleg("1234"));
    }
}

A SetUp attribútummal megjelölt függvényt a rendszer minden teszteset futtatása előtt végrehajtja.

A párja a ritkábban használt TearDown, amit ideiglenes fájlok, adatbázisok stb. lezárása lehet használni:

[TearDown]
public void Teardown()
{
    // ...
}

Előnyök, követelmények

  • Több száz/ezer tesztesetet emberi erővel nem lehet végignézni
  • Ha egy tesztet megírtunk, akkor az a jövőben folyamatosan fut. Biztosak lehetünk, hogy egy 10 éve javított hiba nem jön vissza.
  • A tesztek írásakor rájöhetünk, hogy a specifikáció hiányos:
    • Pl. Mi történik, ha két számlát ugyanazzal a számlaszámmal szeretnék nyitni?

Az egységteszthez követelmények:

  • A kimenetnek egyértelműnek kell lennie, Igen/Nem jelleggel kapjuk a végeredményt.
  • A tesztek sorrendje nem fix:
    • Lehet, hogy csak egy tesztesetet futtatunk
    • Lehet, hogy a sorrend nem egyezik a kódban felvett sorrenddel
    • Ezért a tesztek nem épülhetnek egymásra, mindegyiket tiszta lappal kell kezdeni
  • A fentiekből következik, hogy lehetőleg kerüljük a fájlok, adatbáziskapcsolatok stb. használatát
    • Ha semmiképp sem tudjuk elkerülni, akkor a tesztben kell megfogalmazni a tesztfájlok tartalmát, az adatbázis szerkezetét/tartalmát stb.
    • Adatbázissal együtt történő teszteléshez egy jó eszköz az SQLite memória-adatbázisa (fájlnévnek “:memory:”-t kell megadni).

A program nem minden eleme tesztelhető ezen a szinten!

  • Az egységteszt alkalmatlan pl. UI tesztelésre
  • Ha a hiba a komponensek interakcióiból, vagy hibás specifikációból következik, csak magasabb szintű, pl. integrációs tesztek veszik csak észre.

Más programnyelvek:

C#-hoz, és más nyelvekhez is tartozik akár több tesztelési keretrendszer.

TODO: a fenti példát elkészíteni más programnyelveken:

  • Java: JUnit
  • PHP: PHPUnit
  • JavaScript: mocha + chai