We hebben het boek "Secure by Design" afgerond, dus het wordt tijd voor een nieuw boek. Op advies van onze collega Leks gaan we aan de slag met Unit Testing, Principles, Practices and Patterns.
Want hoewel er wel testprojecten bij (sommige) van onze projecten gebouwd zijn, dat redelijk vaak test-scenario's zijn naar aanleiding van een storing, is dat niet de manier hoe je test-projecten wilt bouwen. Deze horen standaard bij je projecten ingebouwd te zijn, bij voorkeur ook voor deployment uitgevoerd te worden om te valideren of je werkende code naar dev/test/acceptatie of de live-omgeving zet.
De Timo-samenvatting
Dit is het introductie-hoofdstuk, dus nog niet heel veel concreets. De auteur wil met dit boek de lezer leren hoe je goede unit-testen herkent en deze zelf kunt maken. Verschillende metrieken voor "code-coverage", oftewel, welk deel van de code test ik, is een indicatie voor een slechte test-suite (er wordt te weinig getest), maar kan niet aangeven of je op een bepaald punt wel op een voldoende zit. Goede testen zitten in het ontwikkelproces (je zou ze na elke code-wijziging kunnen uitvoeren), raken de essentiële onderdelen van de code (de business-rules) en niet alle niet heel relevante onderdelen en zijn duurzaam/goed onderhoudbaar. Als de klant wijziging A wil en uitbreiding R heeft dat weinig tot geen invloed op de testen, deze hoeven niet volledig omgebouwd te worden om de testbaarheid in stand te houden.
Het eerste hoofdstuk begint met het algemene verhaal, het doel van unit testing. Hoe mooi is het niet dat je unit-testen hebt die bijna geruisloos mee ontwikkelen tijdens het bouwen van nieuwe klantwensen, waar niet al teveel inspanning in gestoken hoeft te worden en je een maximaal voordeel geven (goede testen, minder/geen bugs). Maar er zijn ook test-projecten waar je meer hoofdpijn van krijgt dan dat het je helpt. Dit boek wil ons helpen inzicht te geven in wat goede en slechte test-technieken zijn. Hoe je de analyse kunt uitvoeren om te vergelijken wat een test kost en wat het oplevert en hoe je goede test-technieken kunt toepassen in specifieke situaties. En ook hoe je anti-patterns kunt voorkomen. Dat zijn stukken code die eerst goed lijken, maar later je heel veel werk gaan opleveren.
Huidige status van Unit Testing
In de afgelopen 2 decennia is duidelijk geworden dat Unit Testing onderdeel moet uitmaken van je project. Door alle ontwikkelingen is de vraag "zouden we unit testen" niet meer relevant, maar wel steeds meer de vraag "hoe schrijven we goede unit testen?".
Het doel van Unit Testing
Vaak wordt gezegd dat unit testing leidt tot een beter ontwerp van de software. Maar dat is niet het doel van unit testen, het is wel vaak een prettig bij-effect. Met unit-testing kan vaak gecontroleerd worden of je programma-code "van slechte kwaliteit" is. Is het moeilijk te testen, doordat er allemaal zaken met elkaar verweven zijn/er afhankelijkheden zijn? Andersom hoeft het niet zo te zijn dat als je goede unit tests kunt maken de code ook goed is. Het kan wel goed "decoupled" zijn, maar de code kan nog steeds foute dingen doen. Het doel van UT is om duurzame code te bouwen die kan groeien. We zien een grafiek waarin getoond wordt hoe een systeem zonder tests groeit, in de tijd steeds langzamer, omdat er afhankelijkheden een aanpassingen uitgevoerd moeten worden. Met tests kun je controleren of eerdere functionaliteit behouden blijft. Het punt waarop de boel stagneert wordt de "software entropie" genoemd (de hoeveelheid chaos in het systeem). Elke keer als je wat aanpast maak je de chaos groter. Als je niet refactort, code opschoont wordt het een bende, het oplossen van 1 bug levert 3 andere op, het aanpassen van 1 functie laat de werking op 2 andere plekken kapot gaan, de code wordt onbetrouwbaar en je kunt niet terugkeren naar het eerdere stabiele systeem. Duurzaamheid en schaalbaarheid zijn dus de sleutelwoorden van unit testing.
Wat maakt een test goed of slecht?
Hoeveel een test kost is afhankelijk van een aantal factoren;
- als je de onderliggende code refactort, wat moet je allemaal refactoren aan de test?
- bij elke code-wijziging de test uitvoeren
- omgaan met "vals alarm"-meldingen van de test
- de tijd die je nodig hebt om de resultaten door te lezen om te begrijpen hoe de onderliggende code werkt
In dit geval is het dus niet "meer tests = beter", maar "hou alleen de hoge kwaliteit-tests die extra waarde geven aan je project in stand".
Gebruik dekkings-metrieken om de kwaliteit van tests te meten (coverage metrics)
Er zijn meerdere dekkings-metrieken, de auteur benoemt er 2, "code coverage" en "branche coverage". Met een dekkings-metriek kun je het percentage zien van de code die getest wordt. Dat kan van 0% tot 100% gaan. Ook hier geldt, een laag dekkings-percentage geeft aan dat de test niet goed is, maar een hoog dekkings-percentage betekent niet automatisch dat de tests supergoed zijn. Als je de meest essentiële code niet test, kun je wel alle overige code testen en dus een hoge score hebben, maar je test is onvolledig.
Code coverage - Test coverage
Hoeveel regels van je code worden getest ten opzichte van alle code-regels?
dekking = aantal coderegels uitgevoerd / totaal aantal coderegels
We zien het voorbeeld van een functie IsStringLong die controleert of de string die als input geleverd wordt langer dan 5 tekens is. De test wordt uitgevoerd met IsStringLong("abc").
De functie is 5 regels lang (inclusief de haakjes) en met de true/false check erin voer je 4 regels uit, 80% dekking dus. Vervolgens wordt de functie wat aangepast waardoor er maar 3 regels zijn en je altijd alle 3 regels uitvoert. Nu heb je 100% dekking. Maar is de code beter? Nee, want eigenlijk gebeurt er nog exact hetzelfde. Met wat shuffelen van de code kun je dus de boel er beter uit laten zien.
Branche coverage
Om het voorgaande (beetje husselen met de code, betere resultaten) beter te beoordelen is er branche coverage. Je kijkt niet meer naar de coderegels, maar naar de dekking van de if / switch statements.
dekking = aantal branches (kruisingen) doorlopen / totaal aantal branches (kruisingen)
Bij het in de praktijk brengen bij ons voorbeeld, je hebt daar een "true" of "false" keuze en omdat er maar 1 van de 2 opties getest wordt heb je een dekking van 50%. Hoe je de code zelf ook korter of anders maakt.
Problemen met dekkings-metrieken
Hoewel de laatste check beter is/lijkt dan de eerste kun je nog steeds niets zeggen over de kwaliteit van de tests. Je weet niet of je alle scenario's wel test en als je externe bibliotheken gebruikt is niet zichtbaar welke keuzes daar gemaakt worden.
We zien weer het voorbeeld van de IsStringLog waarbij alleen de 'false'-optie getest wordt. We zien de aanroep van int.Parse("5"), waarbij je een dekking van 100% lijkt te hebben. Maar dat is natuurlijk niet zo. Die functie kan aangeroepen worden met een null waarde, een lege string, met een niet numerieke waarde, dus je hebt in dat geval maar 1 van de 4 paden getest. Het is een voorbeeld om te laten zien hoe berekeningen onvolledig kunnen zijn, want externe bibliotheken testen wordt ook niet aangeraden.
Vast houden aan "we moeten 90% dekking hebben" of een ander getal is niet aan te raden. Mensen gaan om systemen heen bouwen om dat te halen, waarmee je het doel voorbij schiet. Een laag percentage toont problemen aan, maar een hoog percentage dus niet dat het nu wel OK is.
Hoe ga je jouw test-suite dan wel beoordelen?
We hebben gezien wat niet goede indicatoren zijn om de tests te beoordelen. De enige manier is om elke test stuk voor stuk te beoordelen. Volgens de auteur zijn dit zaken waarmee je tests laten zien dat ze goed zijn:
- ze zijn geïntegeerd in het ontwikkel-proces
- het raakt alleen de meest belangrijke onderdelen van je code
- het geeft je veel vertrouwen/inzicht in de code zonder dat je er heel veel voor hoeft te doen
Eigenlijk zou je bij elke code-wijziging de tests moeten uitvoeren, ook bij kleine wijzigingen. DH: als regel aanhouden dat je het sowieso voor een commit doet?
In de meeste applicaties zit het belangrijkste deel in de code waar business logica (het domein model) uitgevoerd wordt. Het testen van die zaken levert je het meeste op. De andere code kan onderverdeeld worden in
- infrastructuur code
- externe diensten en afhankelijkheden, zoals databases en API's van leveranciers
- code wat de boel aan elkaar plakt
Dit zijn de zaken waar je niet zoveel testen op uit zult voeren. Maar ook hier is het afhankelijk van de implementatie. Zit er in de infrastructuurcode een bepaald algoritme verwerkt? Dan is het wel zaak om die te testen.
Om te zorgen dat je het domein-model test (en kunt testen) moet je wel zorgen dat het domein -model geïsoleerd van de andere code te benaderen is. Als daar zaken met HttpContext in verweven zit, dan zou je eerst in je stand-alone test om te beginnen een Http-Object voor moeten aanmaken en hoewel bepaalde mocking-frameworks die mogelijkheid hebben, zijn dat zaken die je niet moet willen.