Unit-testen

Realisatie

Unit-testen is het geautomatiseerd testen van een deel van je software in een gecontroleerde omgeving, met als doel te bepalen of dit deel van de software correct werkt, en correct omgaat gaat fouten. Vooral het correct omgaan met fouten is het primaire doel van unit-testen omdat het al dan niet correct werken meestal vrij goed zichtbaar is en meestal door de software engineer zelf opgemerkt wordt. Wanneer mijn software niet voldoet aan de wensen van de klant, dan merken we dat waarschijnlijk vrij vlug. Het punt zit hem vaak in wat de software doet wanneer er onverwachte situaties ontstaan (uitzonderingen en/of fouten). Het zou slecht zijn als mijn software dan crasht en zichzelf afsluit. Het zou nog veel slechter zijn als mijn software gewoon doorgaat alsof er niets aan de hand is en vervolgens alle gegevens corrupt maakt of verkeerd verwerkt. En het zou nog veel slechter zijn als de software de gegevens corrupt maakt, verkeerd verwerkt en vervolgens aan de gebruiker meldt dat alles goed gegaan is. Nu lach je waarschijnlijk een beetje, maar helaas komt dit laatste nog steeds erg vaak voor. Software die crasht, doet dit door een bug. Dit is vervelend maar met een goede log en een debugger is dit relatief gemakkelijk op te lossen. En, omdat de gegevens niet corrupt zijn, is het herstel meestal ook relatief gemakkelijk. Uiteraard is het beter als software altijd bugvrij is, maar ik denk dat dit een utopie is. Echter, software die niet crasht maar doorgaat en de gegevens corrupt maakt, doet dit doordat de software-engineer een beetje dom of lui geweest is. Het is namelijk prima mogelijk om te testen wat de software doet in geval van uitzonderingen en fouten, daar is unit-testen namelijk voor.

Dit artikel gaat niet over hoe je unit-tests schrijft, dit is namelijk per programmeertaal een beetje anders en er zijn al een heleboel artikelen die daar over gaan. Dit artikel gaat over de gedachten achter het unit-testen, het doel en het begrip.

Wat is een unit?

Een unit in unit-testen is een deel van de software dat als eenheid kan functioneren. Op het laagste niveau is dit misschien 1 klasse, en deze klasse wordt los getest. Hoewel dit meestal erg triviale tests oplevert, is het niet ondenkbaar dat een bepaalde operatie in de klasse van dusdanige complexiteit is dat deze een eigen unit-test rechtvaardigt. Wat vaker komt het voor dat als unit bijvoorbeeld een package van de software gekozen wordt. Hiermee bedoel ik een groep klassen die samen 1 probleem adresseren. Soms heeft de applicatie die getest wordt maar 1 package, omdat het feitelijk maar 1 probleem adresseert, en dan is de unit dus direct ook de gehele applicatie, maar het komt ook voor dat de applicatie uit meerdere packages en componenten bestaat die samenwerken. In zo'n geval is het mogelijk om andere units te definiëren, die uit meerdere packages en/of componenten bestaan. Op het hoogste niveau bestaat een informatiesysteem uit verschillende applicaties die samenwerken, wellicht vanaf verschillende machines, en elke applicatie bestaat uit verschillende componenten met elk weer meerdere packages met meerdere klassen. Op het moment dat je een unit definieert (en dusdanig test) die over de grens van een component gaat, dan wordt een dergelijke unit-test ook wel een integratietest genoemd. De focus van het testen verschuift dan van de interne werking van de component op zich, naar de samenwerking tussen de componenten. Geef bij een unit-test altijd duidelijk aan wat het is dat je test. Veel unit-test-frameworks helpen je hierbij door een bepaalde naamgevingsconventie af te dwingen, maar ben je altijd bewust van het feit dat je beter een lange omschrijvende naam voor je test kan hanteren dan een korte onduidelijke naam. De code van de unit-tests komt nooit in de uiteindelijke software terecht dus het maakt helemaal niet uit hoe je de tests noemt. Ben duidelijk en eenduidig.

Happy flow & huftertests

Zoals in de inleiding van dit artikel al omschreven staat kun je met unit-testen uiteraard de 'happy flow' van je applicatie testen. Hiermee wordt bedoeld dat je de correct werking test: "Doet mijn applicatie wat er verwacht wordt?". Dit is geen verloren tijd en ik adviseer dan ook om deze tests zeker te maken. Echter, hierbij mag het nooit blijven, want het primaire doel van unit-testen is niet om de correcte werking te testen, maar juist om te testen of de applicatie correct omgaat met uitzonderingen en fouten. Het is dus vele malen belangrijker dat je unit-tests schrijft die bewust proberen de applicatie stuk te maken. Noem het maar de 'huftertest'. Een unit-test die netjes alle vakjes correct invult en dan de invoer bevestigt is zinvol, maar een test die bewust alles verkeerd invult of leeg laat en dan de invoer bevestigt is vele malen zinvoller omdat daar de kans op bugs veel hoger is.

Mocking met stubs en drivers

Wanneer je een unit definieert die over de grenzen van een component gaat (integratietest) dan test je eigenlijk de koppeling tussen deze componenten. Je zou dit kunnen doen door de combinatie van componenten als 1 geheel te zien en dat geheel te bevragen met unit-testen. Hieronder een voorbeeld van deze constructie waarin component A operaties in component B aanroept. Het geheel is samengenomen en het rode kader geeft aan wat er met deze unit-test getest wordt. De tests roepen operaties in component A aan, welke op hun beurt operaties in component B aanroepen. De test controleert daarna of het van component A de juiste reacties krijgt.

full fig 1 Twee componenten die samen als 1 unit opereren voor de tests.

Het nadeel van bovenstaande aanpak is dat wanneer er iets mis gaat, het niet duidelijk is of dat component A de fout gemaakt heeft en/of dat component B de fout gemaakt heeft. De twee componenten samen functioneren als een eenheid en de eenheid functioneert of geheel correct of niet. Daarom is dit niet zo'n heel handige aanpak om de integratietest te doen en bestaat er een alternatief. In dit alternatief maak je twee verschillende tests, eentje voor elk component, waarin dat component communiceert met een mock. Een mock is een vereenvoudigde simulatie van de werking van de andere component, welke uitsluitend gebruikt wordt in de testopstelling. De aanname is dan dat de mock altijd correct functioneert en de component die op dat moment met de mock samenwerkt getest wordt. Indien er in deze opstelling dan een fout ontstaat, dan is het altijd de component (dus niet de mock) die de fout gemaakt heeft. In ons voorbeeld hebben we dan twee soorten mocks nodig, een eerste die component A simuleert en de operaties van component B aanroept, dit noemen we een driver, en een tweede die component B simuleert en waarop door component A operaties aangeroepen worden, dit noemen we een stub.

full fig 2 Component B wordt getest door gebruik te maken van een mock voor component A, de mock is hier een driver.

full fig 3 Component A wordt getest door gebruik te maken van een mock voor component B, de mock is hier een stub.

Het idee van bovenstaande constructie is dat wanneer beide unit-tests nu succesvol zijn, dan functioneert de communicatie tussen component A en component B correct. Mocht er echter iets mis gaan dan weet je in welke van de twee componenten de fout zit want deze is geïsoleerd. Uiteraard dien je de mocks zo eenvoudig te maken dat deze foutloos zijn anders is de test nog steeds zinloos.

Merk op dat de figuren hierboven geen klassendiagrammen zijn, mocht je willen weten hoe je mocks kunt toepassen, kijk dan vooral ook even in naar het hoofdstuk Depencency Inversion in het artikel over SOLID, waarin een Data-package vervangen wordt door een alternatief Data-package, bijvoorbeeld een mock.

Wil ik 100% codedekking?

Een illusie die leeft rond unit-testen is dat 100% codedekking belangrijk is. Codedekking is een percentage dat aangeeft hoeveel van je programmacode afgedekt wordt door unit-tests. Natuurlijk is het goed om te weten hoeveel procent dit is, maar het is veel belangrijker om te weten wat de delen zijn die je nu niet afgedekt hebt met unit-tests. In veel gevallen is dit namelijk een prima te onderbouwen, bewuste keuze en zou het schrijven van een unit-test voor deze delen alleen tijdverspilling zijn. De meeste codedekking-tools geven naast een percentage ook de regels programmacode die niet gedekt worden weer in een andere kleur (bijvoorbeeld rood) zodat je een overwogen beslissing kunt maken of die specifieke regels ongetest kunnen blijven. Bij dingen als getters/setters en contructoren zonder parameters en dergelijken kan ik me prima voorstellen dat je deze bewust niet test, omdat de kans dat hier een bug in zit bijzonder klein is. Anderzijds is het natuurlijk onverstandig om een kernactiviteit van de applicatie ongetest te laten en wanneer je opmerkt dat een dergelijke regel programmacode door de codedekking-tool gemarkeerd wordt, dan kun je je afvragen of het niet verstandiger is om daar alsnog een unit-test voor te schrijven. Het doel is derhalve niet om altijd 100% te halen, maar juist het percentage dat je met een goed verhaal kunt onderbouwen.

Conclusie

Unit-testen is het op geautomatiseerde manier testen van je eigen programmacode. Een unit kan op vele manieren gedefinieerd worden, bijvoorbeeld zo klein als 1 klasse, maar ook groter over meerdere klassen/packages/componenten die samenwerken. Wanneer je de componentgrens overschrijd spreken we ook wel van integratietests, waarbij de focus van het testen ligt op de samenwerking tussen de componenten. Het primaire doel van unit-testing is bevestigen dat de applicatie correct omgaat met uitzonderingen en fouten en niet per se of de applicatie correct werkt. Een codedekking-tool helpt je in het opsporen van regels programmacode die getest dienen te worden, of juist niet. Onderbouw deze keuze altijd in je documentatie.