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ó.