Best practices voor eenheidstests met .NET Core en .NET Standard
Er zijn talloze voordelen van het schrijven van eenheidstests; ze helpen bij regressie, bieden documentatie en faciliteren een goed ontwerp. Moeilijk te lezen en broos-eenheidstests kunnen uw codebasis echter verwoesten. In dit artikel worden enkele aanbevolen procedures beschreven met betrekking tot het ontwerp van eenheidstests voor uw .NET Core- en .NET Standard-projecten.
In deze handleiding leert u enkele aanbevolen procedures bij het schrijven van eenheidstests om uw tests tolerant en gemakkelijk te begrijpen.
Door John Reese met speciale dank aan Roy Osherove
Waarom eenheidstest?
Er zijn verschillende redenen om eenheidstests te gebruiken.
Minder tijd bij het uitvoeren van functionele tests
Functionele tests zijn duur. Ze omvatten doorgaans het openen van de toepassing en het uitvoeren van een reeks stappen die u (of iemand anders) moet uitvoeren om het verwachte gedrag te valideren. Deze stappen zijn mogelijk niet altijd bekend bij de tester. Ze moeten contact opnemen met iemand die meer deskundig is in het gebied om de test uit te voeren. Het testen zelf kan seconden duren voor triviale wijzigingen of minuten voor grotere wijzigingen. Ten slotte moet dit proces worden herhaald voor elke wijziging die u in het systeem aanbrengt.
Eenheidstests, daarentegen, nemen milliseconden, kunnen met de druk op een knop worden uitgevoerd en vereisen niet noodzakelijkerwijs enige kennis van het systeem. Of de test is geslaagd of mislukt, is aan de testloper, niet aan de persoon.
Bescherming tegen regressie
Regressiefouten zijn defecten die worden geïntroduceerd wanneer een wijziging in de toepassing wordt aangebracht. Het is gebruikelijk dat testers niet alleen hun nieuwe functie testen, maar ook functies testen die vooraf bestonden om te controleren of eerder geïmplementeerde functies nog steeds werken zoals verwacht.
Met eenheidstests is het mogelijk om na elke build of zelfs nadat u een regel code hebt gewijzigd, uw volledige reeks tests opnieuw uit te voeren. Geeft u er vertrouwen in dat uw nieuwe code de bestaande functionaliteit niet onderbreekt.
Uitvoerbare documentatie
Het is misschien niet altijd duidelijk wat een bepaalde methode doet of hoe deze zich gedraagt op basis van een bepaalde invoer. Misschien vraagt u zich af: Hoe gedraagt deze methode zich als ik deze door een lege tekenreeks geef? Null?
Wanneer u een reeks goed benoemde eenheidstests hebt, moet elke test de verwachte uitvoer voor een bepaalde invoer duidelijk kunnen uitleggen. Bovendien moet het kunnen controleren of het daadwerkelijk werkt.
Minder gekoppelde code
Wanneer code nauw is gekoppeld, kan het lastig zijn om de eenheidstest te testen. Zonder eenheidstests te maken voor de code die u schrijft, is koppeling mogelijk minder duidelijk.
Als u tests voor uw code schrijft, wordt uw code natuurlijk losgekoppeld, omdat het lastiger zou zijn om anders te testen.
Kenmerken van een goede eenheidstest
- Snel: Het is niet ongebruikelijk dat volwassen projecten duizenden eenheidstests hebben. Eenheidstests moeten weinig tijd in beslag nemen om uit te voeren. Milliseconden.
- Geïsoleerd: eenheidstests zijn zelfstandig, kunnen geïsoleerd worden uitgevoerd en hebben geen afhankelijkheden van externe factoren, zoals een bestandssysteem of database.
- Herhaalbaar: het uitvoeren van een eenheidstest moet consistent zijn met de resultaten, dat wil zeggen dat het altijd hetzelfde resultaat retourneert als u niets tussen uitvoeringen wijzigt.
- Zelfcontrole: De test moet automatisch kunnen detecteren of deze is geslaagd of mislukt zonder menselijke tussenkomst.
- Tijdig: Een eenheidstest mag niet onevenredig lang duren om te schrijven in vergelijking met de code die wordt getest. Als u merkt dat het testen van de code veel tijd in beslag neemt in vergelijking met het schrijven van de code, kunt u een ontwerp overwegen dat testbaar is.
Codedekking
Een hoog codedekkingspercentage is vaak gekoppeld aan een hogere kwaliteit van code. De meting zelf kan echter niet de kwaliteit van de code bepalen. Het instellen van een te ambitieus codedekkingspercentagedoel kan contraproductief zijn. Stel een complex project voor met duizenden voorwaardelijke vertakkingen en stel dat u een doel van 95% codedekking instelt. Het project onderhoudt momenteel 90% codedekking. De hoeveelheid tijd die nodig is om rekening te houden met alle edge-zaken in de resterende 5% kan een enorme onderneming zijn en de waardepropositie neemt snel af.
Een hoog codedekkingspercentage is geen indicator van succes en impliceert ook geen hoge codekwaliteit. Het vertegenwoordigt alleen de hoeveelheid code die wordt gedekt door eenheidstests. Zie de codedekking voor eenheidstests voor meer informatie.
Laten we dezelfde taal spreken
De term mock wordt helaas vaak misbruikt wanneer het over testen gaat. De volgende punten definiëren de meest voorkomende soorten neps bij het schrijven van eenheidstests :
Nep - Een nep is een generieke term die kan worden gebruikt om een stub of een mock-object te beschrijven. Of het nu een stub of een mock is, is afhankelijk van de context waarin deze wordt gebruikt. Dus met andere woorden, een nep kan een stub of een mock zijn.
Mock - Een mock-object is een nepobject in het systeem dat bepaalt of een eenheidstest is geslaagd of mislukt. Een mock begint als nep totdat het tegen wordt verklaard.
Stub - Een stub is een controleerbare vervanging voor een bestaande afhankelijkheid (of samenwerker) in het systeem. Met behulp van een stub kunt u uw code testen zonder rechtstreeks met de afhankelijkheid te werken. Standaard begint een stub als nep.
Bekijk het volgende codefragment:
var mockOrder = new MockOrder();
var purchase = new Purchase(mockOrder);
purchase.ValidateOrders();
Assert.True(purchase.CanBeShipped);
Het voorgaande voorbeeld zou een stub zijn die wordt aangeduid als een mock. In dit geval is het een stub. U geeft alleen de Volgorde door als een middel om te kunnen instantiëren Purchase
(het systeem dat wordt getest). De naam MockOrder
is ook misleidend omdat de volgorde geen mock is.
Een betere aanpak is:
var stubOrder = new FakeOrder();
var purchase = new Purchase(stubOrder);
purchase.ValidateOrders();
Assert.True(purchase.CanBeShipped);
Door de naam van de klas FakeOrder
te wijzigen in , hebt u de klas veel algemener gemaakt. De klasse kan worden gebruikt als een mock of een stub, afhankelijk van wat beter is voor de testcase. In het voorgaande voorbeeld FakeOrder
wordt deze gebruikt als stub. U gebruikt geen FakeOrder
vorm of formulier tijdens de assert. FakeOrder
is doorgegeven aan de Purchase
klasse om te voldoen aan de vereisten van de constructor.
Als u deze als een mock wilt gebruiken, kunt u iets doen zoals de volgende code:
var mockOrder = new FakeOrder();
var purchase = new Purchase(mockOrder);
purchase.ValidateOrders();
Assert.True(mockOrder.Validated);
In dit geval controleert u een eigenschap op de Fake (die ertegen staat), dus in het voorgaande codefragment is het mockOrder
een mock.
Belangrijk
Het is belangrijk om deze terminologie correct te krijgen. Als u uw stubs 'mocks' noemt, gaan andere ontwikkelaars valse veronderstellingen over uw intentie maken.
Het belangrijkste om te onthouden over mocks versus stubs is dat mocks net als stubs zijn, maar u beweert tegen het mock-object, terwijl u niet asserteert tegen een stub.
Aanbevolen procedures
Hier volgen enkele van de belangrijkste aanbevolen procedures voor het schrijven van eenheidstests.
Infrastructuurafhankelijkheden voorkomen
Probeer geen afhankelijkheden van de infrastructuur te introduceren bij het schrijven van eenheidstests. De afhankelijkheden maken de tests traag en broos en moeten worden gereserveerd voor integratietests. U kunt deze afhankelijkheden in uw toepassing vermijden door het principe expliciete afhankelijkheden te volgen en afhankelijkheidsinjectie te gebruiken. U kunt uw eenheidstests ook in een afzonderlijk project houden van uw integratietests. Deze aanpak zorgt ervoor dat uw eenheidstestproject geen verwijzingen naar of afhankelijkheden heeft van infrastructuurpakketten.
Uw tests een naam geven
De naam van uw test moet bestaan uit drie onderdelen:
- De naam van de methode die wordt getest.
- Het scenario waarin het wordt getest.
- Het verwachte gedrag wanneer het scenario wordt aangeroepen.
Waarom?
Naamgevingsstandaarden zijn belangrijk omdat ze expliciet de intentie van de test uitdrukken. Tests zijn meer dan alleen het controleren of uw code werkt, ze bieden ook documentatie. Door alleen naar de reeks eenheidstests te kijken, moet u het gedrag van uw code kunnen afleiden zonder de code zelf te bekijken. Bovendien kunt u, wanneer tests mislukken, precies zien welke scenario's niet voldoen aan uw verwachtingen.
Slecht:
[Fact]
public void Test_Single()
{
var stringCalculator = new StringCalculator();
var actual = stringCalculator.Add("0");
Assert.Equal(0, actual);
}
Beter:
[Fact]
public void Add_SingleNumber_ReturnsSameNumber()
{
var stringCalculator = new StringCalculator();
var actual = stringCalculator.Add("0");
Assert.Equal(0, actual);
}
Uw tests rangschikken
Rangschikken, Act, Assert is een gemeenschappelijk patroon bij het testen van eenheden. Zoals de naam al aangeeft, bestaat deze uit drie hoofdacties:
- Rangschik uw objecten, maak en stel ze zo nodig in.
- Actie ondernemen op een object.
- Beweert dat er iets is zoals verwacht.
Waarom?
- Scheidt duidelijk wat wordt getest op basis van de rangschikken en assertiestappen .
- Minder kans om asserties te combineren met 'Act'-code.
Leesbaarheid is een van de belangrijkste aspecten bij het schrijven van een test. Door elk van deze acties binnen de test te scheiden, worden duidelijk de afhankelijkheden gemarkeerd die nodig zijn om uw code aan te roepen, hoe uw code wordt aangeroepen en wat u probeert te bevestigen. Hoewel het mogelijk is om een aantal stappen te combineren en de grootte van uw test te verkleinen, is het primaire doel om de test zo leesbaar mogelijk te maken.
Slecht:
[Fact]
public void Add_EmptyString_ReturnsZero()
{
// Arrange
var stringCalculator = new StringCalculator();
// Assert
Assert.Equal(0, stringCalculator.Add(""));
}
Beter:
[Fact]
public void Add_EmptyString_ReturnsZero()
{
// Arrange
var stringCalculator = new StringCalculator();
// Act
var actual = stringCalculator.Add("");
// Assert
Assert.Equal(0, actual);
}
Minimaal geslaagde tests schrijven
De invoer die in een eenheidstest moet worden gebruikt, moet het eenvoudigst zijn om het gedrag te controleren dat u momenteel test.
Waarom?
- Tests worden toleranter voor toekomstige wijzigingen in de codebasis.
- Dichter bij het testen van gedrag tijdens de implementatie.
Tests die meer informatie bevatten dan nodig is om de test door te geven, hebben een hogere kans op het introduceren van fouten in de test en kunnen de intentie van de test minder duidelijk maken. Bij het schrijven van tests wilt u zich richten op het gedrag. Het instellen van extra eigenschappen voor modellen of het gebruik van niet-nulwaarden wanneer dat niet vereist is, trekt alleen af van wat u probeert te bewijzen.
Slecht:
[Fact]
public void Add_SingleNumber_ReturnsSameNumber()
{
var stringCalculator = new StringCalculator();
var actual = stringCalculator.Add("42");
Assert.Equal(42, actual);
}
Beter:
[Fact]
public void Add_SingleNumber_ReturnsSameNumber()
{
var stringCalculator = new StringCalculator();
var actual = stringCalculator.Add("0");
Assert.Equal(0, actual);
}
Vermijd magic-tekenreeksen
Naamgevingsvariabelen in eenheidstests zijn belangrijk, indien niet belangrijker, dan het benoemen van variabelen in productiecode. Eenheidstests mogen geen magic-tekenreeksen bevatten.
Waarom?
- Hiermee voorkomt u dat de lezer van de test de productiecode moet inspecteren om erachter te komen wat de waarde speciaal maakt.
- Toont expliciet wat u probeert te bewijzen in plaats van te proberen te bereiken.
Magic-tekenreeksen kunnen verwarring veroorzaken voor de lezer van uw tests. Als een tekenreeks er anders uitziet, vraagt deze zich misschien af waarom een bepaalde waarde is gekozen voor een parameter of retourwaarde. Dit type tekenreekswaarde kan ertoe leiden dat ze de implementatiedetails nader bekijken in plaats van zich te richten op de test.
Tip
Bij het schrijven van tests moet u zoveel mogelijk intenties uitdrukken. In het geval van magische tekenreeksen is het een goede benadering om deze waarden toe te wijzen aan constanten.
Slecht:
[Fact]
public void Add_BigNumber_ThrowsException()
{
var stringCalculator = new StringCalculator();
Action actual = () => stringCalculator.Add("1001");
Assert.Throws<OverflowException>(actual);
}
Beter:
[Fact]
void Add_MaximumSumResult_ThrowsOverflowException()
{
var stringCalculator = new StringCalculator();
const string MAXIMUM_RESULT = "1001";
Action actual = () => stringCalculator.Add(MAXIMUM_RESULT);
Assert.Throws<OverflowException>(actual);
}
Logica in tests vermijden
Vermijd bij het schrijven van uw eenheidstests handmatige samenvoeging van tekenreeksen, logische voorwaarden, zoalsif
, while
for
en en switch
andere voorwaarden.
Waarom?
- Minder kans om een bug in uw tests te introduceren.
- Richt u op het eindresultaat in plaats van implementatiedetails.
Wanneer u logica in uw testpakket introduceert, neemt de kans op het introduceren van een bug hierin aanzienlijk toe. De laatste plaats waar u een fout wilt vinden, bevindt zich in uw testpakket. U moet een hoog vertrouwensniveau hebben dat uw tests werken, anders vertrouwt u ze niet. Test die u niet vertrouwt, geef geen waarde op. Wanneer een test mislukt, wilt u weten dat er iets mis is met uw code en dat deze niet kan worden genegeerd.
Tip
Als logica in uw test onvermijdelijk lijkt, kunt u overwegen de test op te splitsen in twee of meer verschillende tests.
Slecht:
[Fact]
public void Add_MultipleNumbers_ReturnsCorrectResults()
{
var stringCalculator = new StringCalculator();
var expected = 0;
var testCases = new[]
{
"0,0,0",
"0,1,2",
"1,2,3"
};
foreach (var test in testCases)
{
Assert.Equal(expected, stringCalculator.Add(test));
expected += 3;
}
}
Beter:
[Theory]
[InlineData("0,0,0", 0)]
[InlineData("0,1,2", 3)]
[InlineData("1,2,3", 6)]
public void Add_MultipleNumbers_ReturnsSumOfNumbers(string input, int expected)
{
var stringCalculator = new StringCalculator();
var actual = stringCalculator.Add(input);
Assert.Equal(expected, actual);
}
De voorkeur geven aan helpermethoden voor het instellen en afbreken
Als u een vergelijkbaar object of een vergelijkbare status voor uw tests nodig hebt, geeft u de voorkeur aan een helpermethode dan het gebruik Setup
en Teardown
de kenmerken als deze bestaan.
Waarom?
- Minder verwarring bij het lezen van de tests, omdat alle code vanuit elke test zichtbaar is.
- Minder kans op het instellen van te veel of te weinig voor de gegeven test.
- Minder kans op het delen van de status tussen tests, waardoor er ongewenste afhankelijkheden tussen de tests ontstaan.
In frameworks voor eenheidstests Setup
wordt aangeroepen vóór elke eenheidstest binnen uw testpakket. Hoewel sommigen dit kunnen zien als een nuttig hulpprogramma, leidt het over het algemeen tot bloated en moeilijk te lezen tests. Elke test heeft over het algemeen verschillende vereisten om de test aan de slag te laten gaan. Helaas dwingt Setup
u om exact dezelfde vereisten voor elke test te gebruiken.
Notitie
xUnit heeft zowel SetUp als TearDown verwijderd vanaf versie 2.x
Slecht:
private readonly StringCalculator stringCalculator;
public StringCalculatorTests()
{
stringCalculator = new StringCalculator();
}
// more tests...
[Fact]
public void Add_TwoNumbers_ReturnsSumOfNumbers()
{
var result = stringCalculator.Add("0,1");
Assert.Equal(1, result);
}
Beter:
[Fact]
public void Add_TwoNumbers_ReturnsSumOfNumbers()
{
var stringCalculator = CreateDefaultStringCalculator();
var actual = stringCalculator.Add("0,1");
Assert.Equal(1, actual);
}
// more tests...
private StringCalculator CreateDefaultStringCalculator()
{
return new StringCalculator();
}
Vermijd meerdere handelingen
Probeer bij het schrijven van uw tests slechts één handeling per test op te nemen. Veelvoorkomende benaderingen voor het gebruik van slechts één handeling zijn:
- Maak een afzonderlijke test voor elke handeling.
- Gebruik geparameteriseerde tests.
Waarom?
- Wanneer de test mislukt, is duidelijk welke actie mislukt.
- Zorgt ervoor dat de test is gericht op slechts één case.
- Geeft u het hele beeld over waarom uw tests mislukken.
Meerdere handelingen moeten afzonderlijk worden assertieerd en het is niet gegarandeerd dat alle asserties worden uitgevoerd. In de meeste moduletestframeworks wordt, zodra een assert mislukt in een eenheidstest, automatisch als mislukt beschouwd. Dit type proces kan verwarrend zijn als functionaliteit die daadwerkelijk werkt, wordt weergegeven als mislukt.
Slecht:
[Fact]
public void Add_EmptyEntries_ShouldBeTreatedAsZero()
{
// Act
var actual1 = stringCalculator.Add("");
var actual2 = stringCalculator.Add(",");
// Assert
Assert.Equal(0, actual1);
Assert.Equal(0, actual2);
}
Beter:
[Theory]
[InlineData("", 0)]
[InlineData(",", 0)]
public void Add_EmptyEntries_ShouldBeTreatedAsZero(string input, int expected)
{
// Arrange
var stringCalculator = new StringCalculator();
// Act
var actual = stringCalculator.Add(input);
// Assert
Assert.Equal(expected, actual);
}
Privémethoden valideren door openbare methoden te testen per eenheid
In de meeste gevallen hoeft u geen privémethode te testen. Privémethoden zijn een implementatiedetail en bestaan nooit geïsoleerd. Op een bepaald moment is er een openbare methode die de privémethode aanroept als onderdeel van de implementatie. Wat u belangrijk vindt, is het eindresultaat van de openbare methode die de persoonlijke methode aanroept.
Houd rekening met het volgende geval:
public string ParseLogLine(string input)
{
var sanitizedInput = TrimInput(input);
return sanitizedInput;
}
private string TrimInput(string input)
{
return input.Trim();
}
Uw eerste reactie kan zijn om te beginnen met het schrijven van een test TrimInput
, omdat u ervoor wilt zorgen dat de methode werkt zoals verwacht. Het is echter volledig mogelijk dat ParseLogLine
u een test op een zodanige manier bewerkt sanitizedInput
dat u niet verwacht, waardoor een test wordt weergegeven tegen TrimInput
nutteloos.
De echte test moet worden uitgevoerd op basis van de openbare methode ParseLogLine
, omdat dat uiteindelijk belangrijk is.
public void ParseLogLine_StartsAndEndsWithSpace_ReturnsTrimmedResult()
{
var parser = new Parser();
var result = parser.ParseLogLine(" a ");
Assert.Equals("a", result);
}
Als u met dit standpunt een privémethode ziet, zoekt u de openbare methode en schrijft u uw tests op basis van die methode. Omdat een privémethode het verwachte resultaat retourneert, betekent dit niet dat het systeem dat uiteindelijk de privémethode aanroept, het resultaat correct gebruikt.
Statische stubverwijzingen
Een van de principes van een eenheidstest is dat het systeem volledig onder controle moet zijn. Dit principe kan problematisch zijn wanneer productiecode aanroepen naar statische verwijzingen bevat (bijvoorbeeld DateTime.Now
). Kijk eens naar de volgende code:
public int GetDiscountedPrice(int price)
{
if (DateTime.Now.DayOfWeek == DayOfWeek.Tuesday)
{
return price / 2;
}
else
{
return price;
}
}
Hoe kan deze code mogelijk worden getest? U kunt een benadering proberen zoals:
public void GetDiscountedPrice_NotTuesday_ReturnsFullPrice()
{
var priceCalculator = new PriceCalculator();
var actual = priceCalculator.GetDiscountedPrice(2);
Assert.Equals(2, actual)
}
public void GetDiscountedPrice_OnTuesday_ReturnsHalfPrice()
{
var priceCalculator = new PriceCalculator();
var actual = priceCalculator.GetDiscountedPrice(2);
Assert.Equals(1, actual);
}
Helaas zult u zich snel realiseren dat er een aantal problemen zijn met uw tests.
- Als de testsuite op een dinsdag wordt uitgevoerd, wordt de tweede test geslaagd, maar mislukt de eerste test.
- Als de testsuite op een andere dag wordt uitgevoerd, wordt de eerste test geslaagd, maar mislukt de tweede test.
Als u deze problemen wilt oplossen, moet u een naad in uw productiecode introduceren. Een benadering is het verpakken van de code die u in een interface moet beheren en de productiecode afhankelijk van die interface moet hebben.
public interface IDateTimeProvider
{
DayOfWeek DayOfWeek();
}
public int GetDiscountedPrice(int price, IDateTimeProvider dateTimeProvider)
{
if (dateTimeProvider.DayOfWeek() == DayOfWeek.Tuesday)
{
return price / 2;
}
else
{
return price;
}
}
Uw testpakket wordt nu als volgt:
public void GetDiscountedPrice_NotTuesday_ReturnsFullPrice()
{
var priceCalculator = new PriceCalculator();
var dateTimeProviderStub = new Mock<IDateTimeProvider>();
dateTimeProviderStub.Setup(dtp => dtp.DayOfWeek()).Returns(DayOfWeek.Monday);
var actual = priceCalculator.GetDiscountedPrice(2, dateTimeProviderStub);
Assert.Equals(2, actual);
}
public void GetDiscountedPrice_OnTuesday_ReturnsHalfPrice()
{
var priceCalculator = new PriceCalculator();
var dateTimeProviderStub = new Mock<IDateTimeProvider>();
dateTimeProviderStub.Setup(dtp => dtp.DayOfWeek()).Returns(DayOfWeek.Tuesday);
var actual = priceCalculator.GetDiscountedPrice(2, dateTimeProviderStub);
Assert.Equals(1, actual);
}
De testsuite heeft nu volledige controle over DateTime.Now
en kan elke waarde stuben bij het aanroepen van de methode.