Előfordulhat, hogy az egység tesztelendő komponensünk függ másik komponensektől, vagy rosszabb esetben komplex komponenseket foglal egymásba. Utóbbi esetben már nem beszélhetünk egységtesztről, mivel egyik komponens nélkül nem tudjuk tesztelni a másikat. Azonban ha az első esetről beszélünk, és a külső függőségünk mondjuk egy interfész, akkor már több lehetőségünk van egységtesztelésre is.
KézenfekvÅ‘ megoldás, hogy a külsÅ‘ interfész függÅ‘séget helyettesÃtjük egy teszt implementációval és azzal tesztelünk.
A helyettesÃtés megvalósÃtásának tekintetében a szakirodalom megkülönböztet stub, fake, spy és mock fogalmakat. Azonban mielÅ‘tt belemerülünk az ezek közötti különbségekbe, nézzünk egy konkrét példát interfész függÅ‘ségre, aminek a segÃtségével körbejárjuk a témát.
Legyen egy interfészünk, ami egy szimpla naplózást Ãr le:
public interface ILog
{
void Error(string message);
void Info(string message);
void Warn(string message);
}
Ez az interfész függősége egy másik komponensnek. Ezt manuálisan 3 módon implementálhatjuk a teszt környezetünkben.
Az elsÅ‘ megoldás a fake implementáció, ami minden metódust leimplementál, de olyan módon, hogy az nem alkalmas az éles környezetben való helyettesÃtésre.
A stub vagy részleges implementáció egy olyan implementációja az interfésznek, ami nem minden metódust implementál, csak azokat, amelyek a teszt futásához kellenek.
A harmadik megoldás a spy implementáció. Ez az implementáció lehetÅ‘séget biztosÃt arra, hogy ellenÅ‘rizzük, hogy a metódus a helyes paraméterekkel lett-e meghÃvva, illetve, hogy tényleg annyiszor lett-e a metódus meghÃvva, mint amit mi gondolunk.
A fenti leÃrások alapján sejthetÅ‘, hogy a stub és fake implementációk nem biztosÃtanak semmiféle lehetÅ‘séget arra, hogy ellenÅ‘rizzük, hogy tényleg az az interakció történt-e a komponensek között, amit szeretnénk. Ezáltal ezek az implementációk nem képesek arra, hogy a tesztet hibára futtassák, mÃg mondjuk egy spy implementáció képes erre, cserébe viszont összetettebb és bonyolultabb implementálni.
Itt jön képbe a mock fogalma. A mock egy olyan implementáció, amely viselkedése elÅ‘re konfigurálható, illetve dinamikusan konfigurálható akár teszt közben is úgy, hogy lehetÅ‘séget biztosÃt ellenÅ‘rzésre, mint egy spy implementáció.
mock objektum készÃthetÅ‘ manuálisan is, de rengeteg munka. Éppen ezért a legtöbb teszt esetén valamilyen könyvtárral oldják meg ezt a feladatot, ami lehetÅ‘séget biztosÃt arra, hogy a megadott interfészbÅ‘l dinamikusan legenerálja a tesztobjektumot a beállÃtott viselkedés alapján. Számos ilyen könyvtár létezik. A legnépszerűbb közülük a Moq (https://github.com/moq/moq4) nevezetű.
Mit lehet mockolni?
- Osztályok absztrakt (
abstract) és virtuális (virtual) metódusait, tulajdonságait - Interfészek metódusait, tulajdonságait
A Moq használata
A Moq hatékony használatát egy mintaprogramon a legegyszerűbb bemutatni, amit tesztelünk.
using System;
namespace MockExample
{
public interface INumberProvider
{
int GetRandomNumber(int minimum, int maximum);
}
public interface IConsole
{
void WriteLine(string message);
int Width { get; }
event EventHandler<int> WidthChanged;
}
public class NumberConsumer
{
private readonly INumberProvider _numberProvider;
private readonly IConsole _console;
private int _currentConsoleWidth;
public NumberConsumer(INumberProvider numberProvider, IConsole console)
{
_numberProvider = numberProvider;
_console = console;
}
public void Initialize()
{
_console.WidthChanged += OnWithChange;
}
public void Deinitialize()
{
_console.WidthChanged -= OnWithChange;
}
private void OnWithChange(object? sender, int e)
{
_currentConsoleWidth = e;
}
public void DisplayNumber(int minimum, int maximum)
{
int number = _numberProvider.GetRandomNumber(minimum, maximum);
string str = number.ToString().PadLeft(_currentConsoleWidth, ' ');
_console.WriteLine(str);
}
}
}
A mintaprogramunk egy olyan komponenst definiál, ami egy véletlenszerűen generált számot a konzolra Ãr úgy, hogy az jobbra igazÃtva jelenjen meg. Az IConsole a konzolt definiálja, mÃg a INumberProvider a véletlen szám generátort. Bizonyosodjunk meg arról, hogy a NumberConsumer objektum megfelelÅ‘en működik!
A Moq használatához a moq csomagot telepÃtenünk kell a NuGet package manager segÃtségével a teszt projektünkbe. Ez után a teszt Setup részében létre kell hoznunk Mock<INumberProvider> és Mock<IConsole> objektumokat a tesztelés alatt álló komponenssel (sut) együtt:
using MockExample;
using Moq;
using NUnit.Framework;
namespace Tests
{
[TestFixture]
internal class MockTest
{
private Mock<INumberProvider> _numberProviderMock;
private Mock<IConsole> _consoleMock;
private NumberConsumer _sut;
[SetUp]
public void Setup()
{
_consoleMock = new Mock<IConsole>(MockBehavior.Strict);
_numberProviderMock = new Mock<INumberProvider>(MockBehavior.Strict);
_sut = new NumberConsumer(_numberProviderMock.Object, _consoleMock.Object);
}
}
}
Mint látható, a Mock egy generikus tÃpus, aminek a tÃpus argumentuma a mockolni kÃvánt interfész. A példányosÃtása esetén a MockBehavior.Strict paraméter megadásával tudjuk befolyásolni a működését. A MockBehavior Strict és Loose értékeket vehet fel. A Loose működés az alapértelmezett abban az esetben, ha a paraméter nélküli konstruktort használjuk a Mock objektum létrehozásakor.
De mi a különbség? Loose üzemmódban a Mock objektumunk egyfajta stub, vagy fake üzemmódban működik, vagyis használat elÅ‘tt a mockolt interfész egyes metódusait nem kell megfelelÅ‘en beállÃtanunk. Strict üzemmódban minden metódushÃvás elÅ‘tt be kell állÃtanunk, hogy a mockolt interfész melyik metódusainak meghÃvására számÃtunk milyen értékekkel és milyen visszatérési értékeket várunk el. Ha ez nem történik meg, akkor a tesztünk hibára fog futni nem megfelelÅ‘ mockolás miatt. Éppen ezért erÅ‘sen ajánlott a Strict üzemmód használata.
Események tesztelése
Események esetén érdemes tesztelni, hogy a megfelelő feliratkozás és megfelelő leiratkozás megtörténik-e bizonyos metódusok esetén. Erre a moq esetén a mock objektumon a VerifyAdd és VerifyRemove metódusokat tudjuk használni.
Használatuk elÅ‘tt a SetupAdd és SetupRemove metódusokkal érdemes beállÃtani az elvárt feliratkozásokat és leiratkozásokat. A szintaxis szerint egy lambda metódusban kell eseménykezelÅ‘t megadnunk. Ha konkrétan nem számÃt, hogy melyik metódus iratkozik fel az eseményre, akkor az It.IsAny<T> segÃtségével helyettesÃthetjük a konkrét metódust egy általánosra
[Test]
public void Test_Initialize_Registers_WidthChange()
{
//arrange
_consoleMock.SetupAdd(m => m.WidthChanged += It.IsAny<EventHandler<int>>());
//act
_sut.Initialize();
//assert
_consoleMock.VerifyAdd(m => m.WidthChanged += It.IsAny<EventHandler<int>>());
}
[Test]
public void Test_DeInitialize_UnRegisters_WidthChange()
{
//arrange
_consoleMock.SetupRemove(m => m.WidthChanged -= It.IsAny<EventHandler<int>>());
//act
_sut.Deinitialize();
//assert
_consoleMock.VerifyRemove(m => m.WidthChanged -= It.IsAny<EventHandler<int>>());
}
HÃvások ellenÅ‘rzése és eventek kiváltása
Nézzünk egy komplex példát, ami a használatát bemutatja. Teszteljük le a DisplayNumber metódust.
[Test]
public void Test_DisplayNumber()
{
//arrange
_consoleMock.SetupGet(m => m.Width).Returns(10);
_consoleMock.Setup(m => m.WriteLine(It.IsAny<string>()));
_numberProviderMock.Setup(m => m.GetRandomNumber(It.IsAny<int>(), It.IsAny<int>())).Returns(4);
//act
_sut.Initialize();
_consoleMock.Raise(m => m.WidthChanged += It.IsAny<EventHandler<int>>(), _consoleMock.Object, 10);
_sut.DisplayNumber(1, 10);
//assert
_numberProviderMock.Verify(m => m.GetRandomNumber(It.Is<int>(x => x == 1), It.Is<int>(x => x == 10)), Times.Once);
_consoleMock.Verify(m => m.WriteLine(It.Is<string>(x => x == " 4")), Times.Once);
}
ElsÅ‘ körben a Strict viselkedés miatt be kell állÃtanunk, hogy a mockolt objektumok melyik részei hogy lesznek használva. A SetupGet metódus direkt tulajdonságok visszatérési értékeinek beállÃtására szolgál. BeállÃtjuk, hogy a konzol szélessége 10-et adjon vissza, illetve a Setup metódussal beállÃtjuk, hogy majd a konzol WriteLine metódusa meg lesz hÃvva egy string argumentummal. Itt nem számÃt, hogy pontosan mi lesz az argumentum, mivel azt majd késÅ‘bb teszteljük. Szintén a Setup segÃtségével beállÃtjuk, hogy a INumberProvider interfész GetRandomNumber metódusa majd meg lesz hÃvva két int paraméterrel és a hÃvásra 4-et válaszoljon.
A teszt act szakaszában meghÃvjuk az Initialize() metódust és a konzol mock objektumon a Raise segÃtségével kiváltjuk a WidthChanged eseményt. Mivel az eseménykezelÅ‘k delegate alapúak, ezért az eseménykezelÅ‘t megfelelÅ‘en paramétereznünk kell a Raise esetén. A sender helyére a _consoleMock objektumot helyettesÃtjük, értéknek (e változó az eseménykezelÅ‘ben) pedig 10-et. Ezt követÅ‘en meghÃvjuk a DisplayNumber metódust.
Ha az eseménykezelőnk nem generikus lenne, hanem az EventHandler leszármazott, akkor elég lenne csak egy argumentumot átadnunk a mock Raise metódusának, méghozzá egy EventArgs leszármazott objektumot, mivel ebben az esetben az eseménykezelő sender objektumát belsőleg fel tudná paraméterezni.
A DisplayNumber metódusunk meghÃvja a GetRandomNumber() metódust, majd a WriteLine metódusokat. Mivel mind a kettÅ‘ hÃvás egy külsÅ‘ komponensben van, ellenÅ‘rizhetjük a Verify metódussal, hogy megtörtént-e ezek meghÃvása a megfelelÅ‘ argumentumokkal. A megfelelÅ‘ argumentumokkal hÃvás ellenÅ‘rzésére az It.Is<T> használható, aminek egy Func<T, bool> tÃpusú lambdát kell átadnunk.
A Verify második paramétere a hÃvások számára vonatkozó ellenÅ‘rzés. A Times.Once azt fejezi ki, hogy pontosan egy alkalommal kell, hogy meghÃvódjon. A Times struktúra metódusaival ellenÅ‘rizhetjük, hogy a tesztelt metódus:
- Legalább n alkalommal (
AtLeast(int callcount)) - Legalább 1 alkalommal (
AtLeastOnce) - Maximum n alkalommal (
AtMost(int callcount)) - Maximum 1 alkalommal (
AtMostOnce) - Egyetlen egy alkalommal sem (
Never) - Pontosan n alkalommal (
Exactly(int callcount)) - n és k alkalom között (
Between(int from, int to))
került meghÃvásra.
Dokumentáció és az NSubstitute
A Moq könyvtár folyamatosan fejlÅ‘dik, javul. A könyvben bemutatott használata csupán a képességeinek a leggyakrabban használt szeletét mutatta be. Ezen felül azonban bÅ‘ven többet tud, ami komplexebb tesztesetek esetén igen hasznos tud lenni. Éppen ezért érdemes átfutni a Moq dokumentációját, ami a https://github.com/moq/moq4/wiki/Quickstart cÃmen található meg.
A Moq mellett a másik sokat használt mock könyvtár az NSubstitute. Ez tudásában megegyezik a Moq-al, az általa használt szintaxis másabb csupán. Szintén NuGet segÃtségével telepÃthetÅ‘, a dokumentációja a https://nsubstitute.github.io/help/creating-a-substitute/ cÃmen található meg.
Dátum és idő a tesztekben
Az olyan kódjaink amelyekben az idÅ‘t a DateTime.Now vagy DateTime.UtcNow segÃtségével kérjük le, nem tesztelhetÅ‘ek, mivel mindkét tulajdonság lekérése a jelenlegi rendszeridÅ‘t fogja visszaadni, vagyis nem tudunk egy konstans értékhez hasonlÃtani, illetve az idÅ‘pontra épülÅ‘ logikákat se tudjuk tesztelni. Éppen ezért, ha dátumra és idÅ‘re van szükségünk, akkor érdemes ezek lekérését és hozzáférését egy interfész mögé kitenni, hogy a tesztekben helyettesÃthetÅ‘ek legyenek tetszÅ‘leges értékekkel. Egy ilyen példa implementáció lehet a következÅ‘:
interface IdateTimeProvider
{
DateTime Now {get; }
DateTime UtcNow {get; }
}
.NET 8 óta azonban beépÃtetten van egy absztrakciós rétegünk a TimeProvider absztrakt osztály formájában. Ennek a System statikus tulajdonságán keresztül érjük el a rendszer által megvalósÃtott példányát, amit behelyettesÃthetünk az alkalmazásunkban implementációnak. A TimeProvider GetUtcNow() metódusa virtuális, vagyis egy mock segÃtsévével felülÃrható a tesztekben, hogy mit adjon vissza. Azonban a GetLocalNow metódusa nem felülÃrható. Ennek az oka az, hogy a LocalTimeZone tulajdonsága szintén felülÃrható. Az idÅ‘zóna és az UTC ismeretében a helyi idÅ‘ pedig számÃtható.