Lezen in het Engels

Delen via


Best practices voor eenheidstests voor .NET

Er zijn talloze voordelen van het schrijven van eenheidstests. Ze helpen bij regressie, bieden documentatie en faciliteren een goed ontwerp. Maar wanneer eenheidstests moeilijk te lezen en broos zijn, kunnen ze uw codebasis verwoesten. In dit artikel worden enkele aanbevolen procedures beschreven voor het ontwerpen van eenheidstests ter ondersteuning van uw .NET Core- en .NET Standard-projecten. U leert technieken om uw tests tolerant en gemakkelijk te begrijpen te houden.

Door John Reese met speciale dank aan Roy Osherove

Voordelen van eenheidstests

In de volgende secties worden verschillende redenen beschreven voor het schrijven van eenheidstests voor uw .NET Core- en .NET Standard-projecten.

Minder tijd bij het uitvoeren van functionele tests

Functionele tests zijn duur. Ze hebben meestal betrekking op 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. De testloper bepaalt of de test is geslaagd of mislukt, niet de persoon.

Bescherming tegen regressie

Regressiefouten zijn fouten 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 bestaande functies nog steeds werken zoals verwacht. Met eenheidstests kunt u uw hele reeks tests na elke build opnieuw uitvoeren of zelfs nadat u een regel code hebt gewijzigd. Deze aanpak helpt om het vertrouwen te vergroten 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 of null geef? Wanneer u een reeks goed benoemde eenheidstests hebt, moet elke test duidelijk de verwachte uitvoer voor een bepaalde invoer uitleggen. Bovendien moet de test kunnen controleren of deze 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. Het schrijven van tests voor je code zorgt er op natuurlijke wijze voor dat je code losgekoppeld wordt, omdat het anders moeilijker te testen is.

Kenmerken van goede eenheidstests

Er zijn verschillende belangrijke kenmerken die een goede eenheidstest definiëren:

  • Fast: het is niet ongebruikelijk dat volwassen projecten duizenden eenheidstests hebben. Eenheidstests moeten weinig tijd in beslag nemen om uit te voeren. Milliseconden.
  • Geïsoleerde: Unit tests zijn zelfstandig, kunnen geïsoleerd worden uitgevoerd en hebben geen afhankelijkheden van externe factoren, zoals een bestandssysteem of database.
  • Herhaalbaarheid: Het uitvoeren van een unit test moet consistent zijn met de resultaten. De test retourneert altijd hetzelfde resultaat als u niets tussen uitvoeringen wijzigt.
  • zelfcontrolemechanisme: De test moet automatisch detecteren of deze is geslaagd of mislukt zonder menselijke tussenkomst.
  • Tijdige: Een unittest moet niet onevenredig veel tijd kosten om te schrijven ten opzichte van de code die wordt getest. Als u ontdekt dat het testen van de code veel tijd kost in vergelijking met het schrijven van de code, kunt u een testbaar ontwerp overwegen.

Codedekking en codekwaliteit

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. Overweeg een complex project met duizenden voorwaardelijke vertakkingen en stel dat u een doel van 95% codedekking instelt. Op dit moment onderhoudt het project 90% codedekking. De hoeveelheid tijd die nodig is om rekening te houden met alle randgevallen in de resterende 5% kan een grote opgave zijn en de waardevermindering treedt snel in.

Een hoog codedekkingspercentage is geen indicator van succes en impliceert geen hoge codekwaliteit. Het vertegenwoordigt alleen de hoeveelheid code die wordt gedekt door eenheidstests. Zie codedekking voor eenheidstestsvoor meer informatie.

Terminologie voor eenheidstests

Verschillende termen worden vaak gebruikt in de context van eenheidstests: nep-, mocken stub-. Helaas kunnen deze voorwaarden onjuist worden toegepast, dus het is belangrijk om het juiste gebruik te begrijpen.

  • Fake: Een nep-object is een generieke term die kan worden gebruikt om een stub of een mock-object te beschrijven. Of het object nu een stub of een mock is, is afhankelijk van de context waarin het object wordt gebruikt. 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 en blijft nep totdat het een Assert-operatie binnengaat.

  • 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 een nepversie.

Houd rekening met de volgende code:

var mockOrder = new MockOrder();
var purchase = new Purchase(mockOrder);

purchase.ValidateOrders();

Assert.True(purchase.CanBeShipped);

Deze code toont een stub die wordt aangeduid als een mock. Maar in dit scenario is de stub echt een stub. Het doel van de code is om de volgorde door te geven als een middel om het Purchase -object (het systeem onder test) te instantiëren. De klassenaam MockOrder misleidend is omdat de volgorde een stub is en geen mock.

De volgende code toont een nauwkeuriger ontwerp:

var stubOrder = new FakeOrder();
var purchase = new Purchase(stubOrder);

purchase.ValidateOrders();

Assert.True(purchase.CanBeShipped);

Wanneer de naam van de klasse wordt gewijzigd in FakeOrder, is de klasse algemener. De klasse kan worden gebruikt als een mock of een stub, volgens de vereisten van de testcase. In het eerste voorbeeld wordt de FakeOrder-klasse gebruikt als stub en wordt deze niet gebruikt tijdens de Assert bewerking. De code geeft de FakeOrder-klasse door aan de Purchase-klasse om te voldoen aan de vereisten van de constructor.

Als u de klasse als een mock wilt gebruiken, kunt u de code bijwerken:

var mockOrder = new FakeOrder();
var purchase = new Purchase(mockOrder);

purchase.ValidateOrders();

Assert.True(mockOrder.Validated);

In dit ontwerp controleert de code een eigenschap van het nepobject en bevestigt het, en daarom is de mockOrder-klasse een mock.

Belangrijk

Het is belangrijk om de terminologie correct te implementeren. 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, met uitzondering van het Assert proces. U voert Assert bewerkingen uit op een mock-object, maar niet op een stub.

Beste praktijken

Er zijn verschillende belangrijke aanbevolen procedures die u moet volgen bij het schrijven van eenheidstests. De volgende secties bevatten voorbeelden die laten zien hoe u de aanbevolen procedures toepast op uw code.

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 Expliciete afhankelijkheden-principe te volgen en door .NET-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.

Testnaamstandaarden volgen

De naam van uw test moet bestaan uit drie onderdelen:

  • Naam van de methode die wordt getest
  • Scenario waarin de methode wordt getest
  • Verwacht gedrag wanneer het scenario wordt aangeroepen

Naamgevingsstandaarden zijn belangrijk omdat ze helpen het testdoel en de toepassing uit te drukken. Tests zijn meer dan alleen om ervoor te zorgen dat uw code werkt. Ze bieden ook documentatie. Door naar de reeks eenheidstests te kijken, moet u het gedrag van uw code kunnen afleiden en niet naar de code zelf hoeven te kijken. Bovendien kunt u, wanneer tests mislukken, precies zien welke scenario's niet aan uw verwachtingen voldoen.

oorspronkelijke code

[Fact]
public void Test_Single()
{
    var stringCalculator = new StringCalculator();

    var actual = stringCalculator.Add("0");

    Assert.Equal(0, actual);
}

Best practice toepassen

[Fact]
public void Add_SingleNumber_ReturnsSameNumber()
{
    var stringCalculator = new StringCalculator();

    var actual = stringCalculator.Add("0");

    Assert.Equal(0, actual);
}

Rangschik uw tests

Het patroon Rangschikken, Handelen, Bevestigen is een veelgebruikte aanpak voor het schrijven van eenheidstests. Zoals de naam al aangeeft, bestaat het patroon uit drie hoofdtaken:

  • uw objecten rangschikken, deze indien nodig maken en configureren
  • Act op een object
  • Assert dat iets is zoals verwacht

Wanneer u het patroon volgt, kunt u duidelijk onderscheiden wat wordt getest van de taken Rangschikken en Stellen. Het patroon helpt ook om de kans te verminderen dat asserties worden gecombineerd met code in de taak Act.

Leesbaarheid is een van de belangrijkste aspecten bij het schrijven van een eenheidstest. Als u elke patroonactie in de test scheidt, 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 algemene doel om de test zo leesbaar mogelijk te maken.

oorspronkelijke code

[Fact]
public void Add_EmptyString_ReturnsZero()
{
    // Arrange
    var stringCalculator = new StringCalculator();

    // Assert
    Assert.Equal(0, stringCalculator.Add(""));
}

Best practice toepassen

[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 voor een eenheidstest moet de eenvoudigste informatie zijn die nodig is om het gedrag te controleren dat u momenteel test. De minimalistische benadering helpt tests toleranter te worden voor toekomstige wijzigingen in de codebasis en zich te richten op het controleren van het gedrag van de implementatie.

Tests die meer informatie bevatten dan nodig is om de huidige 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 als dat niet vereist is, trekt alleen af van wat u probeert te bevestigen.

oorspronkelijke code

[Fact]
public void Add_SingleNumber_ReturnsSameNumber()
{
    var stringCalculator = new StringCalculator();

    var actual = stringCalculator.Add("42");

    Assert.Equal(42, actual);
}

Best practice toepassen

[Fact]
public void Add_SingleNumber_ReturnsSameNumber()
{
    var stringCalculator = new StringCalculator();

    var actual = stringCalculator.Add("0");

    Assert.Equal(0, actual);
}

Vermijd magic-tekenreeksen

Magic-tekenreeksen zijn tekenreekswaarden die rechtstreeks in uw unittests zijn vastgelegd zonder extra codecommentaar of context. Deze waarden maken uw code minder leesbaar en moeilijker te onderhouden. Magic-tekenreeksen kunnen verwarring zaaien bij de lezer van uw testen. Als een tekenreeks er anders uitziet, vraagt men zich misschien af waarom een bepaalde waarde is gekozen voor een parameter of terugkeerwaarde. Dit type tekenreekswaarde kan ertoe leiden dat ze de implementatiedetails nader bekijken in plaats van zich te richten op de test.

Tip

Stel als doel om zo veel mogelijk intentie uit te drukken in je unit-testcode. In plaats van magic strings te gebruiken, wijst u eventuele vastgelegde waarden toe aan constanten.

oorspronkelijke code

[Fact]
public void Add_BigNumber_ThrowsException()
{
    var stringCalculator = new StringCalculator();

    Action actual = () => stringCalculator.Add("1001");

    Assert.Throws<OverflowException>(actual);
}

Best practice toepassen

[Fact]
void Add_MaximumSumResult_ThrowsOverflowException()
{
    var stringCalculator = new StringCalculator();
    const string MAXIMUM_RESULT = "1001";

    Action actual = () => stringCalculator.Add(MAXIMUM_RESULT);

    Assert.Throws<OverflowException>(actual);
}

Codelogica in eenheidstests vermijden

Wanneer u uw eenheidstests schrijft, vermijdt u handmatige samenvoeging van tekenreeksen, logische voorwaarden, zoals if, while, foren switchen andere voorwaarden. Als u logica in uw testpakket opneemt, neemt de kans op het introduceren van bugs aanzienlijk toe. De laatste plaats waar u een fout wilt vinden, bevindt zich in uw testpakket. U moet een hoge mate van vertrouwen hebben dat uw tests werken, anders kunt u ze niet vertrouwen. Tests die je niet vertrouwt, hebben geen waarde. Wanneer een test mislukt, wilt u weten dat er iets mis is met uw code en dat deze niet kan worden genegeerd.

Tip

Als het toevoegen van logica in uw test onvermijdelijk lijkt, kunt u overwegen om de test te splitsen in twee of meer verschillende tests om de logische vereisten te beperken.

oorspronkelijke code

[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;
    }
}

Best practice toepassen

[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);
}

Helpermethoden gebruiken in plaats van Setup en Teardown

Als u een vergelijkbaar object of een vergelijkbare status voor uw tests nodig hebt, gebruikt u een helpermethode in plaats van Setup en Teardown kenmerken, als deze bestaan. Helpermethoden hebben om verschillende redenen de voorkeur boven deze kenmerken:

  • 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 wordt het kenmerk Setup aangeroepen vóór elke eenheidstest in uw testsuite. Sommige programmeurs zien dit gedrag als nuttig, maar het resulteert vaak in opgeblazen en tests die moeilijk leesbaar zijn. Elke test heeft over het algemeen verschillende vereisten voor de installatie en uitvoering. Helaas dwingt het kenmerk Setup u exact dezelfde vereisten voor elke test te gebruiken.

Notitie

De kenmerken SetUp en TearDown worden verwijderd in xUnit versie 2.x en hoger.

oorspronkelijke code

Best practice toepassen

private readonly StringCalculator stringCalculator;
public StringCalculatorTests()
{
    stringCalculator = new StringCalculator();
}
[Fact]
public void Add_TwoNumbers_ReturnsSumOfNumbers()
{
    var stringCalculator = CreateDefaultStringCalculator();

    var actual = stringCalculator.Add("0,1");

    Assert.Equal(1, actual);
}
// More tests...
// More tests...
[Fact]
public void Add_TwoNumbers_ReturnsSumOfNumbers()
{
    var result = stringCalculator.Add("0,1");

    Assert.Equal(1, result);
}
private StringCalculator CreateDefaultStringCalculator()
{
    return new StringCalculator();
}

Vermijd meerdere Act-taken

Wanneer u uw tests schrijft, probeert u slechts één Act-taak per test op te nemen. Enkele veelvoorkomende benaderingen voor het implementeren van één Act-taak zijn het maken van een afzonderlijke test voor elke act of het gebruik van geparameteriseerde tests. Er zijn verschillende voordelen voor het gebruik van één Act-taak voor elke test:

  • U kunt eenvoudig zien welke Act-taak mislukt als de test mislukt.
  • U kunt ervoor zorgen dat de test zich richt op slechts één geval.
  • U krijgt een duidelijk beeld van de reden waarom uw tests mislukken.

Meerdere Act-taken moeten afzonderlijk worden toegepast en u kunt niet garanderen dat alle assertietaken worden uitgevoerd. In de meeste frameworks voor het testen van eenheden, worden alle volgende tests automatisch beschouwd als mislukt nadat een assertietaak in een eenheidstest mislukt. Het proces kan verwarrend zijn omdat sommige werkende functionaliteit kan worden geïnterpreteerd als mislukt.

oorspronkelijke code

[Fact]
public void Add_EmptyEntries_ShouldBeTreatedAsZero()
{
    // Act
    var actual1 = stringCalculator.Add("");
    var actual2 = stringCalculator.Add(",");

    // Assert
    Assert.Equal(0, actual1);
    Assert.Equal(0, actual2);
}

Best practice toepassen

[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 met openbare methoden

In de meeste gevallen hoeft u geen persoonlijke methode in uw code te testen. Privémethoden zijn een implementatiedetail en bestaan nooit geïsoleerd. Op een bepaald moment in het ontwikkelingsproces introduceert u een openbare methode om de privémethode aan te roepen als onderdeel van de implementatie. Wanneer u uw eenheidstests schrijft, is wat u belangrijk vindt het eindresultaat van de openbare methode die de persoonlijke methode aanroept.

Houd rekening met het volgende codescenario:

public string ParseLogLine(string input)
{
    var sanitizedInput = TrimInput(input);
    return sanitizedInput;
}

private string TrimInput(string input)
{
    return input.Trim();
}

In termen van testen is het mogelijk dat uw eerste reactie is om een test te schrijven voor de TrimInput methode om ervoor te zorgen dat deze werkt zoals verwacht. Het is echter mogelijk dat de ParseLogLine methode het sanitizedInput object bewerkt op een manier die u niet verwacht. Het onbekende gedrag kan uw test tegen de TrimInput methode nutteloos maken.

Een betere test in dit scenario is om de openbare ParseLogLine-methode te verifiëren.

public void ParseLogLine_StartsAndEndsWithSpace_ReturnsTrimmedResult()
{
    var parser = new Parser();

    var result = parser.ParseLogLine(" a ");

    Assert.Equals("a", result);
}

Wanneer u een privémethode tegenkomt, zoekt u de openbare methode die de privémethode aanroept en schrijft u uw tests op basis van de openbare methode. Omdat een privémethode een verwacht resultaat retourneert, betekent dit niet dat het systeem dat uiteindelijk de privémethode aanroept, het resultaat correct gebruikt.

Statische referentie-stubs met naden behandelen

Een van de principes van een eenheidstest is dat het systeem volledig onder controle moet zijn. Dit principe kan echter problematisch zijn wanneer productiecode aanroepen naar statische verwijzingen bevat (bijvoorbeeld DateTime.Now).

Bekijk het volgende codescenario:

public int GetDiscountedPrice(int price)
{
    if (DateTime.Now.DayOfWeek == DayOfWeek.Tuesday)
    {
        return price / 2;
    }
    else
    {
        return price;
    }
}

Kunt u een eenheidstest schrijven voor deze code? U kunt proberen een assert-taak uit te voeren op de price:

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 realiseert u zich snel dat er enkele problemen zijn met uw test:

  • Als de testsuite op dinsdag wordt uitgevoerd, wordt de tweede test geslaagd, maar mislukt de eerste test.
  • Als de testsuite op een andere dag wordt uitgevoerd, is 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 hebt:

public interface IDateTimeProvider
{
    DayOfWeek DayOfWeek();
}

public int GetDiscountedPrice(int price, IDateTimeProvider dateTimeProvider)
{
    if (dateTimeProvider.DayOfWeek() == DayOfWeek.Tuesday)
    {
        return price / 2;
    }
    else
    {
        return price;
    }
}

U moet ook een nieuwe versie van uw testpakket schrijven:

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 de DateTime.Now waarde en kan elke waarde stuben bij het aanroepen van de methode.