Hogyan tesztelhetjük MSTest-keretrendszerben azt, hogy egy void metódus nem dob kivételt?
Az MSTest mostanában nem menő. Manapság a nyílt forráskódú, alternatív teszt-keretrendszerek, az NUnit és az xUnit használata terjed egyre inkább az iparban is. Elterjedt vélekedés, hogy az MSTest már csak az Özönvíz előttről, a már létező tesztekkel együtt ránk maradt projektek miatt van használatban. Pedig
az MSTest keretrendszert a Microsoft aktívan fejleszti.
Például nemrég jelentette be az új MSTest SDK-t, amely az MSBuild Project SDK rendszeren alapul, lásd: Introducing MSTest SDK – Improved Configuration & Flexibility – .NET Blog (microsoft.com). (Terveik szerint ez az új SDK-stílus a NET 9-es MSTest-projektsablon szabványává válik.) És – mondhatni, magától értetődően – az MSTest is nyílt forráskódúvá vált, a közösség is fejlesztheti.
MSTest-et használni több szempontból kényelmes: a Microsoft által kifejlesztett, támogatott és karbantartott keretrendszer, amely mélyen integrálva van a Visual Studio-ba. Ez amellett, hogy „out-of-box” használható a Visual Studio-val, a sebesség és a robusztusság szempontjábol is előny. Az MSTest futtató be van ágyazva közvetlenül az MSTest tesztprojektjeibe, és nincsenek más alkalmazás-függőségei. Számos beépített eszközt kínál, amelyek segíthetnek a tesztek kezelésében és futtatásában. Különösen előnyös lehet a Microsoft ökoszisztémában elmélyült fejlesztők számára.
Ezzel szemben az NUnit és az xUnit rendelkezik speciális előnyökkel. Például az NUnit széles-körű attribútumkészletet kínál a tesztkonfigurációhoz. Az xUnit egyszerűséget és rugalmasságot hangsúlyoz, tervezése kifejezetten kiterjeszthetőségre épít, lehetővé téve a fejlesztők számára, hogy saját tesztfuttatókat és reportgenerator-okat hozzanak létre.
Akit érdekel, a különböző teszt-keretrendszerekről itt található egy alaposabb összevetés: NUnit vs. XUnit vs. MSTest: Unit Testing Frameworks | LambdaTest, egy kicsit rövidebb pedig itt: NUnit Vs XUnit Vs MSTest: Core Differences | BrowserStack.
De persze, az alternatív teszt-keretrendszerek jól átgondolt, speciális előnyei ellenére ne gondoljuk azt, hogy nincsenek szintén jól átgondolt,
speciális hátrányai az MSTest-nek.
Ha például MSTest keretrendszerben szeretnénk kiértékelni azt, hogy egy void metódus nem dob kivételt, a különféle javaslatok leginkább arról szólnak, hogy teszteljük a metódus mellékhatását. De mi a helyzet a validátor-metódusokkal, amiknek az a feladata, hogy különböző más metódusok, pl. konstruktorok paramétereit validálja, vagyis kivételt dobjon, ha a paraméter(ek), érvénytelen(ek), és amúgy nincs semmi mellékhatása?
Létesítsünk egy faék-egyszerűségű paraméter-validáló osztályt, aminek egyetlen tagja van:
public class ParamValidator
{
public void Validate([DoesNotReturnIf(false)] bool isValid)
{
if (isValid) return;
throw new ArgumentOutOfRangeException(nameof(isValid));
}
}
Pozitív teszt-esetre, vagyis arra, hogy jelen esetben true-paraméter ellenőrzése során nem dob kivételt, MSTest keretrendszerben nincs kész megoldás.
Vajon van-e erre megoldás az alternatív teszt-keretrendszerekben?
Az NUnit nyújt számunkra egy kifejezetten erre való kiértékelést:
using NUnit.Framework;
public sealed class ParamValidatorTests_NUnit
{
[Test]
public void ParamValidator_validArg_bool_returns()
{
// Arrange
ParamValidator paramValidator = new();
bool isValid = true;
// Act
void attempt() => paramValidator.Validate(isValid);
// Assert
Assert.DoesNotThrow(attempt);
}
}
Az elvárás tehát, hogy MSTest-keretrendszerben is le tudjuk tesztelni a „does not throw exception”-teszt-esetet, láthatólag nem teljesen irreális.
Korábban az xUnit is rendelkezett hasonló metódussal, amit a 2.0 verzióból kihagytak (lásd: Remove Assert.DoesNotThrow · Issue #188 · xunit/xunit (github.com)), mondván:
„Every single line of code is an implicit ’does not throw’, because if it throws, then the test fails.”
Nyilván ezt a filozófiát követi az MSTest is. Ez a felfogás szerintem hibás. Hogy egyszerűen szemléltessem, mintegy „véletlenül” rontsuk el egyetlen kattintással a tesztelésre szánt metódusunkat:
public class ParamValidator
{
public void Validate(bool isValid)
{
//if (isValid) return;
throw new ArgumentOutOfRangeException(nameof(isValid));
}
}
Ha ezt a metódust megkérdezzük, hogy érvénytelen paraméter-e a false-érték, változatlanul vissza fogja igazolni az elvárásunkat. Azonban ez nem jelenti azt, hogy a metódus a feladatának megfelelően működik. A hibát viszont közvetlenül nem fogjuk észlelni, legalább is nem úgy, ahogyan az szükséges és szabályos volna, hanem esetleg közvetve, egy másik tag tesztjénél, ami ezt a metódust használja. Ha szerencsénk van.
A konkrét esetben a problémát persze eleve kiküszöbölhettük volna az alábbi implementációval:
public void Validate([DoesNotReturnIf(false)] bool isValid)
{
ArgumentOutOfRangeException.ThrowIfNotEqual(isValid, true, nameof(isValid));
}
De megváltoztathatjuk a metódust szándékosan is, például beleírunk egy újabb sort, ami más szempont alapján dob más típusú kivételt (pl. a paraméter nevét vizsgálja), és mondjuk elmulasztjuk megírni hozzá a megfelelő unit-tesztet. A kész tesztek meghatározott, az adott teszt szempontjából szignifikáns teszt-paramétereket használnak, és szintén csak meghatározott Exception-típus dobását értékelik ki. Amiről nem kérdezzük a tesztet, arra nem válaszol.
Az xUnit keretrendszer viszont továbbra is lehetőséget ad pozitív teszt-eset viszonylag elegáns közvetett kiértékelésére, két lépésben, az alábbi módon:
using Xunit;
public sealed class ParamValidatorTests_xUnit
{
[Fact]
public void ParamValidator_validArg_bool_returns()
{
// Arrange
ParamValidator paramValidator = new();
bool isValid = true;
// Act
void attempt() => paramValidator.Validate(isValid);
// Assert
Exception caughtException = Record.Exception(attempt);
Assert.Null(caughtException);
}
}
Nem is kell túl mélyen bányásznunk az NUnit forráskódjában (NUnit/NUnit: NUnit Framework (github.com)), hogy lássuk, ott is
ugyanígy működik az Assert.DoesNotThrow metódus.
Az egy-paraméteres DoesNotThrow-metódus egy három-paraméteres túlterhelését hívja meg:
public static void DoesNotThrow(TestDelegate code)
{
DoesNotThrow(code, string.Empty, null);
}
public static void DoesNotThrow(TestDelegate code, string message, params object?[]? args)
{
Assert.That(code, new ThrowsNothingConstraint(), () => ConvertMessageWithArgs(message, args));
}
Ez az Assert.That metódus-túlterhelés kifejezetten Exception kiértékelésére van létrehozva. Mielőtt megvizsgáljuk a működését, értelmezzük a paramétereit.
Az első paraméter, a „code” maga a tesztelés alatt álló metódus, a harmadik pedig a hibaüzenet. Ez utóbbi jelen esetben nem értelmezhető, tehát ha a teszt megbukik, a paraméter most végül a DoesNotThrow-metódus string message paramétere lesz, vagyis egy üres string. (string.Empty):
protected static string ConvertMessageWithArgs(string message, object?[]? args)
=> (args is null || args.Length == 0) ? message : string.Format(message, args);
A második paraméter egy IResolveConstraint- (és a tőle származó IConstraint-) interfészt is megvalósító, ThrowsNothingConstraint-típusú objektum, és ez az, aminek az IConstraint.ApplyTo-metódusa az, ami végső soron a kiértékelést adja: Magát a kódot lefuttatja, a futás közben dobott Exception-t kezeli, tárolja, valamint tárolja a futtatás utáni sikeres vagy elbukott állapotot is. Benne az Exception caughtException-változót az ExceptionHelper osztály RecordException nevű metódusa (remélhetőleg nem) rögzíti a tesztelt kód futtatásával. A bool isSuccess-paramétere a visszaadott ConstraintResult-objektum konstruktorának pedig az, hogy vajon a „caughtException is null”-e.
public override ConstraintResult ApplyTo<TActual>(TActual actual)
{
var @delegate = ConstraintUtils.RequireActual<Delegate>(actual, nameof(actual));
Exception? caughtException = ExceptionHelper.RecordException(@delegate, nameof(actual));
return new ConstraintResult(this, caughtException, caughtException is null);
}
Az ApplyTo-metódus jelen esetben felfogható egyfajta kétszeresen becsomagolt „Try”-metódusnak is, vagyis olyannak, ami bool-értékkel tér vissza aszerint, hogy a feladatát sikerült-e végrehajtania vagy nem. Ez a visszatéréi típus ugyan nem bool, de egy bool-paraméter állítja be a ConstraintStatus (enum) -típusú Status-tulajdonságának értékét, ami itt bináris: lehet Success vagy Failure. (Két további lehetséges érték lenne az Unknown és az Error.) A ConstraintResult meghívott konstruktora a következő:
public ConstraintResult(IConstraint constraint, object? actualValue, bool isSuccess)
: this(constraint, actualValue)
{
Status = isSuccess ? ConstraintStatus.Success : ConstraintStatus.Failure;
}
Az Assert.That metódus az elején meghívja az IResolveConstraint-paraméter Resolve()-metódusát. Ez ellenőrzi, és visszaadja őt mint leszármazott IConstraint-t. Ha ez sikeres, akkor ő hívja meg a teszt „lelkét”, az IConstraint-interfész ApplyTo-metódusát. Az ettől visszakapott ConstraintResult-típusú objektumnak már csak az öröklött bool IsSuccess-tulajdonságát vizsgálja az Assert.That (amit a bemutatott Status-tulajdonság határoz meg), és ha az false-értékű, akkor összeállít egy logot:
public static void That<TActual>(
TActual actual,
IResolveConstraint expression,
Func<string> getExceptionMessage,
[CallerArgumentExpression(nameof(actual))] string actualExpression = "",
[CallerArgumentExpression(nameof(expression))] string constraintExpression = "")
{
var constraint = expression.Resolve();
IncrementAssertCount();
var result = constraint.ApplyTo(actual);
if (!result.IsSuccess)
ReportFailure(result, getExceptionMessage(), actualExpression, constraintExpression);
}
MSTest-keretrendszerben, erősen leegyszerűsítve
az alábbi módon tudjuk ezt a kiértékelési sémát modellezni (ezúttal kipróbálva a megbukott teszt-esetet is):
using Microsoft.VisualStudio.TestTools.UnitTesting;
[TestClass]
public sealed class ParamValidatorTests_MSTest
{
[TestMethod]
[DataRow(false, DisplayName = "false => fails")]
[DataRow(true, DisplayName = "true => passes")]
public void POC_ParamValidator_validArg_bool_returns(bool isValid)
{
// Arrange
ParamValidator paramValidator = new();
Exception caughtException = null;
// Act
void attempt() => paramValidator.Validate(isValid);
// Assert
try
{
attempt();
}
catch (Exception ex)
{
Trace.WriteLine(ex.Message, ex.GetType().Name);
caughtException = ex;
}
Assert.IsNull(caughtException);
}
}
Ha try-catch blokkot implementálunk, akkor a catch-blokkba illik, vagy mondjuk inkább úgy, hogy
érdemes valamilyen logolást beiktatni.
Try-catch-blokkot ugyanis annak a kezelésére használjuk, ha valamilyen várható hiba keletkezik. Ez ugyan várható, de hiba.
Rossz gyakorlat az, ha olyan feladatra használjuk a try-catch blokkot, ami logikailag más módon megoldható, és/vagy nem a hibás működés kezelése a cél, hanem a program „észrevétlen” folytatása és a hiba elfedése. Ennek ellenére az úgynevezett „Try-catch design (anti-)pattern”, vagyis „pokémon exception handling”-jellegű try-catch blokkok ördögi kör szerűen egymást generáló, indokolatlan, már-már tervezési minta jellegű láncolata (pl. típus-szűrésre) gyakran alakul ki egy idő után (Michael Feathers definíciója szerinti) „legacy kódokban”, tehát olyan kódokban, amikhez nincsenek tesztek. (Lásd: The key points of Working Effectively with Legacy Code | Understand Legacy Code)
Például valami hasonló módon történhet értékadás legacy code továbbfejlesztése során újonnan implementált tulajdonságnak:
private object? _newProperty;
public object? NewProperty
{
get => _newProperty;
set
{
try
{
DoSomething(value);
_newProperty = value;
}
catch (Exception)
{
_newProperty = null;
}
}
}
Ezeket általános refaktorálás nélkül nem nagyon lehet kigyomlálni, mert az a „patch” a varrás mentén szakadna. Ki tudja, mi minden lett már elfedve, amit az utána következő logika már nem is ellenőriz, vagy pont hogy épít erre a viselkedésre? Viszont épp ezért, addig is érdemes bevezetni logolást a catch-ágban, hogy legyen információnk arról, ha pl. egy nemvárt hibát fed el ez a szükségmegoldás, hogy a refaktoráláshoz majd legyen adatunk, illetve ezzel az esetleg addig is folyó továbbfejlesztést is segítjük.
Persze, nem minden esetben indokolt vagy lehetséges logolni. De ha például beszúrjuk egy Trace.WriteLine-metódus meghívását, ami rögzíti a kivétel adatait (meg amit akarunk), azzal nagy bajt nem csinálunk. Viszont így hallhatja, aki hallgatja (Listener), például a debugger is megjeleníti az Output-ablakban.
És ha majd refaktoráljuk a kódot, akkor persze párhuzamosan építsük fel és őrizzük meg a teszteket is, és ne gyártsunk már eredendően legacy code-ot. A legacy code természetesen több és kevesebb is, mint „code without tests”. (Például, ahol a Property-ben ilyen exception handling van, az már eleve Legacy kód.) Ez csak egy végtelenül leegyszerűsítő, ám nagyon frappáns definíció, amit fejlesztés közben érdemes mindig fejben tartani. (Ha valakit érdekel, itt olvashat – a fenti definícióval vitatkozva – a legacy code pontosabb meghatározásáról: What is Legacy Code? Is it code without tests? | Understand Legacy Code.)
A fenti POC-tesztmetódusban is, bár a negatív teszt-esetet az elkapott kivétel létének a kiértékelésére építjük, magát a hibát végső soron elfedjük. A teszt üzenete ugyan tartalmazni fogja az elkapott kivétel Message-tulajdonságát, a rendellenes működésről szükségünk lehet ennél részletesebb információra. Tehát mielőtt kiértékeljük az elkapott kivétel létét, logoljuk (ha van) a tartalmát.
A Trace.WriteLine metódussal az alábbi információt kapjuk a TestExplorer-ben a „false => fails”-esetre:
Ezek alapján
elkészíthetjük a saját „does not throw”-esetet kiértékelő metódusunkat is.
Ehhez hozzunk létre egy SupplementaryAssert-osztályt. Ha több teszt-projektben is akarjuk használni ezt az osztályt, akkor létrehozhatjuk külön projektben is, de fontos, hogy MSTest-projekt legyen, mert az Assert-osztályát fogjuk használni.
using Microsoft.VisualStudio.TestTools.UnitTesting;
public class SupplementaryAssert
{
public static void DoesNotThrowException(Action attempt)
{
Assert.IsNull(getCaughtExceptionOrNull());
Exception? getCaughtExceptionOrNull()
{
try
{
attempt();
return null;
}
catch (Exception caughtException)
{
// Logging…
return caughtException;
}
}
}
}
A saját kiértékelő metódusunkra most már építhetünk az MSTest-tesztosztályunkban is, pozitív teszt-esetre is szép, tiszta teszt-metódust:
[TestMethod]
public void ParamValidator_validArg_bool_returns()
{
// Arrange
ParamValidator paramValidator = new();
bool isValid = true;
// Act
void attempt() => paramValidator.Validate(isValid);
// Assert
SupplementaryAssert.DoesNotThrowException(attempt);
}

