Naar kennisoverzicht

Het rendement van simpelere unit tests

Software is nog nooit zo belangrijk geweest voor ondernemingen als dat het vandaag de dag is. Of het nu gaat om het MKB of multinationals, elk bedrijf is wel afhankelijk van 1 of meerdere softwarepakketten. Daarom is het belangrijker dan ooit dat software werkt zoals het bedoeld is en meegroeit met de wensen en noden van de klant.

Geautomatiseerd testen

Een veelgebruikte methode om ontwikkelteams te helpen met het grip houden op kwaliteit, is geautomatiseerd testen. Deze kunnen onderverdeeld worden in 3 'lagen', van eenvoudig tot complex: Unit tests, integratie tests en end-to-end tests. Unit tests zijn bedoeld om componenten in de code te testen, integratie tests om de integratie tussen verschillende infrastructuur componenten te testen (zoals de applicatie en de database) en end-to-end tests om de hele keten van interface tot backend onder de loep te nemen.

Door regelmatig geautomatiseerde tests uit te voeren kan een ontwikkelteam tijdig op de hoogte gebracht worden van onverwacht gedrag van de software. Het oplossen van problemen voordat de software bij de klant terechtkomt is vaak vele malen eenvoudiger en levert geen problemen op bij productiesystemen en dus ook geen problemen in de relatie met de klant.

Unit tests

Met de meest eenvoudige tests, de unit test, wordt gekeken of het gedrag van een component in de code inderdaad is zoals verwacht wordt. Deze zijn vaak het makkelijkst te schrijven en kunnen in veel gevallen eenvoudig uitgevoerd worden door ontwikkelplatforms als Azure DevOps of GitHub.

Het risico met unit tests is om deze te complex te maken, waardoor ze veel tijd kosten om te onderhouden en uit te voeren. Complexe unit tests kunnen ook resulteren in onduidelijke signalen naar het ontwikkelteam toe. Dit kost meer manuren om te zien waar eventuele problemen zitten en wat er gedaan moet worden om deze op te lossen.

Alleen de unit tests anders schrijven zal niet genoeg zijn om dergelijke complexiteit in de kiem te smoren. In veel gevallen zal het ook nodig zijn de structuur van de applicatie aan te passen, zodat deze goed te testen is. In de praktijk blijkt dat het resultaat hiervan een applicatie is die de SOLID principes navolgt, modulair opgezet is en gemakkelijk aan te passen is.

Hieronder volgen een aantal richtlijnen die teams kunnen helpen om unit tests eenvoudig te houden, zodat deze snel blijven, makkelijk te onderhouden zijn en duidelijke signalen geven aan ontwikkelteams.

Minder code per test

Hoe meer code een unit test raakt, hoe meer code een ontwikkelaar potentieel moet lezen als die test faalt. Daarom is het beter om meerdere kleine tests te hebben dan een kleiner aantal grote tests. Zo is het duidelijker waar een probleem te vinden is wanneer er tests falen.

Neem als voorbeeld onderstaande test method, welke een aantal methods van onze `Calculator` class test. Als deze test zou falen is het niet onmiddellijk duidelijk waar het probleem zich voordoet.

public void LargeTests()
{
    var sut = new Calculator();

    var add = sut.Add(5, 3);
    var sub = sut.Subtract(5, 3);
    var div = sut.Divide(10, 2);

    Assert.Equal(8, add);
    Assert.Equal(2, sub);
    Assert.Equal(5, div);
}

Dit probleem wordt groter naarmate de complexiteit van deze class groter wordt of als de aangeroepen methods in de implementatiecode delen waar een fout ontstaat. Als we voor elke method apart een of meerdere tests zouden schrijven wordt het beeld al veel sneller duidelijk, nog voordat een ontwikkelaar de code hoeft te openen.

public void AddTest()
{
    var sut = new Calculator();
    var add = sut.Add(5, 3);
    Assert.Equal(8, add);
}

public void SubtractTest()
{
    var sut = new Calculator();
    var sub = sut.Subtract(5, 3);
    Assert.Equal(2, sub);
}

public void DivideTest()
{
    var sut = new Calculator();
    var div = sut.Divide(10, 2);
    Assert.Equal(5, div);
}

 

Op deze manier kan een ontwikkelaar naar de testresultaten kijken en bij voorbaat al weten welke methods het probleem vertonen. Dit kan veel tijd schelen, vooral naarmate de geteste class complexer wordt, omdat de testresultaten veel meer granulaire informatie verschaffen. Bij complexere methods kunnen verschillende gedragingen met meerdere kleine tests afgedekt worden. Kleinere tests zijn ook eenvoudiger te begrijpen, wat bijdraagt bij het onderzoeken van onverwacht gedrag.

Minder componenten per test

Hoe meer componenten er bij een unit test betrokken zijn, des te complexer het is om te begrijpen hoe deze met elkaar interacteren en waarom dit bij een falende test niet zoals verwacht loopt. Daarom is het wenselijk dat een unit test slechts bij een enkel component betrokken is of alleen bij een specifieke interactie tussen twee componenten.

Vaak hebben componenten echter een afhankelijkheid van meerdere andere componenten, waardoor het lastig kan zijn de test te beperken tot een enkele interactie. In het onderstaande voorbeeld wordt het gedrag van de `BuyCookies` method van de `CookieShop` class getest. Omdat de class meerdere lagen van afhankelijkheden heeft, moeten deze allemaal geïnstantieerd en geconfigureerd worden.

 

[Theory]
[InlineData(10, true)]
public void ManyDependencies(int amount, bool chocolate)
{
    var oven = new Oven();
    var chocolateRepo = new ChocolateRepository();
    var doughRepo = new DoughRepository();
    var bakery = new Bakery(doughRepo, chocolateRepo, oven);
    var sut = new CookieShop(bakery);

    oven.Preheat (180);
    chocolateRepo.PureChocolate = false;
    doughRepo.AddDough(new Dough { Weight = amount*sut.CookieSize });

    var cookies = sut.BuyCookies(amount, chocolate);

    Assert.Equal(amount, cookies.Count);
    Assert.True(cookies.All(c => c.Size == sut.CookieSize));
            Assert.True(cookies.All(c => c.Chocolate == chocolate));
            Assert.True(cookies.All(c => c.Sweet == true));
}

 

Hoewel een dergelijke opzet mogelijk is, is het resultaat een relatief broze unit test. Het initiëren van de afhankelijkheden is nu integraal onderdeel van de test geworden, ook al was de intentie om slechts het gedrag van `BuyCookies` te testen. Hier komt bij dat het aanroepen van de `BuyCookies` method mogelijkerwijs de code van deze onderliggende elementen raakt, waardoor het lastiger te bepalen is in welk component een fout is opgetreden als deze test niet slaagt.

Tenslotte kost een dergelijke opzet veel meer tijd om te onderhouden, aangezien een wijziging in elk van de afhankelijkheden van `CookieShop` kan resulteren in het falen van deze test. Hierdoor moet elke unit tests voor dit component met deze opzet aangepast worden.

De oplossing is om gebruik te maken van zogeheten 'Mocks'. Dit is een vervangende implementatie die voor de unit test specifiek gewenst gedrag kan vertonen. Zo kan de unit test zich richten op het component of de interactie die getest wordt.

 

[Theory]
[InlineData(10, true)]
public void SimpleDependency(int amount, bool chocolate)
{
    var bakeryMock = NSubstitute.Substitute.For<IBakery>();
    var sut = new CookieShop(bakeryMock);

    bakeryMock.BakeCookie(sut.CookieSize, chocolate)
            .Returns(a => new Cookie
{ Chocolate = chocolate, Size = sut.CookieSize, Sweet = true });

    var cookies = sut.BuyCookies(10, true);

    Assert.Equal(amount, cookies.Count);
    Assert.True(cookies.All(c => c.Size == sut.CookieSize));
    Assert.True(cookies.All(c => c.Chocolate == chocolate));
    Assert.True(cookies.All(c => c.Sweet == true));
}

 

Niet alleen is deze test nu korter, het resultaat van deze test is zuiver (afhankelijk van de code in de `CookieShop` class) waardoor het falen van deze test een eenduidiger signaal geeft. Er wordt geen code meer geraakt van diens afhankelijkheden, omdat deze met een mock vervangen zijn waar het gedrag van de unit test door wordt bepaald.

Hierdoor hebben wijzigingen in de afhankelijkheden van de `CookieShop` geen invloed meer op het resultaat van deze unit test. Waar het wenselijk is om de interactie tussen componenten te testen, kan dit alsnog in separate unit tests afgevangen worden, die op eenzelfde manier geïsoleerd kunnen worden van hun eigen afhankelijkheden.

Dit helpt ook bij tests die al dan niet bedoeld de grens over gaan naar integratie tests, zodat een test bedoeld voor een component die interacteert met infrastructuur, zoals bijvoorbeeld een database, van die afhankelijkheid gescheiden kan worden.

Voor een dergelijke opzet is het wel nodig dat het Dependency Inversion principe uit SOLID gehandhaafd wordt. Dit stelt dat we afhankelijkheden van abstracties willen, in plaats van implementaties. Is dit nog niet overal het geval? Dan is het schrijven van een dergelijke unit test een mooie aanleiding om dit voor het te testen component toe te passen.

Dit illustreert maar weer hoe het verbeteren van de unit tests noodzakelijkerwijs leidt tot verbeteringen in de structuur van de applicatie zelf.

Selectiever tests uitvoeren

Eerder werd genoemd hoe unit tests separaat zijn van integratie of end-to-end tests. Toch is het in de praktijk makkelijk om over deze lijn heen te stappen. Zo is het bijvoorbeeld mogelijk om in .NET via de unit tests een container op te starten met een database server en via unit tests met deze database te communiceren.

Hoewel hier zeker een plaats voor is in het landschap van geautomatiseerd testen, valt dit beslist buiten het domein van unit tests. Omdat integratie tests vaak een langere doorlooptijd hebben (en end-to-end tests nog weer langer), is het verstandig om een scheiding aan te brengen tussen de verschillende typen van tests, zodat het mogelijk is te kiezen wanneer welk type uitgevoerd wordt.

Bij .NET is het bijvoorbeeld mogelijk om alle tests waar IntegrationTest in voorkomt over te slaan door het volgende commando te gebruiken.

dotnet test --filter FullyQualifiedName\!~IntegrationTest

Zo kan ervoor gekozen worden om bijvoorbeeld alleen unit tests uit te voeren als voorwaarde voor integratie bij het aanmaken van een pull-request, maar wel alle tests uit te voeren bij het uitrollen naar een test omgeving. Door het toepassen van de overige richtlijnen kan dan tot een situatie gekomen worden waar de unit tests voldoende zekerheid geven over het functioneren van de code en waar de integratie tests dan gebruikt kunnen worden voor integratie met een database of andere infrastructuur.

Wanneer elk component in isolatie getest kan worden blijft er veel minder functionaliteit over die door integratietests afgedekt moet worden, wat onder andere de doorlooptijd ten goede komt.

Conclusie

Het gebruik van unit tests is bij de meeste ontwikkelteams al heel gebruikelijk, maar met bovenstaande richtlijnen kan er vaak nog veel winst gemaakt worden als het gaat om de simpliciteit van deze tests. Simpelere tests zijn over het algemeen sneller om uit te voeren en zijn voor ontwikkelaars makkelijker te doorgronden, waardoor een falende test niet langer een speurtocht betekent, maar een helder signaal afgeeft over welke functionaliteit zich niet naar verwachting gedraagt.

Zo dragen simpelere unit tests bij aan een kortere ontwikkelcyclus, zodat er meer tijd beschikbaar is voor nieuwe functionaliteit in plaats van het proberen op te lossen van problemen.