Lehetséges-e egységes kivétel-tesztelés a .NET-dzsungelben?
A .NET teszt-keretrendszerei sok mindenben hasonlítanak, de a kivételtesztelés terén gyakran egészen mást értenek ugyanazon Assert-metódus alatt. Ami az egyik futtatóban „példány-ellenőrzés”, az a másikban „pontos típus-egyezés”. Van, ahol könnyű kivételt rögzíteni, máshol hiányzik a megfelelő API. És egyik keretrendszer sem kínál olyan megoldást, amellyel egyetlen hívással kiértékelhetnénk a kivétel típusát, üzenetét, és – ArgumentException esetén – a ParamName értékét.
Ha adatvezérelt teszteket írunk, vagy több keretrendszer között kell mozognunk, ezek a különbségek gyorsan felesleges zajt és ismétlődő kódot eredményeznek.
Ebben a cikkben megmutatom, hogyan lehet mindezt egyetlen, keretrendszer-agnosztikus metódusba sűríteni, amelyet bármelyik tesztfuttatóhoz könnyedén adaptálhatunk. A kulcs a funkcionális Strategy Pattern: nem egységesíteni próbáljuk a keretrendszereket, hanem egyszerűen injektáljuk a saját Assert-viselkedésüket a mi absztrakciónkba.
Így lesz a kivétel-tesztelésből valóban: „írd meg egyszer, használd mindenhol”.
A probléma – Ahány ház, annyi teszt-keretrendszer
Nem csak arra nincs minden teszt-keretrendszerben egységes megoldás, amiről az előző bejegyzésemben írtam – hogy kiértékeljük, egy metódus nem dob-e kivételt. A látszólag azonos Assert-metódusok is eltérően viselkedhetnek különböző futtatókban. Már a visszatérési érték vagy a dobott kivétel típusa is okozhat meglepetést migráláskor, vagy amikor különböző keretrendszerek eredményeit kell összevetnünk – például közösségi fejlesztésű, nyílt forráskódú projektekben.
var expectedType = typeof(ArgumentException);
var actualException = new ArgumentNullException();
using xUnit;
// "Verifies that an object is exactly the given type (and not a derived type)."
Assert.IsType(expectedType, actualException); // false
using MSTest.TestFramework;
// "Tests whether the specified object is an instance of the expected type"
Assert.IsInstanceOfType(actualException, expectedType); // true
// Megegyező viselkedés az xUnit 'Assert.IsType' metódusával
Assert.AreEqual(expectedType, actualException.GetType()); // false
Ami azonban minden keretrendszerben közös: egyik sem kínál olyan összevont megoldást, amellyel egyetlen hívással kiértékelhetnénk az elkapott kivételek legfontosabb tulajdonságait:
- futásidejű típus (
GetType()), - üzenet (
Message), ArgumentExceptionesetén a paraméternév (ParamName).
Pedig adatvezérelt tesztelésnél pontosan erre lenne szükségünk: egyetlen tesztmetódussal minél több tesztesetet lefedni – mindegyiket precízen.
A sztori – „Én, mint .NET-fejlesztő, azt akarom, hogy…”
Szeretnénk egy olyan kiértékelő metódust, amely egyszerre oldja meg a két fő problémát:
- keretrendszer-agnosztikus,
- és részletesen kiértékeli a kivételt:
- elkapja, ha dobódott,
- ellenőrzi, hogy dobódott-e egyáltalán,
- ellenőrzi a futásidejű típust,
- opcionálisan ellenőrzi az üzenetet,
- opcionálisan ellenőrzi a
ParamNameértékét, - és visszaadja az elkapott kivételt további vizsgálatokhoz.
A koncepció – Ha a hegy nem megy Mohamedhez…
Kezdjük az alapoknál.
A már idézett írásomban bemutattam, hogy bizonyos keretrendszerek (pl. xUnit) biztosítanak metódust a kivétel rögzítésére (Record.Exception), mások viszont nem (pl. MSTest). Ezt pótolhatjuk egy egyszerű, mindenhol használható segédmetódussal:
namespace TestHelpers;
// Nem akarom példányosítani,
// de szeretném örököltetni,
// ezért 'abstract' osztály
public abstract class SupplementaryAssert
{
// Privát konstruktor a példányosítás megakadályozására
private SupplementaryAssert()
{
}
public static Exception? CatchException(Action attempt)
{
ArgumentNullException.ThrowIfNull(attempt);
try
{
attempt();
}
catch (Exception exception)
{
return exception;
}
return null;
}
}
Másik probléma: szükségünk van egy metódusra, amely biztosan a futásidejű típust értékeli ki – mégpedig a két típus egyenlőségének vizsgálatával. Ez minden keretrendszerben konzisztens, így bátran építhetünk rá.
A kivétel-tesztelő metódusokba a kiértékelendő metódust delegáltként injektáljuk. Ugyanígy injektálhatjuk a kiértékelő függvényeket is:
protected static void IsTypeOf(
Type expected,
object actual,
Action<Type, Type> assertEquality) // Assert-metódus injektálása
{
assertEquality(expected, actual.GetType());
}
Ez a mintázat a Functional Strategy Pattern: az Inversion of Control egy funkcionális változata. Ha nem tudunk egységes megoldást adni, akkor injektáljuk a keretrendszer saját Assert-viselkedését a mi absztrakciónkba!
A kivételes kivételek – Valamit valamiért
A ParamName kiértékelése miatt eleve külön kell kezelnünk az ArgumentException‑típusú kivételeket. De nem csak a ParamName miatt speciálisak: az üzenetük viselkedése is több szempontból eltér a többi kivételtől, és ez közvetlenül befolyásolja, hogyan tudjuk őket tesztelni.
A default üzenetek mindig generálódnak
Ha nem adunk meg saját üzenetet (vagy null‑t adunk át), az ArgumentException‑család akkor is automatikusan létrehoz egy alapértelmezett üzenetet. (Ez igaz más kivételtípusokra is, PL. NotImplementedException, NullReferenceException, IndexOutOfRangeException, stb.)
A statikus guard metódusok más üzenetet adnak, mint a konstruktor
Az ArgumentException‑típusok rendelkeznek statikus guard metódusokkal, amelyek nem ugyanazt az üzenetet állítják elő, mint a konstruktor. Ezek:
ArgumentNullException–ThrowIfNullArgumentException–ThrowIfNullOrEmpty,ThrowIfNullOrWhiteSpaceArgumentOutOfRangeException–ThrowIfZero,ThrowIfNegative,ThrowIfNegativeOrZero,ThrowIfEqual,ThrowIfNotEqual,ThrowIfGreaterThan,ThrowIfGreaterThanOrEqual,ThrowIfLessThan,ThrowIfLessThanOrEqual
A gond az, hogy ezeknek az üzeneteknek a pontos tartalmát nem tudjuk egyszerűen reprodukálni egy elvárt kivétel példány létrehozásával, mert ezek az üzenetek gyakran tartalmaznak olyan értékeket is, amelyekhez teszteléskor nem férünk hozzá (pl. az actualValue). Ha mégis megpróbálnánk, a tesztkód feleslegesen bonyolulttá válna.
Milyen minták alapján tudunk mégis szűrni?
A statikus metódusok üzenetei jellegzetes mintákat követnek:
- ArgumentNullException
"Value cannot be null. (Parameter 'paramName')"
Ez megegyezik a konstruktor default üzenetével, tehát itt nincs gond.
(A ‘nullable reference type’ bevezetése óta az ArgumentNullException használatára amúgy is egyre ritkábban van szükség, hiszen a fordító már figyelmeztet a potenciális null‑értékekre. Ez azonban kikapcsolható, és egyébként is érdemes szem előtt tartani az örök alapelvet: „User input is evil.”)
- ArgumentException
Minden esetben így kezdődik:
"The value cannot be an empty string" - ArgumentOutOfRangeException
Minden esetben így kezdődik:
"'paramName' ('actualValue') must be "
AzactualValueértékéhez nem férünk hozzá, így a használható részlet:
"'paramName' ('"
Ezek a minták alkalmasak arra, hogy kizárjuk a statikus metódusok által generált üzeneteket, és csak az egyedi, általunk megadott üzeneteket ellenőrizzük.
A kompromisszum
A fenti minták használata egy apró tradeoff‑ot jelent:
- Előny:
Megbízhatóan kizárjuk a statikus guard metódusok üzeneteit, így nem kell őket reprodukálnunk. - Hátrány:
Elméletileg előfordulhat, hogy egy egyedi üzenet véletlenül ugyanúgy kezdődik, mint a fenti minták egyike. Ennek esélye kicsi, de nem nulla.
Ez azonban egy teljesen vállalható kompromisszum: a tesztelés így marad egyszerű, karbantartható és keretrendszer‑független, miközben a kivételrészletek ellenőrzése továbbra is pontos marad.
A megvalósítás – Ronda, de finom
A szükséges delegáltak:
Func<Action, Exception?> catchException– kivétel elkapása,Action<object?> assertNotNull– annak ellenőrzése, hogy dobódott-e kivétel,Action<Type, object> assertIsType– futásidejű típus ellenőrzése,Action<string, string?> assertEquality– üzenet és paraméternév ellenőrzése.
Így kapunk egy algoritmuscsaládot, amely futásidőben cserélhető – pontosan úgy, ahogy a Strategy Pattern előírja.
A keretrendszer-agnosztikus metódus végül így néz ki:
public static TException? ThrowsDetails<TException>(
Action attempt,
TException expected,
Func<Action, Exception?> catchException,
Action<object?> assertNotNull,
Action<Type, object> assertIsType,
Action<string, string?> assertEquality)
where TException : Exception
{
// Elkapjuk a kivételt. Ha nem dobódott, null-t kapunk vissza.
// A konkrét elkapási logika keretrendszerfüggő (Strategy Pattern).
var actual = catchException(attempt);
// Ellenőrizzük, hogy valóban dobódott-e kivétel.
// Ha nem, a keretrendszer saját assert-je fog hibát jelezni.
assertNotNull(actual);
// A futásidejű típus összehasonlítása.
// Itt mindig a GetType() egyenlőségét vizsgáljuk,
// mert ez minden keretrendszerben konzisztens.
assertIsType(expected.GetType(), actual);
var expectedMessage = expected.Message;
var actualMessage = actual.Message;
bool shouldAssertMessage;
// ArgumentException-típusok speciális kezelése:
// - statikus guard metódusok által generált üzenetek kiszűrése
// - ParamName kiértékelése
if (expected is ArgumentException argExpected &&
actual is ArgumentException argActual)
{
var actualParamName = argActual.ParamName;
// Ha az üzenet NEM a statikus metódusok jellegzetes mintáival kezdődik,
// akkor biztonsággal összehasonlíthatjuk az elvárt üzenettel.
shouldAssertMessage =
actualMessageDoesNotStartWith("The value cannot be an empty string") &&
actualMessageDoesNotStartWith($"'{actualParamName}' ('");
assertMessage();
// Csak akkor ellenőrizzük a ParamName-et,
// ha az elvárt példányban megadtuk.
if (argExpected.ParamName is string expectedParamName)
{
assertEquality(expectedParamName, actualParamName);
}
}
else if (expectedMessage is not null)
{
// Ha az elvárt kivétel rendelkezik üzenettel, akkor azt csak
// abban az esetben hasonlítjuk össze, ha nincs statikus guard metódusa,
// vagy ha a statikus guard metódus ('ObjectDisposedException.ThrowIf')
// által generált üzenet eltér az aktuális kivétel üzenetétől.
shouldAssertMessage =
expected is not ObjectDisposedException ||
actualMessageDoesNotStartWith(
"Cannot access a disposed object.\nObject name: '");
assertMessage();
}
// Visszaadjuk az elkapott kivételt,
// hogy a teszt további részében is használható legyen.
return actual as TException;
// Lokális metódusok
// Üzenet-összehasonlítás csak akkor,
// ha a minta alapján biztonságos.
void assertMessage()
{
if (shouldAssertMessage)
{
assertEquality(expectedMessage, actualMessage);
}
}
// Segédmetódus a statikus guard metódusok üzenetmintáinak kiszűrésére.
bool actualMessageDoesNotStartWith(string sample)
=> !actualMessage.StartsWith(sample);
}
Ez már önmagában használható bármely .NET teszt-keretrendszerben. De ki akarja ezt így, ezzel a hosszú paraméterlistával használni a teszt-metódusokban?
Készítsünk hozzá kényelmi kiterjesztéseket is, amelyek egyszerű, egységes szignatúrát és konzisztens viselkedést biztosítanak!
A kiterjesztések – Három keretrendszer, egy aláírás
xUnit kiterjesztés
using xunit.assert;
// xUnit v3 esetén:
// using xunit.v3.assert;
namespace TestHelpers.xunit;
// Örökli az ősosztályt, megvalósít statikus tagokat,
// kiterjeszthető, de nem példányosítható, mert 'abstract'.
public abstract class SupplementaryAssert : TestHelpers.SupplementaryAssert
{
public static TException? ThrowsDetails<TException>(Action attempt, TException expected)
where TException : Exception
{
// Az xUnit minden szükséges viselkedést natívan biztosít:
// - Record.Exception: kivétel elkapása
// - Assert.NotNull: kivétel hiányának jelzése
// - Assert.IsType: futásidejű típus összehasonlítása
// - Assert.Equal: üzenet és ParamName összehasonlítása
return ThrowsDetails(
attempt,
expected,
catchException: Record.Exception,
assertNotNull: Assert.NotNull,
assertIsType: Assert.IsType,
assertEquality: Assert.Equal);
}
}
NUnit kiterjesztés
using NUnit;
namespace TestHelpers.NUnit;
public abstract class SupplementaryAssert : TestHelpers.SupplementaryAssert
{
public static TException? ThrowsDetails<TException>(Action attempt, TException expected)
where TException : Exception
{
// Az NUnit Assert.Catch metódusa kivételt dobna,
// ezért a saját CatchException metódusunkat használjuk.
// A többi assert viselkedést névtelen delegáltakkal adjuk át.
return ThrowsDetails(
attempt,
expected,
catchException: CatchException,
assertNotNull: a => Assert.That(a, Is.Not.Null),
assertIsType: (e, a) => Assert.That(a, Is.TypeOf(e)),
assertEquality: (e, a) => Assert.That(a, Is.EqualTo(e)));
}
}
MSTest kiterjesztés
using MSTest.TestFramework;
namespace TestHelpers.MSTest;
public abstract class SupplementaryAssert : TestHelpers.SupplementaryAssert
{
// Segédmetódus a vizuális zaj csökkentésére:
// generikus egyenlőség-assert, amely az MSTest AreEqual-jét használja.
protected static Action<T, T?> AssertEquality<T>()
{
return (e, a) => Assert.AreEqual(e, a);
}
// A futásidejű típus összehasonlításának MSTest-specifikus adaptációja.
public static void IsTypeOf(Type expected, object actual)
{
// A saját absztrakciónkat specializáljuk MSTest AreEqual-re
// a saját 'AssertEquality' absztrakciónk segítségével.
IsTypeOf(expected, actual, assertEquality: AssertEquality<object>());
}
public static TException? ThrowsDetails<TException>(Action attempt, TException expected)
where TException : Exception
{
return ThrowsDetails(
attempt,
expected,
catchException: CatchException, // A saját implementációnk
assertNotNull: a => Assert.IsNotNull(a), // Névtelen delegált
assertIsType: IsTypeOf, // A saját specializált típus-assert
assertEquality: AssertEquality<string>()); // A saját absztrakciónk
}
}
A példa – Egy konstruktor, négy teszteset, háromféle kivétel
Ahhoz, hogy a megoldás valódi értéke láthatóvá váljon, nézzünk egy egyszerű, de tipikus példát. A BirthDay osztály konstruktora négy különböző hibás bemenetre három különböző kivételt dob:
ArgumentNullException, ha anamenull,ArgumentException, ha anameüres vagy whitespace,ArgumentOutOfRangeException, ha adateOfBirtha jövőben van.
public class BirthDay
{
public string Name { get; init; }
public DateOnly DateOfBirth { get; init; }
public BirthDay(string name, DateOnly dateOfBirth)
{
// Elvárt negatív tesztesetek:
// 1. name is null => throws ArgumentNullException
// 2. name is empty => throws ArgumentException
// 3. name is white space => throws ArgumentException
ArgumentException.ThrowIfNullOrWhiteSpace(name, nameof(name));
if (dateOfBirth > DateOnly.FromDateTime(DateTime.Now))
{
// Elvárt negatív teszteset:
// 4. dateOfBirth is greater than the current day
// => throws ArgumentOutOfRangeException
throw new ArgumentOutOfRangeException(
nameof(dateOfBirth),
DateOfBirthExceptionMessage);
}
Name = name;
DateOfBirth = dateOfBirth;
}
public const string DateOfBirthExceptionMessage =
"A 'dateOfBirth' nem lehet későbbi érték, mint a mai nap.";
}
Ez a konstruktor remek példa arra, miért érdemes egységes kivételtesztelést használni: többféle kivétel és többféle elvárás jelenik meg benne, mindegyikhez pontos típus- és paraméternév-ellenőrzés tartozik, és az üzenetet is ellenőriznünk kell – de csak ott, ahol az valóban egyedi.
Az adatforrás – Írd meg egyszer, használd mindenhol
A negatív teszteseteket egyetlen, jól strukturált adatforrásból is előállíthatjuk. Ez nemcsak átláthatóbbá teszi a tesztelést, hanem tökéletesen illeszkedik ahhoz a megközelítéshez, amelyet a korábbi bejegyzésemben részletesen bemutattam.
Ugyanezt az elvet alkalmazzuk itt is: egy lokális metódus gondoskodik a paraméterek konzisztens sorrendjéről és a vizuális zaj csökkentéséről. A tesztesetek pedig csak azokat a kivétel-részleteket adják meg, amelyeket valóban ellenőrizni szeretnénk – például csak ott ellenőrizzük az üzenetet, ahol az üzenet egyedi, és nem a rendszer által generált alapértelmezett szöveg.
public class BirthDayDataSource
{
public IEnumerable<object?[]> GetCtorInvalidParams()
{
// Helyi metódus a paraméter-sorrend konzisztenciájához
// és a vizuális zaj csökkentéséhez.
object?[] paramsToObjectArray()
=> [name, dateOfBirth, expected];
string paramName = "name";
// 1. name is null => throws ArgumentNullException
// (Az üzenetet nem ellenőrizzük, mert a rendszer generálja.)
string name = null!;
DateOnly dateOfBirth = DateOnly.FromDateTime(DateTime.Now);
ArgumentException expected = new ArgumentNullException(paramName);
yield return paramsToObjectArray();
// 2. name is empty => throws ArgumentException
// (Az üzenetet nem ellenőrizzük, mert a rendszer generálja.)
name = string.Empty;
expected = new ArgumentException(null, paramName);
yield return paramsToObjectArray();
// 3. name is white space => throws ArgumentException
// (Az üzenetet itt sem ellenőrizzük.)
name = " ";
// expected: ugyanaz az ArgumentException, mint az előző esetben
yield return paramsToObjectArray();
paramName = "dateOfBirth";
// 4. dateOfBirth is greater than the current day
// => throws ArgumentOutOfRangeException
// (Itt az üzenet egyedi, ezért ellenőrizzük.)
string message = BirthDay.DateOfBirthExceptionMessage;
name = "valid name";
dateOfBirth = dateOfBirth.AddDays(1);
expected = new ArgumentOutOfRangeException(paramName, message);
yield return paramsToObjectArray();
}
}
A ThrowsDetails metódus automatikusan csak azokat a kivételrészleteket ellenőrzi, amelyeket az elvárt kivétel példányában megadtunk. Így a tesztek minden keretrendszerben azonosak, és csak ott történik üzenet-ellenőrzés, ahol valóban szükséges.
Ez a megoldás is teljesen összhangban van a cikk fő üzenetével: írd meg egyszer, használd mindenhol.
A tesztelés – Egyszerűen egyforma
A közös ThrowsDetails metódus egyik fő előnye, hogy a tesztelési logika minden keretrendszerben pontosan ugyanúgy néz ki. A különbség mindössze annyi, hogy másik kiterjesztést importálunk – a tesztmetódus szignatúrája, felépítése és tartalma változatlan marad.
Az adatforrásból érkező teszteseteket minden keretrendszer ugyanúgy tudja fogadni:
private static readonly BirthDayDataSource DataSource = new();
public static CtorInvalidParams
=> DataSource.GetCtorInvalidParams();
És innentől a tesztelés valóban „írd meg egyszer, használd mindenhol” lesz. Mindhárom keretrendszerben ugyanaz a kétlépéses minta jelenik meg:
- Arrange & Act – lokális
attemptdelegált, amely meghívja a konstruktort. - Assert – a keretrendszer-specifikus kiterjesztésen keresztül meghívott
ThrowsDetails.
Nincs további teendő – a részletes kivétel-kiértékelést a közös absztrakció elvégzi.
MSTest
[TestMethod, DynamicData(nameof(CtorInvalidParams))]
public void Ctor_InvalidArgs_ThrowsArgumentException(
string name,
DateOnly dateOfBirth,
ArgumentException expected)
{
// Arrange & Act
void attempt() => _ = new BirthDay(name, dateOfBirth);
// Assert
SupplementaryAssert.ThrowsDetails(attempt, expected);
}
NUnit
[Test, TestCaseSource(nameof(CtorInvalidParams))]
public void Ctor_InvalidArgs_ThrowsArgumentException(
string name,
DateOnly dateOfBirth,
ArgumentException expected)
{
// Arrange & Act
void attempt() => _ = new BirthDay(name, dateOfBirth);
// Assert
SupplementaryAssert.ThrowsDetails(attempt, expected);
}
xUnit
[Theory, MemberData(nameof(CtorInvalidParams))]
public void Ctor_InvalidArgs_ThrowsArgumentException(
string name,
DateOnly dateOfBirth,
ArgumentException expected)
{
// Arrange & Act
void attempt() => _ = new BirthDay(name, dateOfBirth);
// Assert
SupplementaryAssert.ThrowsDetails(attempt, expected);
}
A három teszt között mindössze a keretrendszer saját attribútumai térnek el, a tesztlogika maga bitre pontosan ugyanaz.
A kivétel-tesztelés elsőre egyszerűnek tűnik, de amint több tesztkeretrendszerrel dolgozunk, gyorsan kiderül, mennyi apró eltérés és rejtett különbség nehezíti a munkát. A ThrowsDetails metódus és a hozzá tartozó kiterjesztések pontosan ezt a problémát oldják fel: a keretrendszerek közötti eltéréseket stratégiaként injektáljuk, így a tesztelési logika mindenhol ugyanaz marad. A végeredmény:
- egységes API,
- egyszerű, olvasható tesztek,
- pontos kivételrészlet-ellenőrzés,
- keretrendszer-független működés,
- és egy olyan absztrakció, amelyet valóban elég egyszer megírni.
Jó kivétel-tesztelést – és azt is elárulhatom: Nem ez az egyetlen tesztlogika, amit lehet és érdemes egyszerűsíteni vagy egységesíteni. De erről majd máskor…
2026.02.25. @ 11:38
Nagyon jó írás, gratulálok! Ritka, hogy a .NET-ben a kivétel-tesztelés ilyen tisztán és végigkövethetően legyen bemutatva — a szellemes címadások külön plusz. Az egységes, keretrendszerfüggetlen megközelítés szépen megoldja a széttöredezett assertelés problémáját, robusztusabb és karbantarthatóbb teszteket ad. Egy újabb lépés a Clean kód felé. Köszi, megy is a bookmark!
2026.02.26. @ 20:20
Köszönöm, igyekeztem úgy megírni, hogy még én is megértsem.
A célom, hogy ha már muszáj tesztelni (mert muszáj, ha igényesek vagyunk), akkor próbáljuk meg élvezni! – Vagy ha nem megy, legalább ne fájjon.