Objektum-orientált, típus-biztos dinamikus adattesztelés MSTest-keretrendszerben
Paraméteres metódusok unit-teszteléséhez a legtöbb esetben egynél több teszt-metódust kell készíteni. Alapesetben írunk minden teszt-esethez egy-egy teszt-metódust. Összetettebb logika, és/vagy több paraméter esetén ez akár indokolatlanul sok egyedi teszt-metódus írását jelentheti, ami összességében sem az olvashatóságát, sem a karbantarthatóságát nem javítja a teszt-osztálynak. Nem is beszélve arról, hogy uncsi.
Az adattesztelés javítására Mark Peterson szépen összegzi a lehetséges megoldásokat a (nem túl terjedelmes) blogjában. Az Improving MSTest unit tests with DataRows | Mark Peterson’s Blog (mmp.dev) bejegyzése, a probléma ismertetése mellett, a DataRowAttribute használatát és annak problémáit, korlátait mutatja be. Ennek folytatásaként az Improving complex MSTest unit tests with DynamicData | Mark Peterson’s Blog (mmp.dev) bejegyzésében a blog szerzője továbblép a DynamicDataAttribute használatára és ennek korlátaira. Ezekre minden részletében nem térek ki, de javaslom mindenkinek elolvasni, akit érdekel.
Amit szeretnék most bemutatni, az a Mark Peterson második blog-bejegyzésének végén felvázolt módszer a típusbiztos dinamikus adattesztelésre. Ennek a koncepciónak az alkalmazása, továbbfejlesztése az OOP elvek alapján, az újabb C# verziók nyelvi elemeinek hasznosításával, különösen több szintű öröklésre épülő projektek tesztelésénél hasznos, de – mint látni fogjuk – az előnyei már kis projekteknél is több szempontból megmutatkoznak. De ingyen azért nem lesz.
Amit tesztelni fogunk
Legyen tehát egy MyType osztályunk, ami rendelkezik két tulajdonsággal: Egy string-típusú címkével, és egy int-típusú mennyiséggel. A tesztelés egyszerűsége kedvéért legyen immutábilis, így fókuszálhatunk magára az adattesztelésre. A MyType-típus alkalmazása során szükségem van arra, hogy két MyType egyenlőségét ellenőrizzem mindkét tulajdonsága alapján.
public sealed class MyType(int quantity, string label) : IEquatable<MyType>
{
public int Quantity { get; init; } = quantity;
public string Label { get; init; } = label;
public bool Equals(MyType? other)
{
return other is not null
&& other.Quantity == Quantity
&& other.Label == Label;
}
public override bool Equals(object? obj)
{
return obj is MyType other
&& Equals(other);
}
public override int GetHashCode()
{
return HashCode.Combine(Quantity, Label);
}
}
A metódusoktól a következő értékeket akarom visszakapni, tehát ezeket az eseteket kell majd tesztelnem:
- MyType.GetHashCode():
- Same Quantity, different Label => hash codes are not equal;
- Different Quantity, same Label => hash codes are not equal;
- Same Quantity, same Label => hash codes are equal;
- MyType.Equals(object?):
- null => false
- object => false
- Different MyType => false;
- Same MyType => true;
- IEquatable.Equals(MyType?):
- null => false
- Same Quantity, different Label => false;
- Different Quantity, same Label => false;
- Same Quantity, same Label => true;
Tehát „csak” tizenegy teszt-metódust kellene a hagyományos módszerrel írnunk. Ez azért elég aránytalan hercehurca, ahhoz képest, hogy mindösszesen van három metódusunk, és ezek még nem is csinálnak semmit, csak egyenlőséget ellenőriznek. (Pont, mint a régi szovjet csúcstechnológiai termék, a miniatűr karóra-adóvevő, aminek csak az a rohadt akkumulátora ne lett volna bőrönd-méretű…)
Létrehozzuk a teszt-osztályt
Nos, lássunk neki. Legelőször is létrehozzuk a MyTypeTests-tesztosztályt, a teszteléshez szükséges mezőkkel és segéd-metódusokkal:
[TestClass]
public sealed class MyTypeTests
{
private const string TestLabel = nameof(TestLabel); // NagyBetűs
private const string testLabel = nameof(testLabel); // kisBetűs
private const int TestQuantity = 3;
private const int DifferentQuantity = 4;
private int _quantity;
private string _label;
private MyType _myType;
private MyType GetMyType()
{
return new(_quantity, _label);
}
}
Ezek a tagok mindenképpen szükségesek, vagy legalább is hasznosak, akkor is ha, egyenként implementáljuk a teszteket. A következő tagok viszont majd csak a dinamikus adatteszteléshez fognak kelleni.
A dinamikus adattesztelés előkészítése
Nagy meglepetésre a DynamicDataAttribute-ot fogjuk használni a dinamikus adatteszteléshez. Ez az adott teszt-osztályon belüli statikus tagot használ dinamikus adatforrásként. Az adatforrásoknak static IEnumerable<object[]> típusúaknak kell lenniük, és a teszt-osztályon belül kell őket implementálni. Lehetnek property-k, vagy paraméter nélküli metódusok. Mark Peterson metódusok használatát javasolja, én viszont property-ket fogok használni.
private static readonly MyTypeTests Instance = new();
private static IEnumerable<object[]> EqualsMyTypeArgs => Instance.GetEqualsMyTypeArgs();
private static IEnumerable<object[]> EqualsObjectArgs => Instance.GetEqualsObjectArgs();
private static IEnumerable<object[]> GetHashCodeArgs => Instance.GetGetHashCodeArgs();
A statikus property-k a MyTypeTests-osztály példány-szintű, dinamikus adatokat generáló metódusait fogják visszaadni, egy statikus MyTypeTests-példány (Instance) által meghívva őket. Így a dinamikus adatforrásul szolgáló metódusokban felhasználhatjuk a teszt-osztály tagjait (mezőit, segéd-metódusait), miközben statikus taggal etetjük meg a DynamicDataAttribute-ot, ahogy azt kell.
Mivel a teszt-inicializáló metódus csak azután futna le, hogy az adott teszthez tartozó, dinamikus adatforrásul szolgáló metódus már lefutott, ezért ehelyett szükségünk van még egy metódusra, amivel inicializáljuk majd az adatforrások mezőit:
private MyType InitMyType()
{
_quantity = TestQuantity;
_label = TestLabel;
return GetMyType();
}
A GetDisplayName metódusból, aminek a nevét visszaadja a DisplayName property, a DynamicDataAttribute DynamicDataDisplayName-tulajdonságának fogunk értéket adni, méghozzá a teszt-metódus nevét és a teszt-eset leírását.
private const string DisplayName = nameof(GetDisplayName);
public static string GetDisplayName(MethodInfo methodInfo, object[] args)
{
string methodName = methodInfo.Name;
string testCase = (string)args[0];
return $"{methodName}: {testCase}";
}
Amint a tulajdonság neve sugallja, a teszt lefutása után ezt a szöveget fogja kiírni a TestExplorer az egyes teszt-esetekhez, és felhasználhatjuk logoláshoz is. Míg a dinamikus adatforrás lehet privát, a GetDisplayName esetében további szabály, hogy ennek a metódusnak publikus hozzáférésűnek kell lennie. Viszont tartalmazhat paramétereket, amikből teszt-esetenként összeállíthatjuk a megjelenő szöveget.
Típus-biztos objektum-tömb, az meg hogyan?
Mark Peterson a DynamicDataAttribute által adatsorként feldolgozott objektum-tömböket úgy teszi indirekt módon típus-biztossá, hogy létrehoz olyan újabb típusokat, amelyek mezőinek a típusai a teszthez szükséges paraméterek típusaival azonosak. Ezeknek a mezőinek ad dinamikusan értékeket, majd ezekből a mezőkből az uniformizáltan implementált object[] ToObjectArray()-metódussal generál objektum-tömböket, amikhez a „típus-szűrő” tehát az objektum-tömböt generáló típus. Ezeket a tömböket pedig egyenként hozzáadja egy felsoroláshoz.
Ez így egy kicsit bonyolultnak hangzik. Nézzük meg inkább az objektum-tömbök alapjául szolgáló típusok alábbi implementációit. Ezeket a típusokat az újrahasználhatóság érdekében a teszt-osztályon kívül, önálló record-típusokként hozzuk létre. Azt, hogy ezeket a boilerplate-kódokat, azokon belül is a tömbök létrehozását ilyen egyszerűen és szépen implementálhatjuk, a C# 12 (.Net 8) nyelvi újdonságának köszönhetjük.
public abstract record ObjectArray(string TestCase)
{
public virtual object[] ToObjectArray() => [TestCase];
}
public record TestCase_bool_MyType(string TestCase, bool IsTrue, MyType MyType) : ObjectArray(TestCase)
{
public override object[] ToObjectArray() => [TestCase, IsTrue, MyType];
}
public record TestCase_bool_MyType_object(string TestCase, bool IsTrue, MyType MyType, object Obj) : TestCase_bool_MyType(TestCase, IsTrue, MyType)
{
public override object[] ToObjectArray() => [TestCase, IsTrue, MyType, Obj];
}
public record TestCase_bool_MyType_MyType(string TestCase, bool IsTrue, MyType MyType, MyType Other) : TestCase_bool_MyType(TestCase, IsTrue, MyType)
{
public override object[] ToObjectArray() => [TestCase, IsTrue, MyType, Other];
}
Mark Peterson struct-típust használt az objektum-tömbök alapjául szolgáló típusokhoz. A blogbejegyzés megírása óta megjelent a C# 9-ben (Net 5) a record-típus. Ez két szempontból is alkalmasabb a céljainkra: Egyrészt nagyobb méretű, nem-primitív típusú tagok esetén a struct-típus kevésbé alkalmas, mint a record-típus, másrészt a record-típus örökíthető, szemben a struct-tal.
Itt szeretném megjegyezni, milyen fontos, hogy következetesen érvényesítsünk elnevezési konvenciókat, akkor is, ha saját örömünkre programozgatunk csak. Csapatban, ipari körülmények között viszont szinte elengedhetetlen. A fenti record-típusok elnevezésénél olyan szabályt alakítottam ki magamnak, ami első ránézésre pontosan tükrözi a konstruktor-paraméterek típusainak a sorrendjét, az pedig minden esetben azonos a ToObjectArray-metódusok által létrehozott tömbökben a tagok sorrendjével. Ez fontos, mert végső soron objektum-tömböt hozunk létre, és a teszt-metódus paraméterezésénél is pontosan ezt a sorrendet kell követni, különben hibát dob a teszt-metódus, és nem fut le. Ez az elnevezési konvenció segít kizárni a tévedést, mikor implementáljuk magát a dinamikus adatforrásul szolgáló metódust. Később nagyon hálásak leszünk magunknak érte akkor is, mikor újra-használjuk a rekordot, vagy még tovább kiterjesztjük új tagokkal.
Milyen lesz a dinamikus adatteszt-metódus, ha nagy (azaz, ha kicsi) lesz?
Miután eleget vakartuk a fejünket, hogy hogyan oldjuk meg a lehető legelegánsabban a teszteléseket, és elolvastuk Mark Peterson másodikként belinkelt blogbejegyzését, térjünk vissza a MyTypeTests-osztályunkba, és vágjunk is bele, és implementáljunk egy teszt-metódust. Adatot még nem fog kapni, de egyelőre nem is akarjuk lefuttatni.
A DynamicDataAttribute-nak a két-paraméteres konstruktorát hívjuk meg. Az első paraméter string, ez az adatforrásul szolgáló statikus tag neve. A második egy DynamicDataSourceType-típusú enum, aminek két lehetséges értéke a Property és a Method. Ezt aszerint adjuk meg, hogy az adatforrásul szolgáló statikus tag melyik fajta. A DynamicDataDisplayName property-ről pedig épp az imént beszéltünk.
[TestMethod]
[DynamicData(nameof(GetHashCodeArgs), DynamicDataSourceType.Property, DynamicDataDisplayName = DisplayName)]
public void GetHashCode_returns_expected(string testCase, bool expected, MyType myType, MyType other)
{
// Arrange
int hashCode1 = myType.GetHashCode();
int hashCode2 = other.GetHashCode();
// Act
var actual = hashCode1 == hashCode2;
// Assert
Assert.AreEqual(expected, actual);
}
(Bár szabályosan a két hashCode-ot kéne közvetlenül összehasonlítani, Assert.AreEqual és Assert.AreNotEqual metódusokkal, annak érdekében hogy uniformizáljuk a teszt-eseteket, ennyi kompromisszumot megengedhetünk. Egy biztos: A teszt-metódusunk szépen olvasható, egyetlen finnyás úriember se kifogásolhatja az eleganciáját.)
A teszt-metódus paramétereinek a DynamicDataAttribute fog értéket adni soronként, a dinamikus adatforrás object[]-elemei tartalmának a sorrendjében. Az első, vagyis nulladik eleme tehát a teszt-eset leírása lesz, ezt eszi meg a GetDisplayName-metódus. A többit pedig maga a teszt-metódus.
Ennek mintájára megírhatjuk a két Equals-metódus teszt-metódusait is:
[TestMethod]
[DynamicData(nameof(EqualsMyTypeArgs), DynamicDataSourceType.Property, DynamicDataDisplayName = DisplayName)]
public void Equals_arg_MyType_returns_expected(string testCase, bool expected, MyType myType, MyType other)
{
// Arrange
// Act
var actual = myType.Equals(other);
// Assert
Assert.AreEqual(expected, actual);
}
[TestMethod]
[DynamicData(nameof(EqualsObjectArgs), DynamicDataSourceType.Property, DynamicDataDisplayName = DisplayName)]
public void Equals_arg_object_returns_expected(string testCase, bool expected, MyType myType, object obj)
{
// Arrange
// Act
var actual = myType.Equals(obj);
// Assert
Assert.AreEqual(expected, actual);
}
A dinamikus adatforrások
Végre rendelkezünk minden eszközzel ahhoz, hogy belevágjunk a GetHashCode()-metódus egységteszteléséhez a dinamikus adatforrás-metódus implementálásának izgalmas feladatába. Kész vannak már a teszt-metódusok is, amik alig várják, hogy megetessük őket adatokkal. Jöhet a jutifali – vagy tortúra, ízlés szerint.
Ebben a metódusban lesz egy másik, ritkán használt érdekesség is, ami már a C# 7 óta része a nyelvnek: a metóduson belül beágyazott, lokális object[] argsToObjectArray() metódus.
private IEnumerable<object[]> GetGetGashCodeArgs()
{
_myType = InitMyType();
string testCase = "Different Quantity, same Label => false";
_quantity = DifferentQuantity;
MyType other = GetMyType();
bool expected = false;
yield return argsToObjectArray();
testCase = "Same Quantity, same Label => true";
_quantity = TestQuantity;
other = GetMyType();
expected = true;
yield return argsToObjectArray();
testCase = "Same Quantity, different Label => false";
_label = testLabel;
other = GetMyType();
expected = false;
yield return argsToObjectArray();
object[] argsToObjectArray()
{
TestCase_bool_MyType_MyType args = new(testCase, expected, _myType, other);
return args.ToObjectArray();
}
}
A lokális metódusok használata segít a kód strukturáltabbá és olvashatóbbá tételében, valamint a logika hatékonyabb szeparálásában. Elsősorban, de nem kizárólag olyan esetekben érdemes használni, ha egy kódrészletet ki akarunk emelni egy metódusból egy másik metódusba, de az eredeti metódus kivételével több metódus nem használná. Előnyük továbbá, hogy a metóduson belüli változókat ugyanúgy látják, mint egy privát metódus az osztály privát mezőit, ezért nem kell őket paraméterként hozzáadni a lokális metódus szignatúrájához.
A beágyazott object[] argsToObjectArray() metódussal szépen olvashatóvá tettük az őt tartalmazó adatforrás-metódust. A lehető legtöbb vizuális zajt kizártuk a használatával, hogy a fölöttük lévő logikai megoldásokra fókuszálhassunk. És ha következetesen tartjuk az elnevezési konvenciónkat, (ezt, vagy bármi mást,) az szemmel láthatóan egyszerűvé teszi a további implementációkat, akár kódrészlet-másolással, vagy code snippet-létrehozásával is. (Persze, megint csak nem is beszélve a karbantarthatóságról).
A fenti mintára ugyanígy létrehozhatjuk az Equals(object) metódus adatforrás-metódusát is:
private IEnumerable<object[]> GetEqualsObjectArgs()
{
_myType = InitMyType();
string testCase = "null => false";
object obj = null;
bool expected = false;
yield return argsToObjectArray();
testCase = "object => false";
obj = new();
yield return argsToObjectArray();
testCase = "Same MyType => true";
obj = GetMyType();
expected = true;
yield return argsToObjectArray();
testCase = "Different MyType => false";
_quantity = DifferentQuantity;
_label = testLabel;
obj = GetMyType();
expected = false;
yield return argsToObjectArray();
object[] argsToObjectArray()
{
TestCase_bool_MyType_object args = new(testCase, expected, _myType, obj);
return args.ToObjectArray();
}
}
Mielőtt nekiesnénk az adatforrás immár rutinszerű megírásának az utolsó, az Equals(MyType) metódus teszteléséhez, álljunk meg egy pillanatra, és gondolkozzunk, Béláim! Ha megnézzük az előkészített teszt-metódus szignatúráját, rádöbbenünk, hogy ugyanazon típusú paramétereket használja, mint a GetHashCode()-metódus tesztje. És ha átgondoljuk, értelemszerűen ugyanazokat a bool-értékeket is fogja a GetHashCodeArgs adatforrás visszaadni az egyes teszt-esetekben, mint amiket most is várnánk. Egy teszt-eset azonban, szintén magától értetődően, hiányzik: A null-paraméter tesztelése. Használjuk fel hát újra a már létrehozott adatforrást, és egészítsük ki a hiányzó esettel:
private IEnumerable<object[]> GetEqualsMyTypeArgs()
{
_myType = InitMyType();
string testCase = "null => false";
MyType other = null;
bool expected = false;
TestCase_bool_MyType_MyType args = new(testCase, expected, _myType, other);
object[] argsArray = args.ToObjectArray();
return GetHashCodeArgs.Append(argsArray);
}
Akkor most már ugye tesztelhetünk?
Végre futtassuk le a teszteket. Látható a TestExplorer-ben, hogy mind a három teszt-metódusunk lefutott, és ezzel összesen tizenegy teszt-eset átment. És ha a kurzorral kiválasztunk egy teszt-metódust, ami dinamikus adatforrást használt, akkor a következő részletek tárulnak elénk:
Most próbaképpen generáljunk egy hibás tesztet: az Equals(MyType) adatforrásában, a null-paraméter teszt-esetében változtassuk meg a bool expected változó értékét false-ról true-ra, és futtassuk le újra a teszteket. (Csak ne felejtsük el majd utána visszaírni…) Íme a részletek:
Kicsit sok hűhó, talán nem-semmiért
Ha eddig eljutottunk, nyilván azon gondolkozunk, hogy érdemes-e úgymond „egyszerűsíteni” a tesztelést, ha az egyszerűsítés ennyire bonyolult? A válasz egyértelműen igen.
A három metódushoz létre kellett volna hoznunk alapesetben 3 + 4 + 4 = 11 teszt-metódust. Ezeket egyenként és együttesen átlátni és karbantartani nem túl hálás feladat (és persze, mint említettem már, uncsi). Ehelyett minden esetben van összesen két metódusunk a tesztelésükhöz: Egy teszt-metódus, ami végrehajtja a tesztelést, és egy adatforrás, amiben amellett, hogy a már inicializált mezőket, változókat, sőt: korábban már felépített adatforrás-metódusokat újra használhatjuk, egy helyen van az összes teszt-eset is. Ebből a szempontból már két teszt-esetet is érdemes összevonni egyetlen dinamikus adatteszt-metódusba, mert akkor is csak ugyanannyi, vagyis két metódust kell létrehoznunk. Az egyes teszt-esetek feliratozva is vannak a string-típusú testCase-ekkel, tehát még kommentelnünk sem kell őket, hogy megértsük és kövessük a logikát.
A DataRowAttribute használatához képest is olvashatóbb így a teszt, és bár az is megeszi az objektum-tömböt, abban nem tudunk dinamikusan generálni paramétereket. Ez a megoldás ráadásul indirekt módon típus-biztos is, mint azt fejtegettük.
Cserébe könnyedén implementálnunk kell néhány egyszerű boilerplate-kódot. Megéri? – Szerintem az előnyök már egy ilyen kis projekt esetén is láthatóak. Ezek az előnyök pedig a tesztelt projekt mérete és komplexitása szerint szinte exponenciálisan nőnek.
Összefoglalva: bár a miniatűr adóvevőnk akkumulátora továbbra sem fér be a zsebünkbe, de legalább már nem kell belerokkanni a cipelésébe.
A fenti kódokat tartalmazó projekt elérhető a következő linken: CsabaDu/CsabaDu.DynamicDataTestDemo: Demo for object-oriented dynamic data test. (github.com).

