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