Tesztek írásához először szükségünk lesz egy tesztelendő metódusra, vagy specifikációra, ha TDD szerint dolgozunk. Jelen esetben adott egy osztály, amely egyetlenegy metódussal rendelkezik. Ez képes eldönteni egy számról, hogy prím-e vagy sem..
namespace Prime
{
internal class PrimeTools
{
public bool IsPrime(int number)
{
for (int i = 2; i < number; i++)
{
if (number % 2 == 0) return false;
}
return true;
}
}
}
A kód leteszteléséhez készítenünk kell egy új tesztprojektet. Ennek létrehozása után az első szándékos "akadály" az internal módosító. Ez ugye azt teszi lehetővé, hogy csak a szerelvényen belül érhető el a kód, na de hogy is lesz akkor ez tesztelve? Eddigi ismereteink alapján sehogy se, vagy a láthatóság módosításával.
Vannak, akik szerint az osztálykönyvtárakat a publikus részeiken keresztül szabad csak tesztelni, hiszen ha valahol hiba van, akkor az előbb vagy utóbb, de kiütközik. Ezzel a megközelítéssel az a baj, hogy bár látszólag egységtesztek lesznek ezek is, de ez inkább integrációs teszt irány, ami igencsak bonyolult tesztkódhoz vezet.
Na de akkor mégis hogy teszteljünk internal osztályokat? A megoldás egyszerű. Hozzáférést kell biztosítani a tesztprojektnek a tesztelendő részekhez. Ezt úgy tudjuk megtenni, hogy a tesztelendő kódrészlet hozzáférést biztosít a teszteknek az internal láthatóságú dolgokhoz.
Ezt úgy tudjuk megtenni, hogy szerelvény szinten alkalmazzuk az InternalsVisibleTo attribútumot, ami hozzáférést biztosít a megadott nevű projektnek az internal láthatóságú részekhez. Az attribútum a System.Runtime.CompilerServices névtérben helyezkedik el. Saját projektjeimen ezt az attribútumot egy külön fájban szoktam deklarálni a többi saját, szerelvény szintű attribútumommal együtt:
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("Tests")]
Jelen esetben a tesztprojekt neve kreatív módon Tests lesz.
Tesztek írása
A tesztünk lényegében egy osztály lesz, ami void visszatérésű metódusokat tartalmaz. A metódusok lesznek a tesztesetek. A teszteseteket a Visual Studio, Test explorer-éből tudjuk futtatni, ami a Test menüpont alatt található meg.
Ahhoz, hogy a tesztek itt megjelenjenek, a tesztosztályt és a teszteket is annotálnunk kell megfelelő attribútumokkal. Az osztályt a TestFixture attribútummal kell ellátni, míg az egyes tesztmetódusokat a Test vagy TestCase attribútumokkal. A Test annotáció egy paraméter nélküli metódus esetén alkalmazható, míg a Testcase attribútummal egy paraméteres metódus esetén tudjuk definiálni a bemeneteket. Ez azért hasznos, mert egy metódussal több tesztesetet is lefedhetünk.
De nézzünk egy egyszerű tesztet. Például teszteljük le, hogy a 0-ra a rendszer kimenete hamis lesz:
using NUnit.Framework;
using Prime;
namespace Tests
{
[TestFixture]
public class PrimeSimple
{
private PrimeTools _sut;
[SetUp]
public void Setup()
{
_sut = new PrimeTools();
}
[TearDown]
public void Teardown()
{
_sut = null;
}
[Test]
public void TestThat_0_IsnotPrime()
{
//act
bool result = _sut.IsPrime(0);
//assert
Assert.That(result, Is.False);
Assert.IsFalse(result);
}
}
}
A teszteket érdemes a "tripla A minta (AAA)" alapján felépíteni, ami az "Arrange, Act, Assert" szavak rövidítése.
A teszteset előkészítésért az Arrange szekció felel. Általában itt kerül létrehozásra a teszt alatt álló rendszer az összes függőségével. Ezt angolul system under test-nek hívjuk, vagy röviden csak sut-nak. A sut elnevezés használata tesztek esetén ajánlatos, mivel egyértelműsíti, hogy pontosan mit is tesztelünk.
Egy teszt több Arrange résszel is rendelkezhet. Jelen esetben csak egy van, méghozzá a Setup metódusban, ami a SetUp attribútummal van annotálva. A SetUp attribútummal megjelölt metódus minden teszteset futtatása előtt végrehajtásra kerül. A TearDown attribútummal megjelölt kódrészlet pedig a teszt futtatás után fog lefutni. Jelen esetben felesleges, csupán demonstrációs céllal lett beleírva, mivel a szemétgyűjtő amúgy is eltakarítja majd az objektumot. De ha mondjuk IDisposable implementációval rendelkezne a tesztelt rendszer, akkor már értelme is lenne a demonstráción felül.
Az előkészítés után jöhet a műveletsor, amit tesztelni szeretnénk. Ez lehet egy metódushívás is, vagy valami komplexebb kód. Ez lesz a tesztünk Act része.
Ez után az ellenőrzés, vagyis az Assert rész következik. Itt tudunk megbizonyosodni arról, hogy valóban az történik-e, amit szeretnénk. Ehhez az Assert osztály metódusait alkalmazhatjuk. Működés tekintetében az osztály metódusai speciális kivételeket generálnak, amiket a tesztfuttató környezet kap el. Az Assert osztály két szintaxist biztosít számunkra. A That metódus az úgynevezett "fluent" szintaxis szerinti megközelítés, míg az explicit elnevezésű metódusok a hagyományos NUnit 2-szerű megközelítést követik. A fontosabb tesztmetódusok a teljesség igénye nélkül:
-
Assert.TrueésAssert.IsTrueBoolean típus esetén igaz (true) érték tesztelése
-
Assert.FalseésAssert.IsFalseBoolean típus esetén hamis (false) érték tesztelése
-
Assert.NullésAssert.IsNullTetszőleges objektum tesztelése, hogy null állapotú-e.
-
Assert.NotNullésAssert.IsNotNullTetszőleges objektum tesztelése, hogy nem null állapotú-e.
-
Assert.ZeroésAssert.NotZeroint,uint,long,ulong,decimal,doubleésfloatértékek esetén ellenőrzi, hogy az érték nulla-e vagy nem nulla. -
Assert.IsNaNdoubleérték esetén ellenőrzi, hogy az NaN (not a number) értékű-e -
Assert.IsEmptyésAssert.IsNotEmptystringvagy egy tetszőlegesIEnumerable<T>esetén ellenőrzi, hogy üres vagy nem üres. -
Assert.AreEqualéásAssert.AreNotEqualKét objektum ellenőrzése, hogy azonosak-e, vagy sem. Belsőleg az objektumokon az
Equalsmetódust hívja. Tömbök (többdimenziós és egydimenziós) és generikus kollekciók összehasonlítása is lehetséges. Kollekciók és tömbök esetén mind a két összehasonlított kollekció bejárásra kerül és az egyes elemekenEqualshívásával kerül eldöntésre, hogy azonosak-e.Lebegőpontos (
double) értékek összehasonlításánál egy tolerancia szint megadható harmadik paraméternek. Ez aDefaultFloatingPointToleranceattribútummal tesztmetódus vagy tesztosztály szinten is megadható. -
Assert.AreSameésAssert.AreNotSameKét objektum referencia szinten való összehasonlítása.
-
Assert.ContainsEllenőrzi, hogy a megadott objektum része-e egy megadott kollekciónak.
-
Assert.GreaterésAssert.GreaterOrEqualSzámok és az
IComparablefelületet implementáló típusok esetén nagyobb (x > y) VAGY nagyobb vagy egyenlő (x >= y) teszt. -
Assert.LessésAssert.LessOrEqualSzámok és az
IComparablefelületet implementáló típusok esetén kisebb (x < y) VAGY kisebb vagy egyenlő (x <= y) teszt. -
Assert.PositiveésAssert.NegativeSzámok esetén teszteli, hogy a szám pozitív vagy negatív.
-
Assert.IsInstanceOfésAssert.IsNotInstanceOfEllenőrzi, hogy a megadott objektum megvalósítja-e a megadott interfészt. Az interfész típusa
Typeinformációval vagy generikusTargumentummal is megadható. -
Assert.PassLehetővé teszi a teszt azonnali befejezését, sikeresként rögzítve. Mivel ez a metódus kivételt dob, hatékonyabb, ha szimplán
returnutasítással megszakítjuk a teszt futtatását. Használata abban az esetben ajánlott, ha egyedi üzenetet szeretnénk átküldeni a teszt sikerességéről a futtatókörnyezetnek. -
Assert.FailLehetővé teszi a teszt azonnali befejezését, sikertelenként rögzítve.
-
Assert.InconclusiveAzt jelzi, hogy a teszt nem fejezhető be a rendelkezésre álló adatokkal. Olyan helyzetekben érdemes használni, amikor egy másik, eltérő adatokkal rendelkező futtatás a befejezésig futhat, akár sikerrel, akár kudarccal.
Az olyan Assert metódusok esetén, amelyek két értéket hasonlítanak össze, minden esetben az első paraméter az elvárt érték, a második pedig az aktuális érték, amit hasonlítunk az elvárthoz. Ez azért lényeges, mert ha megcseréljük őket, akkor sikertelen tesztfutás esetén a megjelenő üzenet nem lesz egyértelmű. A NUnit klasszikus modelljének a dokumentációja a https://docs.nunit.org/articles/nunit/writing-tests/assertions/assertion-models/classic.html címen lelhető fel, míg a fluent szintaxis esetén használható kényszerek, megkötések a https://docs.nunit.org/articles/nunit/writing-tests/constraints/Constraints.html címen részletezettek.
Az alábbi táblázat a korábban bemutatott Classic Assert szintaxis metódusok Fluent Assert megfelelőjét tartalmazza:
| Classic Assert | Fluent Assert |
|---|---|
Assert.True és Assert.IsTrue |
Assert.That(result, Is.True) |
Assert.False és Assert.IsFalse |
Assert.That(result, Is.False) |
Assert.Null és Assert.IsNull |
Assert.That(result, Is.Null) |
Assert.Zero |
Assert.That(result, Is.Zero) |
Assert.NotZero |
Assert.That(result, Is.Not.Zero) |
Assert.IsNaN |
Assert.That(result, Is.NaN) |
Assert.IsEmpty |
Assert.That(result, Is.Empty) |
Assert.IsNotEmpty |
Assert.That(result, Is.Not.Empty) |
Assert.AreEqual |
Assert.That(result, Is.EqualTo(expected)) |
Assert.AreNotEqual |
Assert.That(result, Is.Not.EqualTo(expected)) |
Assert.AreSame |
Assert.That(result, Is.SameAs(expected)) |
Assert.AreNotSame |
Assert.That(result, Is.Not.SameAs(expected)) |
Assert.Contains |
Assert.That(collection, Does.Contain(expected)) |
Assert.Greater |
Assert.That(result, Is.GreaterThan(expected)) |
Assert.GreaterOrEqual |
Assert.That(result, Is.GreaterThanOrEqualTo(expected)) |
Assert.Less |
Assert.That(result, Is.LessThan(expected)) |
Assert.LessOrEqual |
Assert.That(result, Is.LessThanOrEqualTo(expected)) |
Assert.Positive |
Assert.That(expected, Is.Positive) |
Assert.Negative |
Assert.That(result, Is.Negative) |
Assert.IsInstanceOf |
Assert.That(expected, Is.InstanceOf(type))) |
Assert.IsNotInstanceOf |
Assert.That(expected, Is.Not.InstanceOf(type))) |
NUnit 4
Az NUnit 4.0-ás változat óta a fluent szintaxisú Assert szintaxist támogatja alapértelmezetten, a korábbi Assert metódusok a Nunit.Framework.Legacy csomagban található ClassicAssert osztályban találhatóak meg.
A tesztek átírásában segít a Visual Studio is, ha telepítve van a tesztünkbe az Nunit.Analyzers csomag. Ennek a telepítése erősen ajánlott régebbi projektek esetén, még mielőtt 4.0-ra frissítenénk az NUnit verziót, ugyanis ez a korábbi Assert hívásokat fordítási figyelmeztetéssel jutalmazza, illetve a CTRL + . segítségével aktiválható refactor menüben automatikus konverziót kínál a fluent szintaxisra.
Tesztfuttatás
A tesztek futtatása Visual Studio-n belül a Test Explorer-ből lehetséges, ami a Test menüpont alatt érhető el. Parancssorból pedig az SLN fájlt tartalmazó mappából a dotnet test parancs segítségével tudjuk elindítani a tesztfuttatást.
A tesztfuttató környezet és az NUnit igyekszik párhuzamosítani a tesztesetek futtatását metódus szinten. Ezt érdemes észben tartani, mivel ha az osztályunk, amit tesztelünk nem szálbiztos, akkor külön jelezni kell a környezet számára, hogy az adott tesztosztályban található tesztek nem párhuzamosíthatóak. Ehhez a SingleThreaded attribútummal kell ellátnunk a tesztosztályunkat.
A SetUp és TearDown attribútummal megjelölt metódusok a korábban említett módon minden tesztmetódus végrehajtása előtt meghívódnak. A OneTimeSetUp attribútummal megjelölt metódus a tesztosztály első tesztmetódusának futása előtt kerül végrehajtásra. A OneTimeTearDown attribútummal megjelölt metódus pedig a tesztosztály utolsó tesztmetódusának végrehajtása után egyszer kerül lefuttatásra. 1
Tesztesetek definiálása
A korábbi metódusimplementációnk alapján szépen látszik, hogy a rendszert nem készítettük fel rendesen arra, hogy a 0 értékkel elbánjon, így javítani kellene a metódusimplementációján. De mielőtt favágó módon kijavítanánk a metódusimplementációt, gondolkozzunk el egy pillanatra, hogy pontosan hány teszteset is kell ahhoz, hogy meggyőződjünk arról, hogy valóban helyes a működése a metódusunknak?
Talán a legkézenfekvőbb az lenne, hogy az összes lehetséges bemeneti kombinációra megnéznénk a működést, de könnyen belátható, hogy ez nem praktikus és eléggé kimerítő is lenne, hiszen int esetén nagyjából négymilliárd (4 294 967 295) tesztesetet kellene definiálnunk. A szakirodalom ezt nevezi kimerítő tesztelésnek.
Vannak azonban ennél jobb módszerek. Az egyik ilyen az ekvivalenciaparticionálás. Ennek során olyan teszteseteket készítünk, amelyek az ekvivalenciapartíciók egyes reprezentánsait tesztelik. Jellemzően minden egyes ekvivalenciapartíciót érdemes legalább egyszer lefedni. Prím zámok esetén kézenfekvően lehet két partíciót definiálni, például negatív számok és pozitív számok. Negatív számok esetén minden esetben hamis eredményt kell visszaadnia a metódusunknak, míg pozitív esetben már bizonyos bemenetek esetén igaz értéket.
A problémás rész a "bizonyos bemenetek" esete. Ezeket jelen esetben ekvivalencia partíciókkal nem lehet lefedni, mivel a számok növekedésével a pírmszámok gyakorisága is csökken. Jobb módszer jelen esetben a határérték elemzés, ami a program változóinak, illetve paramétereinek szélsőérték elemzésén alapuló teszttervezési technika.
Az ekvivalenciapartíciók és a határérték-elemzés közötti különbségeket egy egyszerűbb példával könnyű szemléltetni. Tételezzük fel, hogy van egy metódusunk, ami 1 és 10 közötti számok esetén igaz értéket ad vissza.
Ebben az esetben, ha ekvivalenciapartíciók módszerével tesztelünk, akkor három partíciót definiálunk:
- nullánál kisebb számok
- 1 és 10 közötti számok
- 11 és nagyobb számok
A három partícióból egy-egy értéket választunk, amivel tesztelünk, hiszen ha azokra működik a metódus, akkor valószínűleg a többi esetben is működik.
Határérték elemzés esetén azonban 5 tesztesetünk lesz:
- A minimum érték, jelen esetben 1
- A minimum feletti közvetlen szám, jelen esetben 2
- Egy tetszőlegesen választott érték a 2 – 9 tartományban. Pl.: 5
- A maximum érték alatti közvetlen szám, jelen esetben 9
- A maximum érték, ami jelen esetben 10.
Mindkét módszernek megvannak az előnyei, azonban a legjobb módszer, ha mind a két megoldást kombináljuk. A prímes példa metódusunk esetén is ezt fogjuk alkalmazni.
Teszteseteket Nunit esetén a TestCase attribútummal tudunk definiálni. A TestCase attribútumban megadott értékek fognak a tesztmetódusunkba behelyettesítődni futáskor:
using NUnit.Framework;
using Prime;
namespace Tests
{
[TestFixture]
public class PrimeTests
{
[TestCase(int.MinValue, false)]
[TestCase(-1, false)]
[TestCase(0, false)]
[TestCase(1, false)]
[TestCase(2, true)]
[TestCase(3, true)]
[TestCase(4, true)]
[TestCase(997, true)]
[TestCase(int.MaxValue, true)]
[TestCase(int.MaxValue-1, false)]
public void TestThat_IsPrime_Returns_CorrectResult(int input, bool expected)
{
var prime = new PrimeTools();
bool result = prime.IsPrime(input);
Assert.AreEqual(expected, result);
}
}
}
A TestCase attribútum problémája ugyan az, mint az összes attribútumnak. Olyan tesztadatokat nem tudunk átadni vele, amiket a new operátorral kell létrehozni. Éppen ezért egy másik módszer tesztesetek meghatározására a TestCaseSource attribútum:
using NUnit.Framework;
using Prime;
using System.Collections.Generic;
namespace Tests
{
[TestFixture]
public class TestCaseSource
{
public static IEnumerable<TestCaseData> TestCases
{
get
{
yield return new TestCaseData(int.MinValue, false);
yield return new TestCaseData(-1, false);
yield return new TestCaseData(0, false);
yield return new TestCaseData(1, false);
yield return new TestCaseData(2, true);
yield return new TestCaseData(3, true);
yield return new TestCaseData(4, true);
yield return new TestCaseData(997, true);
yield return new TestCaseData(int.MaxValue, true);
yield return new TestCaseData(int.MaxValue-1, false);
}
}
[TestCaseSource(nameof(TestCases))]
public void TestThat_IsPrime_Returns_CorrectResult(int input, bool expected)
{
var prime = new PrimeTools();
bool result = prime.IsPrime(input);
Assert.AreEqual(expected, result);
}
}
}
A TestCaseSource attribútummal egy kollekciót adunk meg, amely TestCaseData objektumokból áll. Ezek az objektumok már tartalmazhatnak olyan típusokat is, amelyek a new operátorral példányosodnak. Futtatáskor a kollekcióban található objektumok elemei kerülnek a tesztmetódusba behelyettesítésre.
A tesztek ismeretében nem maradt más hátra, "csak" az, hogy megírjuk a metódusunkat, amely megfelel a teszteseteknek. Egy lehetséges implementáció:
public bool IsPrime(int number)
{
if (number <= 1) return false;
if (number == 2) return true;
for (int i = 2; i < (number / 2); i++)
{
if (number % 2 == 0) return false;
}
return true;
}
Kombinációs, szekvenciális és páronkénti tesztesetek
Az NUnit lehetőséget biztosít arra is, hogy a tesztjeink paramétereit kombinációs módon állítsuk elő ahelyett, hogy ezeket manuálisan TestCase-be felvennénk.
[Test, Combinatorial]
public void MyTest(
[Values(1, 2, 3)] int x,
[Values("A", "B")] string s)
{
//teszt logika
}
A Combinatorial attribútummal jelezzük, hogy a megadott Values attribútumok alapján különböző teszteseteket kell a tesztfuttatónak ellenőriznie. Jelen példa esetén x változó 1, 2, 3, míg s változó ‘A’ és ‘B’ esetet vehet fel. Ez alapján a futtató ki tudja generálni a 6db tesztesetet, amelyek a következőek lesznek:
MyTest(1, "A")
MyTest(1, "B")
MyTest(2, "A")
MyTest(2, "B")
MyTest(3, "A")
MyTest(3, "B")
A Sequential attribútum arra utasítja a tesztfuttatót, hogy további kombinációk generálása nélkül biztosítson értékeket a teszt paramétereihez.
[Test, Sequential]
public void MyTest(
[Values(1, 2, 3)] int x,
[Values("A", "B")] string s)
{
// teszt logika
A fenti példa alapján a Sequential attribútum miatt az alábbi tesztesetek fognak lefutni:
MyTest(1, "A")
MyTest(2, "B")
MyTest(3, null)
A Pairwise attribútum egy tesztben annak meghatározására szolgál, hogy az tesztfuttató olyan teszteseteket generáljon, hogy az összes lehetséges értékpárt felhasználja. Ez egy jól ismert megközelítés a tesztesetek kombinatorikus robbanása elleni küzdelemben, ha kettőnél több jellemzőről (paraméterről) van szó.
[Test, Pairwise]
public void MyTest(
[Values("a", "b", "c")] string a,
[Values("+", "-")] string b,
[Values("x", "y")] string c)
{
Console.WriteLine("{0} {1} {2}", a, b, c);
}
A fenti példában, ha a Combinatorial attribútum került volna felhasználásra, akkor 12 tesztesetet kapnánk (3x2x2), míg a Pairwise attribútummal csak annyi teszteset kerül lefuttatásra, amely feltétlenül kell minden lehetséges párhoz. Jelen esetben a következő tesztesetek fognak generálódni:
MyTest("a", "+", "y");
MyTest("a", "-", "x");
MyTest("b", "-", "y");
MyTest("b", "+", "x");
MyTest("c", "-", "x");
MyTest("c", "+", "y");
-
A
OneTimeSetUpés aOneTimeTearDownműködése kísértetiesen hasonlít a konstruktor feladatára. Azonban a konstruktor egyszer fut le, méghozzá a létrehozó szálon, míg aOneTimeSetUpés aOneTimeTearDownminden tesztfuttató szálon egyszer lefuttatásra kerül. Az NUnit belsőleg ezzel kerüli el, hogy zárolással kelljen foglalkoznia a teszt futtatás során.↩