Dela via


Metodtips för enhetstestning med .NET Core och .NET Standard

Det finns många fördelar med att skriva enhetstester; de hjälper till med regression, tillhandahåller dokumentation och underlättar god design. Men svåra att läsa och spröda enhetstester kan orsaka förödelse på din kodbas. I den här artikeln beskrivs några metodtips för enhetstestdesign för dina .NET Core- och .NET Standard-projekt.

I den här guiden får du lära dig några metodtips när du skriver enhetstester för att hålla dina tester motståndskraftiga och lätta att förstå.

Av John Reese med särskilt tack till Roy Osherove

Varför enhetstest?

Det finns flera orsaker till att använda enhetstester.

Mindre tid att utföra funktionella tester

Funktionella tester är dyra. De innebär vanligtvis att öppna programmet och utföra en serie steg som du (eller någon annan) måste följa för att verifiera det förväntade beteendet. De här stegen kanske inte alltid är kända för testaren. De måste kontakta någon som är mer kunnig i området för att utföra testet. Själva testningen kan ta sekunder för triviala ändringar eller minuter för större ändringar. Slutligen måste den här processen upprepas för varje ändring som du gör i systemet.

Enhetstester, å andra sidan, tar millisekunder, kan köras med en knapptryckning och kräver inte nödvändigtvis någon kunskap om systemet i stort. Om testet klarar eller inte är upp till testkörarna, inte individen.

Skydd mot regression

Regressionsfel är defekter som introduceras när en ändring görs i programmet. Det är vanligt att testare inte bara testar sin nya funktion utan även testfunktioner som fanns i förväg för att verifiera att tidigare implementerade funktioner fortfarande fungerar som förväntat.

Med enhetstestning kan du köra hela testpaketet igen efter varje version eller till och med när du har ändrat en kodrad. Ger dig förtroende för att den nya koden inte bryter befintliga funktioner.

Körbar dokumentation

Det kanske inte alltid är uppenbart vad en viss metod gör eller hur den beter sig med en viss indata. Du kan fråga dig själv: Hur fungerar den här metoden om jag skickar en tom sträng? Null?

När du har en uppsättning väl namngivna enhetstester bör varje test tydligt kunna förklara förväntade utdata för en viss indata. Dessutom bör den kunna kontrollera att den faktiskt fungerar.

Mindre kopplad kod

När koden är nära kopplad kan det vara svårt att enhetstesta. Utan att skapa enhetstester för den kod som du skriver kan kopplingen vara mindre uppenbar.

Om du skriver tester för koden frikopplas koden naturligt, eftersom det skulle vara svårare att testa annars.

Egenskaper hos ett bra enhetstest

  • Snabb: Det är inte ovanligt att mogna projekt har tusentals enhetstester. Enhetstester bör ta lite tid att köra. Millisekunder.
  • Isolerad: Enhetstester är fristående, kan köras isolerat och har inga beroenden för externa faktorer, till exempel ett filsystem eller en databas.
  • Repeterbar: Om du kör ett enhetstest bör det vara konsekvent med dess resultat, det vill säga att det alltid returnerar samma resultat om du inte ändrar något mellan körningarna.
  • Självkontroll: Testet bör kunna identifiera automatiskt om det har godkänts eller misslyckats utan någon mänsklig interaktion.
  • Tid: Ett enhetstest bör inte ta oproportionerligt lång tid att skriva jämfört med den kod som testas. Om det tar lång tid att testa koden jämfört med att skriva koden bör du överväga en design som är mer testbar.

Kodtäckning

En hög kodtäckningsprocent associeras ofta med en högre kodkvalitet. Själva mätningen kan dock inte fastställa kodens kvalitet. Att ange ett alltför ambitiöst mål för kodtäckningsprocent kan vara kontraproduktivt. Föreställ dig ett komplext projekt med tusentals villkorsstyrda grenar och tänk dig att du anger ett mål på 95 % kodtäckning. För närvarande har projektet 90 % kodtäckning. Den tid det tar att ta hänsyn till alla gränsfall i de återstående 5 procenten kan vara ett massivt åtagande, och värdeförslaget minskar snabbt.

En hög kodtäckningsprocent är inte en indikator på framgång, och det innebär inte heller hög kodkvalitet. Den representerar bara mängden kod som omfattas av enhetstester. Mer information finns i enhetstestning av kodtäckning.

Låt oss tala samma språk

Termen mock missbrukas tyvärr ofta när man talar om testning. Följande punkter definierar de vanligaste typerna av förfalskningar när du skriver enhetstester:

Fake – En falsk är en generisk term som kan användas för att beskriva antingen en stub eller ett hånfullt objekt. Om det är en stub eller en mock beror på i vilken kontext den används. Så med andra ord kan en förfalskning vara en stub eller ett hån.

Mock – Ett falskt objekt är ett falskt objekt i systemet som avgör om ett enhetstest har godkänts eller misslyckats. En mock börjar som en falsk tills den hävdas mot.

Stub – en stub är en kontrollbar ersättning för ett befintligt beroende (eller medarbetare) i systemet. Genom att använda en stub kan du testa koden utan att hantera beroendet direkt. Som standard börjar en stub som en falsk.

Överväg följande kodfragment:

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

purchase.ValidateOrders();

Assert.True(purchase.CanBeShipped);

Föregående exempel skulle vara en stub som kallas för ett hån. I det här fallet är det en stub. Du skickar bara order som ett sätt att instansiera Purchase (systemet som testas). Namnet MockOrder är också missvisande eftersom ordningen återigen inte är ett hån.

Ett bättre tillvägagångssätt skulle vara:

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

purchase.ValidateOrders();

Assert.True(purchase.CanBeShipped);

Genom att byta namn på klassen till FakeOrderhar du gjort klassen mycket mer generisk. Klassen kan användas som en mock eller en stub, beroende på vilket som är bättre för testfallet. I föregående exempel FakeOrder används som en stub. Du använder FakeOrder inte i någon form under kontrollen. FakeOrder skickades in i Purchase klassen för att uppfylla konstruktorns krav.

Om du vill använda den som en mock kan du göra något som liknar följande kod:

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

purchase.ValidateOrders();

Assert.True(mockOrder.Validated);

I det här fallet kontrollerar du en egenskap på Fake (hävdar mot den), så i föregående kodfragment mockOrder är det en mock.

Viktigt!

Det är viktigt att få den här terminologin korrekt. Om du kallar dina stubs för "hån" kommer andra utvecklare att göra falska antaganden om din avsikt.

Det viktigaste att komma ihåg om hån kontra stubs är att hån är precis som stubs, men du hävdar mot det falska objektet, medan du inte hävdar mot en stub.

Bästa praxis

Här är några av de viktigaste metodtipsen för att skriva enhetstester.

Undvik infrastrukturberoenden

Försök att inte införa beroenden för infrastrukturen när du skriver enhetstester. Beroendena gör testerna långsamma och spröda och bör reserveras för integreringstester. Du kan undvika dessa beroenden i ditt program genom att följa principen explicita beroenden och använda beroendeinmatning. Du kan också hålla enhetstesterna i ett separat projekt från dina integreringstester. Den här metoden säkerställer att enhetstestprojektet inte har referenser till eller beroenden för infrastrukturpaket.

Namnge dina tester

Namnet på testet bör bestå av tre delar:

  • Namnet på den metod som testas.
  • Scenariot där det testas.
  • Det förväntade beteendet när scenariot anropas.

Varför?

Namngivningsstandarder är viktiga eftersom de uttryckligen uttrycker avsikten med testet. Tester är mer än att bara se till att koden fungerar, de tillhandahåller också dokumentation. Bara genom att titta på sviten med enhetstester bör du kunna härleda beteendet för din kod utan att ens titta på själva koden. När testerna misslyckas kan du dessutom se exakt vilka scenarier som inte uppfyller dina förväntningar.

Dålig:

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

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

    Assert.Equal(0, actual);
}

Bättre:

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

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

    Assert.Equal(0, actual);
}

Ordna dina tester

Ordna, Agera, Assert är ett vanligt mönster vid enhetstestning. Som namnet antyder består det av tre huvudsakliga åtgärder:

  • Ordna dina objekt, skapa och konfigurera dem efter behov.
  • Agera på ett objekt.
  • Bekräfta att något är som förväntat.

Varför?

  • Separerar tydligt vad som testas från stegen ordna och kontrollera .
  • Mindre chans att blanda in påståenden med "Act"-kod.

Läsbarhet är en av de viktigaste aspekterna när du skriver ett test. Om du separerar var och en av dessa åtgärder i testet markeras tydligt de beroenden som krävs för att anropa koden, hur koden anropas och vad du försöker hävda. Det kan vara möjligt att kombinera vissa steg och minska teststorleken, men det primära målet är att göra testet så läsbart som möjligt.

Dålig:

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

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

Bättre:

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

    // Act
    var actual = stringCalculator.Add("");

    // Assert
    Assert.Equal(0, actual);
}

Skriv minimalt klara tester

De indata som ska användas i ett enhetstest bör vara det enklaste möjliga för att verifiera det beteende som du testar för närvarande.

Varför?

  • Testerna blir mer motståndskraftiga mot framtida ändringar i kodbasen.
  • Närmare testbeteende över implementering.

Tester som innehåller mer information än vad som krävs för att klara testet har större chans att införa fel i testet och kan göra avsikten med testet mindre tydlig. När du skriver tester vill du fokusera på beteendet. Om du ställer in extra egenskaper för modeller eller använder värden som inte är noll när det inte behövs, förringas bara det du försöker bevisa.

Dålig:

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

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

    Assert.Equal(42, actual);
}

Bättre:

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

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

    Assert.Equal(0, actual);
}

Undvik magiska strängar

Att namnge variabler i enhetstester är viktigt, om inte viktigare, än att namnge variabler i produktionskod. Enhetstester får inte innehålla magiska strängar.

Varför?

  • Förhindrar att testläsaren kontrollerar produktionskoden för att ta reda på vad som gör värdet speciellt.
  • Visar uttryckligen vad du försöker bevisa i stället för att försöka utföra.

Magiska strängar kan orsaka förvirring för läsaren av dina tester. Om en sträng ser ut utöver det vanliga kanske de undrar varför ett visst värde valdes för en parameter eller ett returvärde. Den här typen av strängvärde kan leda till att de tar en närmare titt på implementeringsinformationen i stället för att fokusera på testet.

Dricks

När du skriver tester bör du sträva efter att uttrycka så mycket avsikt som möjligt. När det gäller magiska strängar är en bra metod att tilldela dessa värden till konstanter.

Dålig:

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

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

    Assert.Throws<OverflowException>(actual);
}

Bättre:

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

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

    Assert.Throws<OverflowException>(actual);
}

Undvik logik i tester

När du skriver enhetstesterna bör du undvika manuell strängsammanfogning, logiska villkor som if, while, foroch och switchandra villkor.

Varför?

  • Mindre chans att introducera en bugg i dina tester.
  • Fokusera på slutresultatet i stället för implementeringsinformation.

När du introducerar logik i din testsvit ökar risken för att en bugg introduceras i den dramatiskt. Den sista platsen där du vill hitta en bugg finns i din testsvit. Du bör ha hög förtroende för att dina tester fungerar, annars litar du inte på dem. Tester som du inte litar på, ger inget värde. När ett test misslyckas vill du ha en känsla av att något är fel med din kod och att det inte kan ignoreras.

Dricks

Om logiken i testet verkar oundviklig kan du överväga att dela upp testet i två eller flera olika tester.

Dålig:

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

Bättre:

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

Föredrar hjälpmetoder för att konfigurera och riva ned

Om du behöver ett liknande objekt eller tillstånd för dina tester föredrar du en hjälpmetod än att använda Setup och Teardown attribut om de finns.

Varför?

  • Mindre förvirring vid läsning av testerna eftersom all kod är synlig inifrån varje test.
  • Mindre chans att ställa in för mycket eller för lite för det angivna testet.
  • Mindre chans att dela tillstånd mellan tester, vilket skapar oönskade beroenden mellan dem.

I ramverk för enhetstestning anropas Setup före varje enhetstest i din testsvit. Även om vissa kan se detta som ett användbart verktyg leder det vanligtvis till uppsvällda och svåra att läsa tester. Varje test har i allmänhet olika krav för att få igång testet. Tyvärr Setup tvingar dig att använda exakt samma krav för varje test.

Kommentar

xUnit har tagit bort både SetUp och TearDown från och med version 2.x

Dålig:

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

Bättre:

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

Undvik flera akter

När du skriver dina tester kan du bara försöka inkludera en handling per test. Vanliga metoder för att bara använda en handling är:

  • Skapa ett separat test för varje handling.
  • Använd parametriserade tester.

Varför?

  • När testet misslyckas är det tydligt vilken handling som misslyckas.
  • Säkerställer att testet fokuserar på bara ett enskilt fall.
  • Ger dig hela bilden om varför dina tester misslyckas.

Flera akter måste vara individuellt framhållna och det är inte garanterat att alla Asserts körs. När en Assert misslyckas i ett enhetstest i de flesta enhetstestramverk anses de fortsättande testerna automatiskt misslyckas. Den här typen av process kan vara förvirrande eftersom funktioner som faktiskt fungerar visas som misslyckade.

Dålig:

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

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

Bättre:

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

Verifiera privata metoder genom enhetstestning av offentliga metoder

I de flesta fall bör det inte finnas något behov av att testa en privat metod. Privata metoder är en implementeringsinformation och finns aldrig isolerat. Någon gång kommer det att finnas en offentlig metod som anropar den privata metoden som en del av implementeringen. Vad du bör bry dig om är slutresultatet av den offentliga metoden som anropar till den privata.

Överväg följande fall:

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

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

Din första reaktion kan vara att börja skriva ett test för TrimInput eftersom du vill se till att metoden fungerar som förväntat. Det är dock fullt möjligt att ParseLogLine manipulera på sanitizedInput ett sådant sätt att du inte förväntar dig, vilket gör ett test mot TrimInput värdelöst.

Det verkliga testet bör göras mot den offentliga metoden ParseLogLine eftersom det är vad du i slutändan bör bry dig om.

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

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

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

Med den här synpunkt hittar du den offentliga metoden om du ser en privat metod och skriver dina tester mot den metoden. Bara för att en privat metod returnerar det förväntade resultatet betyder det inte att systemet som så småningom anropar den privata metoden använder resultatet korrekt.

Stub statiska referenser

En av principerna för ett enhetstest är att det måste ha fullständig kontroll över systemet som testas. Den här principen kan vara problematisk när produktionskoden innehåller anrop till statiska referenser (till exempel DateTime.Now). Ta följande kod som exempel:

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

Hur kan den här koden eventuellt testas i enheten? Du kan prova en metod som:

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

Tyvärr kommer du snabbt att inse att det finns ett par problem med dina tester.

  • Om testsviten körs på en tisdag godkänns det andra testet, men det första testet misslyckas.
  • Om testpaketet körs någon annan dag godkänns det första testet, men det andra testet misslyckas.

För att lösa dessa problem måste du införa en söm i din produktionskod. En metod är att omsluta koden som du behöver styra i ett gränssnitt och låta produktionskoden vara beroende av det gränssnittet.

public interface IDateTimeProvider
{
    DayOfWeek DayOfWeek();
}

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

Din testsvit blir nu följande:

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

Nu har testpaketet fullständig kontroll över DateTime.Now och kan stub valfritt värde när du anropar metoden.