Dekoráljuk ki a [DynamicData] attribútumot!
Az MSTest az egyetlen .NET-alapú tesztkeretrendszer, amely natív támogatást nyújt a dinamikus adattesztek egyedi tesztneveinek előállításához a DynamicDataAttribute segítségével. Bár ez a lehetőség hasznos, a megvalósítása korántsem felhasználóbarát: kódismétlést eredményez, és felesleges vizuális zajt visz a tesztosztályokba.
Ebben a bejegyzésben létrehozunk egy olyan attribútumot, amely megszünteti ezt a problémát. Célunk, hogy a tesztnevek előállításához szükséges logika kikerüljön a tesztosztályból, és egy külön attribútumba kerüljön, így a tesztkód egyszerűbbé és letisztultabbá válik.
Fontos szempont, hogy az új attribútum megőrizze a DynamicDataAttribute viselkedését. Mivel azonban a DynamicDataAttribute osztály sealed, nem örökölhetünk belőle. Ezért a Decorator tervezési mintát fogjuk alkalmazni, és egy olyan attribútumot készítünk, amely nemcsak helyettesíti, de kiterjeszthetővé is teszi az eredeti funkcionalitást. Ehhez pedig a névtelen Tuple-típust használó modern, pattern-matching alapú factory segítségét is igénybe vesszük.
A jelenlegi írásban újrahasznosítjuk az előző bejegyzésemből megismert BirthDay osztályt, valamint a konstruktor negatív teszteseteihez létrehozott BirthDayDataSource.GetCtorInvalidParams() metódust, igazolva a cikk alapvető üzenetét: „Írd meg egyszer, használd mindenhol”.
A DynamicDataAttribute.GetDisplayName metódusa
Az attribútumok osztályszintű definiálását és alkalmazását a C# Tutorial.hu Attribútumok című bejegyzése részletesen ismerteti. (Ha nem találkoztál még attribútumokkal, javaslom, kezdd itt.)
A saját megjelenítési név előállításában a DynamicDataAttribute osztály az alábbi tagokra támaszkodik:
#region Properties
// Annak a metódusnak a neve, amely a teszt megjelenítési nevét előállítja.
// A keretrendszer ezt fogja meghívni a 'GetDisplayName' hívásakor.
public string? DynamicDataDisplayName { get; set; }
// Ha a névgeneráló metódus nem a tesztosztályban található,
// ezzel adható meg a deklaráló típus.
public Type? DynamicDataDisplayNameDeclaringType { get; set; }
#endregion Properties
#region Methods
// A teszt megjelenítési nevét előállító metódus,
// amelyet minden teszteset futtatásakor meghív a keretrendszer.
public string? GetDisplayName(MethodInfo methodInfo, object?[]? data)
{
// Ha nincs megadva egyéni névgeneráló metódus,
// akkor az alapértelmezett tesztnév kerül felhasználásra.
if (DynamicDataDisplayName == null)
{
return TestDataSourceUtilities.ComputeDefaultDisplayName(methodInfo, data);
}
// Ha nincs külön deklaráló típus megadva,
// akkor a tesztosztályban keresi a névgeneráló metódust.
Type? dynamicDisplayNameDeclaringType =
DynamicDataDisplayNameDeclaringType ?? methodInfo.DeclaringType;
DebugEx.Assert(dynamicDisplayNameDeclaringType is not null,
"Declaring type of test data cannot be null.");
// Megkeresi a névgeneráló metódust a megadott név alapján.
MethodInfo method =
dynamicDisplayNameDeclaringType.GetTypeInfo().GetDeclaredMethod(DynamicDataDisplayName)
?? throw new ArgumentNullException($"{DynamicDataSourceType.Method} {DynamicDataDisplayName}");
// Validálja a névgeneráló metódus szignatúráját.
ParameterInfo[] parameters = method.GetParameters();
if (parameters.Length != 2 // Pontosan két paraméter szükséges
|| parameters[0].ParameterType != typeof(MethodInfo) // 1. paraméter: MethodInfo
|| parameters[1].ParameterType != typeof(object[]) // 2. paraméter: object[]
|| method.ReturnType != typeof(string) // Visszatérési típus: string
|| !method.IsStatic // A metódusnak statikusnak kell lennie
|| !method.IsPublic) // A metódusnak publikusnak kell lennie
{
throw new ArgumentNullException(string.Format(
CultureInfo.InvariantCulture,
FrameworkMessages.DynamicDataDisplayName,
DynamicDataDisplayName,
nameof(String),
string.Join(", ", nameof(MethodInfo), typeof(object[]).Name)));
}
// Meghívja a névgeneráló statikus metódust,
// és átadja a szükséges paramétereket.
return method.Invoke(null, new object?[] { methodInfo, data }) as string;
}
// A tesztadatok előállításáért felelős metódus.
public IEnumerable<object[]> GetData(MethodInfo methodInfo)
{
// Adatsorok előkészítésének és iterálásának logikája.
}
#endregion Methods
A mindenképpen szükséges segédmetódusok
Mind a jelenlegi DynamicDataAttribute, mind az általunk létrehozni kívánt változat használatához szükségünk lesz néhány segédmetódusra.
Saját megjelenítési név generálása
- Szükségünk van tehát egy segédmetódusra, amely megfelel a
GetDisplayNameáltal elvárt kritériumoknak. Ennek a metódusnak:- statikusnak,
- publikusnak,
- pontosan két paramétert fogadónak (a tesztmetódust és a tesztadatokat tartalmazó objektumtömböt),
- valamint
stringvisszatérési értékűnek
kell lennie.
A legegyszerűbb megközelítés, ha az adatsor első eleme tartalmazza a teszteset egyéni megjelenítési nevét. Ebből és a tesztmetódus nevéből könnyen előállítható a TesztMetódusNeve(TesztEsetLeírása) formátumú megjelenítési név:
public static class DisplayNameSupport
{
public static string? CreateDisplayName(MethodInfo testMethod, params object?[]? data)
{
if (testMethod?.Name is string testMethodName
&& data is { Length: > 1 } // Tesztnév + legalább egy paraméter
&& data[0] is string testCaseName) // az első elem a teszteset neve
{
return $"{testMethodName}({testCaseName})";
}
return null;
}
}
A megjelenítési név beszúrása
- A következő lépés egy olyan segédmetódus elkészítése, amely a meglévő tesztadatforrás minden sora elé beszúrja a hozzá tartozó teszteset nevét:
public static IEnumerable<object?[]> GetNamedData(
IEnumerable<object?[]> dataSource,
params string[] testCases)
{
ArgumentNullException.ThrowIfNull(dataSource, nameof(dataSource));
var testCasesName = nameof(testCases);
ArgumentNullException.ThrowIfNull(testCases, testCasesName);
int index = 0;
// A foreach csak egyszer járja be az adatforrást, és mindig csak a következő elemet olvassa be.
// Ez hatékonyabb, mint Count()-ot hívni, mert:
// - nem kényszeríti ki a teljes bejárást,
// - nem okoz dupla munkát,
// - kompatibilis a csak egyszer bejárható (yield-es, streaming) sorozatokkal.
foreach (var row in dataSource)
{
// Ha még van tesztesetnév, hozzárendeljük az aktuális adatsorhoz.
if (testCasesRemained())
{
yield return [testCases[index++], .. row];
}
else
{
// Ha több adat érkezik, mint név, az azonnal kiderül.
throw getTestCaseNamesCountException("több");
}
}
// Ha a ciklus lefutott, de maradt még tesztesetnév,
// akkor kevesebb adat érkezett, mint amennyire szükség lenne.
if (testCasesRemained())
{
throw getTestCaseNamesCountException("kevesebb");
}
// Lokális funkciók
bool testCasesRemained()
=> index < testCases.Length;
ArgumentOutOfRangeException getTestCaseNamesCountException(string relation)
=> new(testCasesName,
$"A tesztadat-kollekció {relation} elemet tartalmaz, " +
$"mint a {testCasesName}.");
}
A foreach‑alapú megoldás hatékonyabb, mint a metódus elején végzett elemszám‑ellenőrzés, mert nem kényszeríti ki a teljes IEnumerable bejárását. Sok adatforrás nem rendelkezik O(1) Count művelettel, ezért a Count() valójában végigiterálná az egész sorozatot, ami dupla munkát és felesleges teljesítményköltséget okozna.
A foreach ezzel szemben „menet közben” validál: mindig csak annyi elemet olvas be, amennyire ténylegesen szükség van. Ha több adat érkezik, mint amennyi tesztesetnév, az azonnal kiderül; ha kevesebb, akkor a ciklus után. Bár ez késlelteti a hibajelzést, a hatékonyság és a kompatibilitás szempontjából ez a kompromisszum sokkal kedvezőbb.
Névvel ellátott adatsorok generálása
- A fenti segédmetódus segítségével már könnyedén előállíthatjuk a tesztesetek nevét is tartalmazó adatforrást az előző írásomban ismertetett, bármilyen keretrendszerben használható,
BirthDayDataSource.GetCtorInvalidParamsadatforrásból:
public class BirthDayNamedDataSource
{
public IEnumerable<object?[]> GetCtorInvalidNamedParams()
{
BirthDayDataSource dataSource = new();
return DisplayNameSupport.GetNamedData(
dataSource.GetCtorInvalidParams(),
"name is null => throws ArgumentNullException",
"name is empty => throws ArgumentException",
"name is white space => throws ArgumentException",
"dateOfBirth is greater than the current day => throws ArgumentOutOfRangeException");
}
}
A DynamicDataAttribute működése
A tesztmetódus, az előző alkalommal létrehozott SupplementaryAssert.ThrowsDetails kiértékeléssel és a DynamicDataAttribute tulajdonságainak inicializálásával így néz ki:
[TestClass]
public class BirthDayTests
{
// Változatlan
private static readonly BirthDayDataSource DataSource = new();
// Nevekkel kiegészített adatforrás
public static IEnumerable<object?[]> CtorInvalidParams => DataSource.GetCtorInvalidNamedParams();
[TestMethod, DynamicData(nameof(CtorInvalidParams),
DynamicDataDisplayName = nameof(DisplayNameSupport.CreateDisplayName),
DynamicDataDisplayNameDeclaringType = typeof(DisplayNameSupport))]
public void Ctor_InvalidArgs_ThrowsArgumentException(
string testCase_ignore,
string name,
DateOnly dateOfBirth,
ArgumentException expected)
{
// Arrange & Act
void attempt() => _ = new BirthDay(name, dateOfBirth);
// Assert
SupplementaryAssert.ThrowsDetails(attempt, expected);
}
}
A Text Explorerben a tesztek az alábbi nevekkel jelennek meg:
Ctor_InvalidArgs_ThrowsArgumentException(name is null => throws ArgumentNullException)
Ctor_InvalidArgs_ThrowsArgumentException(name is empty => throws ArgumentException)
Ctor_InvalidArgs_ThrowsArgumentException(name is white space => throws ArgumentException)
Ctor_InvalidArgs_ThrowsArgumentException(dateOfBirth is greater than the current day => throws ArgumentOutOfRangeException)
A kód futásakor a DynamicDataAttribute működése lépésről lépésre így zajlik:
- Metaadatok kinyerése
A keretrendszer reflection segítségével kiolvassa az attribútumokat és azok paramétereit a tesztmetódusról (például aCtor_InvalidArgs_ThrowsArgumentExceptionmetódusról). - Attribútum példányosítása
Az MSTest létrehozza aDynamicDataAttributepéldányát a megadott argumentumokkal. - Attribútum tagjainak inicializálása
A példányosítás után az attribútum tulajdonságai beállításra kerülnek:
DynamicDataDisplayName = nameof(CreateDisplayName)és
DynamicDataDisplayNameDeclaringType = typeof(DisplayNameSupport). - Metódusok meghívása
A keretrendszer meghívja az attribútumhoz tartozó metódusokat:- a
GetDataelőállítja az adatsorokat, - minden adatsorra meghívja a
DynamicDataAttribute.GetDisplayName()metódust, - ez pedig továbbhívja a
DisplayNameSupport.CreateDisplayName()metódust, amely ténylegesen előállítja a megjelenítési nevet.
- a
- A teszt futtatása
Végül az MSTest aGetDataáltal előállított adatsorokkal paraméterezve futtatja a tesztmetódust.
A DynamicDataAttribute nagy ereje abban rejlik, hogy rugalmasan képes különböző forrásokból tesztadatot biztosítani, és akár egyedi tesztneveket is generálni. Gyengesége viszont, hogy sealed, így nem örökölhetünk belőle, és nem tudjuk közvetlenül kiterjeszteni. Ez különösen akkor válik problémává, amikor szeretnénk:
- egységesíteni a tesztnevek előállítását,
- csökkenteni a tesztosztályokban megjelenő boilerplate kódot,
- vagy éppen saját adatforrás‑attribútumot létrehozni.
A megoldás a Decorator tervezési minta: készítünk egy olyan attribútumot, amely becsomagolja a DynamicDataAttribute példányát, és annak minden képességét megtartja, miközben új viselkedést is hozzáadunk. Így a DynamicDataAttribute továbbra is elvégzi a „piszkos munkát”, mi pedig egy tiszta, kiterjeszthető, absztrakt alapot kapunk, amelyre saját attribútumokat építhetünk.
Az absztrakt dekorátor attribútum
A DynamicDataAttribute összesen hat különböző konstruktorral rendelkezik. A dekorátorunk szempontjából valójában csak egyetlen konstruktorra lenne szükség, mégis fontos, hogy megőrizzük az eredeti attribútum teljes viselkedését. Ám ha mind a hat overloadot külön implementálnánk, az rengeteg fölösleges vizuális zajt és duplikált kódot eredményezne.
Ehelyett egy pattern‑matching alapú factory gondoskodik róla, hogy a megadott paraméterek alapján mindig a megfelelő DynamicDataAttribute példány jöjjön létre – tisztán, központosítva és karbantarthatóan.
Az alábbi osztály a DynamicDataAttribute teljes funkcionalitását becsomagolja, és kiegészíti egy saját névgenerálással:
[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)]
public abstract class BaseNamedDataAttribute(
string sourceName,
Type? declaringType = null,
DynamicDataSourceType? sourceType = null,
object?[]? sourceArgs = null)
: Attribute, ITestDataSource, ITestDataSourceIgnoreCapability
{
// A belső, eredeti 'DynamicDataAttribute' példány.
// Minden tényleges adatforrás‑logikát ez végez, mi pedig dekoráljuk.
private readonly DynamicDataAttribute _innerAttribute =
Create(sourceName, declaringType, sourceType, sourceArgs);
// A megfelelő 'DynamicDataAttribute' példány kiválasztása és létrehozása.
private static DynamicDataAttribute Create(
string sourceName,
Type? declaringType,
DynamicDataSourceType? sourceType,
object?[]? sourceArgs)
{
ArgumentException.ThrowIfNullOrEmpty(sourceName);
return (declaringType, sourceType, sourceArgs) switch
{
// Érvénytelen kombináció: 'sourceType' és 'sourceArgs' nem használható együtt
(_, not null, not null) => throw new ArgumentException(
"Nem adható meg egyszerre 'sourceType' és 'sourceArgs'.",
// Érvényes konstruktorváltozatok
(not null, not null, null) => new(sourceName, declaringType, sourceType.Value),
(not null, null, not null) => new(sourceName, declaringType, sourceArgs),
(not null, null, null) => new(sourceName, declaringType),
(null, not null, null) => new(sourceName, sourceType.Value),
(null, null, not null) => new(sourceName, sourceArgs),
_ => new(sourceName),
};
}
// A 'DynamicDataAttribute' megfelelő tulajdonságainak delegálása
public string? IgnoreMessage
{
get => _innerAttribute.IgnoreMessage;
set => _innerAttribute.IgnoreMessage = value;
}
public string? DynamicDataDisplayName
{
get => _innerAttribute.DynamicDataDisplayName;
set => _innerAttribute.DynamicDataDisplayName = value;
}
public Type? DynamicDataDisplayNameDeclaringType
{
get => _innerAttribute.DynamicDataDisplayNameDeclaringType;
set => _innerAttribute.DynamicDataDisplayNameDeclaringType = value;
}
// ITestDataSource delegálása az eredeti DynamicData-implementációra
public IEnumerable<object?[]> GetData(MethodInfo methodInfo)
=> _innerAttribute.GetData(methodInfo);
// Egyedi megjelenítési név előállítása
public string? GetDisplayName(MethodInfo methodInfo, params object?[]? data)
{
string? displayName = null;
// Ha nincs a 'DynamicDataDisplayName' tulajdonság inicializálva,
// akkor megkíséreljük előállítani a saját megjelenítési nevet.
if (DynamicDataDisplayName is null)
{
displayName = DisplayNameSupport.CreateDisplayName(methodInfo, data);
}
// Ha nem sikerült saját nevet generálni,
// visszadelegálunk az eredeti DynamicData-implementációra.
return displayName ?? _innerAttribute.GetDisplayName(methodInfo, data);
}
}
Miért absztrakt osztály?
Azért, mert szeretnénk:
- kiterjeszthető alapot biztosítani,
- egységes viselkedést adni minden adatforrás attribútumnak,
- és megőrizni a
DynamicDataAttributeAPI‑ját.
Az absztrakt osztály fölé pedig egy sealed konkrét attribútum kerül (NamedDataAttribute), amely az egyéni névgenerálással kiegészített DynamicDataAttribute használati eseteket fedi le.
A Create factory metódus
A Create metódus feladata, hogy a megadott paraméterek kombinációja alapján automatikusan kiválassza a megfelelő DynamicDataAttribute konstruktort. A három opcionális paramétert (declaringType, sourceType, sourceArgs) egy tuple‑ként vizsgálja, és C# pattern‑matching segítségével dönti el, melyik konstruktorhívás érvényes. Ez a pattern‑matching alapú factory egy modern, átlátható és biztonságos módja annak, hogy a DynamicDataAttribute többféle konstruktorát egységesen és determinisztikusan kezeljük.
A megoldás előnye, hogy:
- egyértelműen kezeli az összes lehetséges paraméterkombinációt,
- kizárja az érvénytelen eseteket (pl.
sourceTypeéssourceArgsegyszerre), - tisztán olvasható, mert minden konstruktorváltozat külön mintában szerepel,
- karbantartható és bővíthető, mivel a logika egy helyen összpontosul.
A saját GetDisplayName metódus
A GetDisplayName metódus feladata, hogy a tesztesethez tartozó megjelenítési nevet előállítsa:
- Először megvizsgálja, hogy a felhasználó explicit módon megadott‑e saját névgeneráló metódust (
DynamicDataDisplayName). - Ha nem, akkor megpróbál egy egyedi, emberileg olvasható nevet létrehozni a
DisplayNameSupportsegítségével. - Ha ez sem ad eredményt, a metódus visszadelegál az eredeti
DynamicDataAttributeimplementációra.
Így a dekorátor egyszerre biztosít egy testre szabható, jobb alapértelmezett névformát, miközben teljes mértékben megőrzi a natív viselkedést is.
A konkrét, használatra kész attribútum
A NamedDataAttribute az összes használati esetet lefedi, és teljesen kompatibilis a DynamicDataAttribute API‑jával:
[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)]
public sealed class NamedDataAttribute : BaseNamedDataAttribute
{
public NamedDataAttribute(string sourceName)
: base(sourceName) { }
public NamedDataAttribute(string sourceName, DynamicDataSourceType sourceType)
: base(sourceName, sourceType: sourceType) { }
public NamedDataAttribute(string sourceName, params object?[] sourceArgs)
: base(sourceName, sourceArgs: sourceArgs) { }
public NamedDataAttribute(string sourceName, Type declaringType)
: base(sourceName, declaringType: declaringType) { }
public NamedDataAttribute(string sourceName, Type declaringType, params object?[] sourceArgs)
: base(sourceName, declaringType: declaringType, sourceArgs: sourceArgs) { }
public NamedDataAttribute(string sourceName, Type declaringType, DynamicDataSourceType sourceType)
: base(sourceName, declaringType: declaringType, sourceType: sourceType) { }
}
Miért van szükség hat konstruktorra?
A DynamicDataAttribute is ugyanennyi konstruktorral rendelkezik, attól függően, hogy:
- csak a forrás nevét adjuk meg,
- vagy a deklaráló típust,
- vagy a
DynamicDataSourceTypeenumot, - vagy paramétereket (
object[]), - vagy ezek kombinációit.
A NamedDataAttribute attribútumnak is ugyanezt a rugalmasságot kell biztosítania – de úgy, hogy közben ne engedjen meg érvénytelen kombinációkat (pl. sourceType és sourceArgs egyszerre). Ezért a logika egy külön, jól olvasható factory metódusba került.
Teszt
A tesztmetódus az új attribútum használatával így néz ki:
[TestClass]
public class BirthDayTests
{
private static readonly BirthDayDataSource DataSource = new();
public static IEnumerable<object?[]> CtorInvalidParams => DataSource.GetCtorInvalidNamedParams();
[TestMethod, NamedData(nameof(CtorInvalidParams))]
public void Ctor_InvalidArgs_ThrowsArgumentException(
string testCase_ignore,
string name,
DateOnly dateOfBirth,
ArgumentException expected)
{
// Arrange & Act
void attempt() => _ = new BirthDay(name, dateOfBirth);
// Assert
SupplementaryAssert.ThrowsDetails(attempt, expected);
}
}
Mit nyertünk ezzel?
- Kiterjeszthető alapot: bármikor készíthetünk új, saját,
DynamicDataAttribute-viselkedést kiterjesztő attribútumokat, hozzáadva még további viselkedéseket. - Egységes névgenerálást: ha nincs megadva egyéni metódus, automatikusan a
DisplayNameSupport.CreateDisplayNamelép működésbe. - Tiszta tesztkódot: a tesztosztályokból eltűnik a névgenerálás boilerplate‑je.
- Teljes kompatibilitást: a
DynamicDataAttributeminden képessége változatlanul elérhető. - Determinisztikus, immutábilis viselkedést: Az attribútum kiszámíthatóan viselkedik, és nem rejt mellékhatásokat.
A NamedDataAttribute egy apró, de annál hasznosabb réteget ad az MSTest fölé: megőrzi a DynamicDataAttribute minden képességét, miközben eltünteti a felesleges boilerplate‑et és egységes, jól olvasható tesztneveket biztosít. Nem utolsó eredmény, hogy DynamicDataAttribute viselkedése kiterjeszthetővé is válik. A tesztkód így letisztultabb lesz, és a névgenerálás logikája végre ott van, ahol lennie kell – az attribútumban, nem a tesztosztályokban.
A tanulság: még sealed osztályok esetén is lehet rugalmasan bővíteni a funkcionalitást a Decorator minta segítségével, és a modern C# nyelvi elemek (pattern matching, tuple) jelentősen egyszerűsíthetik a factory logikát.