Categorie > Programmeren

Effectief unittests schrijven

geschreven door Wilco van Dijk


In dit artikel zal ik kort uitleggen wat unittests zijn, waarom je unittests in je projecten zou moeten opnemen en welke richtlijnen ik zelf hanteer voor het opzetten van unittests. De richtlijnen zal ik zo veel mogelijk met voorbeelden toelichten. Bij het schrijven van unittests voor mijn .Net-projecten maak ik gebruik van het het MSTest-framework.

Wat is unittesten?

Unittesten is een softwaretestmethode waarbij de kleinste stukjes code, de zogenaamde "units", geïsoleerd worden getest om te verifiëren dat ze correct functioneren. Deze units kunnen methoden, functies of klassen zijn.

Unittests worden geschreven door ontwikkelaars en helpen om bugs vroeg in het ontwikkelingsproces te identificeren en op te lossen, voordat ze zich kunnen verspreiden naar andere delen van de code. In het ideale geval zijn alle testcases onafhankelijk van andere tests. Eventueel worden hiertoe zogenaamde nepobjecten gemaakt om de unittests gescheiden uit te kunnen voeren. Unittests vormen de basis van een robuuste teststrategie en dragen bij aan de algehele kwaliteit en onderhoudbaarheid van de software. Unittest vormen de onderste laag van de testpyramide.

Testpyramide

De testpiramide is een concept dat wordt gebruikt in softwareontwikkeling om te helpen bij het organiseren en prioriteren van verschillende soorten tests.

Het idee is om een solide basis te leggen door meer tests op lagere niveaus uit te voeren en minder tests op hogere niveaus. Dit zorgt voor een efficiënter en effectiever testproces. Hoe hoger het niveau, hoe hoger de kosten om te realiseren.

Waarom zou je unittests in je projecten moeten gebruiken?

Er zijn tal van voordelen die unittests aan onze projecten bieden:

  • Codekwaliteit: Unittests stellen je in staat om potentiële bugs vroegtijdig op te sporen. Hierdoor kunnen je bugs oplossen en ervoor zorgen dat de software van hoge kwaliteit is en zo efficiënt mogelijk draait.

  • Goede documentatie: Unittests zijn een uitstekende tool om code te documenteren. Ze helpen anderen om de code te begrijpen. De tests laten duidelijk zien hoe de code gebruikt wordt en wat de verwachte resultaten zijn.

  • Vroegtijdige bugdetectie: Met unittests kunnen we potentiële bugs opsporen tijdens het ontwikkelproces. Op deze manier kunnen we bugs oplossen voordat ze de productieomgeving bereiken en de productiecode negatief beïnvloeden.

  • Coderefactoring: Wanneer we de code refactoren, kunnen we met unittests controleren of de algoritmen nog steeds werken zoals ze zouden moeten.

  • Kostenbesparing: Unittests verlagen de kosten van het softwareontwikkelingsproces, omdat ze ons in staat stellen om bugs in de code te repareren voordat de software wordt geïmplementeerd.

  • Agile proces: Dit is het belangrijkste voordeel van unittests. Wanneer ik een algoritme verander of een nieuwe functie toevoeg, fungeren de unittests van de software als een vangnet, dat ervoor zorgt dat de bestaande functionaliteit niet negatief wordt beïnvloed door deze wijzigingen. Je verbetert ook continu de kwaliteit van je code door bij het oplossen van een bug een test te schrijven om te zorgen dat het probleem in de toekomst niet meer kan voorkomen.

Richtlijnen

Een goede unittest moet leesbaar, geïsoleerd, betrouwbaar, eenvoudig en snel zijn.

  1. Naamgeving

    De naam van de unittest moet duidelijk het doel van de test beschrijven. We moeten het gedrag van de methode kunnen afleiden uit de naam van de methode zonder naar de code zelf te kijken. Dit ondersteunt documentatie en leesbaarheid. Ook kunnen we, wanneer tests falen, detecteren welke scenario's niet correct worden uitgevoerd. De naam van de unittest moet drie informatie-elementen bevatten: de naam van de geteste methode, het testscenario en het verwachte gedrag.

    MethodeNaam_StaatVanDeTest_VerwachtGedrag

    public bool IsEven(int number) 
    { 
        // Een getal is even als het deelbaar is door 2 zonder rest
        return number % 2 == 0; 
    } 
    
    [TestMethod]
    
    public void IsEven_WhenNumberIsEven_ReturnsTrue()
    {
        int number = 2;
        bool expected = true;
    
        var actual = IsEven(number);
    
        Assert.AreEqual(expected, actual);
    }
  2. Maak gebruik van het Arrange, Act and Assert (AAA) pattern

    Het AAA-pattern is een zeer belangrijk en veelgebruikt patroon voor unittests. Het zorgt voor een duidelijke scheiding tussen de opstelling van testobjecten, acties en resultaten. Het verdeelt unittestmethoden in drie delen: Arrange, Act en Assert.

    • In Arrange creëren en stellen we de noodzakelijke objecten voor de test in.

    • In Act roepen we de te testen methode aan en verkrijgen we de werkelijke waarde.

    • In Assert controleren we de verwachte en werkelijke waarde. Aannames bepalen of de tests falen of slagen. Als de verwachte waarde en de werkelijke waarde gelijk zijn, is de test geslaagd.

    [TestMethod]
    
    public void IsEven_WhenNumberIsEven_ReturnsTrue()
    {
        // Arrange
        int number = 2;
        bool expected = true;
    
        // Act
        var actual = IsEven(number);
    
        // Assert
        Assert.AreEqual(expected, actual);
    }
  3. Gebruik nepobjecten

    Een unittest moet de functionaliteit van een specifieke methode testen. Sommige methoden kunnen afhankelijkheden hebben met externe services zoals databases of webservices. We kunnen nepobjecten maken om de afhankelijkheden te simuleren. Door gebruik te maken van nepobjecten, kunnen we de te testen code isoleren en ons alleen focussen op het gedrag van de te testen eenheid. Bovendien zijn geïsoleerde unittests sneller uit te voeren.

    We gebruiken de Moq-bibliotheek voor het aanmaken van nepobjecten. Installeer de Moq-bibliotheek vanuit de NuGet Package Manager. Op het moment van schrijven adviseer ik om voor versie 4.18.4 te kiezen. In nieuwere versies is een beveiligingsrisico geïntroduceerd. Meer daarover kun je hier lezen.

    [TestMethod]
    public async Task GetAllOrderNumbers_ReturnsListOfIntegers()
    {
        // Arrange
        var mockOrderRepository = new Mock<IOrderRepository>();
        mockOrderRepository
            .Setup(x => x.GetAllOrders())
            .Returns(Task.FromResult(new List<Order>
            {
                new Order { Number = 1, Amount = 100 },
                new Order { Number = 2, Amount = 200 },
                new Order { Number = 3, Amount = 300 }
            }));
    
        var orderService = new OrderService(mockOrderRepository.Object);
    
        // Act
        var result = await orderService.GetAllOrderNumbers();
    
        // Assert
        Assert.IsNotNull(result);
        Assert.IsInstanceOfType(result, typeof(List<int>));
        Assert.AreEqual(3, result.Count);
    }
  4. Herhaling van code voorkomen

    Een unittest is bedoeld om een specifieke stuk functionaliteit te testen. Mocht je dezelfde stuk met meerdere parameters willen testen is er een manier om dat te doen, namelijk met het DataRow attribuut. De methode wordt nu meerdere keren gedraaid waarbij de waarden uit de DataRow worden gebruikt als input parameters.

    [TestMethod]
    [DataRow(1, "some string")]
    [DataRow(2, "some other string")]
    public void TestMethod(int id, string value) 
    { 
    }
  5. Test op output

    Bepaal wat de mogelijke resultaten zijn die een methode kan teruggeven. Dit kunnen ook exceptions zijn. Schrijf voor alle mogelijke scenario's een aparte test. Kijken we even terug naar het voorbeeld bij de eerste richtlijn, de methode voor bepaling een getal even is. We hebben al een test geschreven voor de methode "IsEven" om te controleren of, gegeven een even egtal, de juiste waarde wordt teruggegeven. Daarbij hoort een test die controleert dit voor een oneven getal ook zo is.

    [TestMethod]
    
    public void IsEven_WhenNumberIsOdd_ReturnsFalse()
    {
        int number = 1;
        bool expected = false;
    
        var actual = IsEven(number);
    
        Assert.AreEqual(expected, actual);
    }
  6. Testen op exceptions

    In het vorige punt gaf ik aan dat belangrijk is om ook te testen op exceptions. Schrijf tests voor exceptions die bewust vanuit de code worden gegooid. Het afvangen van een exception in een unittest kan op twee manieren.

    
    public static int Divide(int numerator, int denominator) 
    { 
        if (denominator == 0) 
        { 
            throw new DivideByZeroException("Denominator cannot be zero."); 
        } 
        return numerator / denominator; 
    }
    
    [TestMethod]
    public void Divide_WhenDenominatorIsZero_ReturnsDivideByZeroException()
    {
        // Arrange
        var numerator = 10;
        var denominator = 0;
    
        // Act and assert
        Assert.ThrowsException<DivideByZeroException>(() => Divide(numerator, denominator));
    }
    
    // Another way of testing exceptions
    
    [TestMethod]
    [ExpectedException(typeof(DivideByZeroException), "Divide throws wrong exception")]
    public void Divide_WhenDenominatorIsZero_ReturnsDivideByZeroException()
    {
        // Arrange
        var numerator = 10;
        var denominator = 0;
    
        // Act
        Divide(numerator, denominator);
    }
  7. Test op grenswaarden

    Schrijf tests die de invoerparameters testen op waarden die net binnen en net buiten de grenswaarden liggen. Houd rekening met de verschillende scenario's en randgevallen.

    public static bool IsAdult(DateTime dateOfBirth)
    {
        DateTime today = DateTime.Today; 
        int age = today.Year - dateOfBirth.Year; // Controleer of de verjaardag dit jaar al heeft plaatsgevonden
        if (dateOfBirth.Date > today.AddYears(-age)) 
        { 
            age--; 
        }
    
        return age >= 18;
    }
    
    [TestMethod]
    public async Task IsAdult_When18thBirthdayIsToday_ReturnsTrue()
    {
        // Arrange
        DateTime dateOfBirth = DateTime.Today.AddYears(-18); // Vandaag 18 geworden
    
        // Act
        bool isAdult = IsAdult(dateOfBirth);
    
        // Assert
        Assert.IsTrue(isAdult);
    }
    
    [TestMethod]
    public async Task IsAdult_When18thBirthdayIsTomorrow_ReturnsFalse()
    {
        // Arrange
        DateTime dateOfBirth = DateTime.Today.AddYears(-18).AddDays(1); // Wordt morgen 18
    
        // Act
        bool isAdult = IsAdult(dateOfBirth);
    
        // Assert
        Assert.IsFalse(isAdult);
    }
  8. Test lifecycle

    Soms wil je in een testproject dat een bepaalde initialisatie of opruimwerk maar eenmalig plaatsvindt voor alle testen. Bijvoorbeeld, je moet een globale configuratie instellen, of enkele bestanden verwijderen na een testrun. Dan is het handig om te weten waar je deze code kunt plaatsen. Dit kan overigens nuttiger zijn voor integratietests dan voor unittests.

    Als je code wilt plaatsen die tijdens een testrun maar eenmalig per assembly mag worden uitgevoerd dien je een aparte klasse te definiëren.

    [TestClass]
    public class Initialize
    {
        [AssemblyInitialize]
        public static void AssemblyInitialize(TestContext context)
        {
        }
    
        [AssemblyCleanup]
        public static void AssemblyCleanup()
        {
        }
    }

    Indien je code wilt plaatsen die tijdens een testrun maar eenmalig per klasse mag worden uitgevoerd, kun je aparte methoden opnemen met een specifiek attribuut.

    [TestClass]
    public class TestClass1
    {
        [ClassInitialize]
        public static void ClassInitialize(TestContext context)
        {
        }
    
        [ClassCleanup]
        public static void ClassCleanup()
        {
        }
    
        [TestMethod]
        public void Test1()
        {
        }
    }

    Indien je code wilt plaatsen die tijdens een testrun maar eenmalig per test mag worden uitgevoerd, kun je dat ook aangeven met een specifiek attribuut.

    [TestClass]
    public class TestClass1 : IDisposable
    {
        // 1. Called once before each test
        public TestClass1()
        {
        }
    
        //  2. Called once before each test after the constructor
        [TestInitialize]
        public void TestInitialize()
        {
        }
    
        [TestMethod]
        public void Test1()
        {
        }
    
        // 4. Called once after each test before the Dispose method
        [TestCleanup]
        public void TestCleanup()
        {
        }
    
        // 5. Called once after each test
        public void Dispose()
        {
        }
    }

    Als je een log maakt van alle aanroepen kun je goed zien in welke volgorde ze worden aangeroepen.

    AssemblyInitialize          (eenmalig per assembly)
      Class1Initialize          (eenmalig per klasse)
        Class1.Constructor      (voor elke test van de klasse)
          TestInitialize        (voor elke test van de klasse)
            Test1
          TestCleanup           (na elke test van de klasse)
        Class1.Dispose          (na elke test van de klasse)
    
        Class1.Constructor
          TestInitialize
            Test2
          TestCleanup
        Class1.Dispose
          ...
      Class2Initialize          (eenmalig per klasse)
          ...
      Class1Cleanup             (eenmalig per klasse)
      Class2Cleanup             (eenmalig per klasse)
    AssemblyCleanup             (eenmalig per assembly)

Het aantal test dat je moet schrijven hangt erg af van de complexiteit van de code. Maar door dit vanaf het begin gelijk goed op te zetten voor de belangrijkste stukken code, profiteer je naarmate de code uitbreidt en complexer wordt.

Als laatste zou ik nog willen adviseren om de unittests onderdeel te maken van de build wanner je gebruik maakt van geautomatiseerde build pipelines. Daarmee voorkom je dat er een applicatie wordt uitgerold waarvan de code niet door de test heen komt.

Heeft dit artikel jou op weg geholpen?  Buy Me A Coffee

Dependency Injection in .NET

Wilco van Dijk
Lees meer

Hoe werkt het laden van AWS credentials in .Net?

Wilco van Dijk
Lees meer