Ik ga niet nog een artikel schrijven over hoe je je tests moet schrijven. Ik ga ook niet in op alle details van de verschillende soorten testen. In plaats daarvan belicht ik een aantal valkuilen, waar je je bewust van moet zijn bij het schrijven van testen en hoe je die kunt vermijden. Dit is geen wetenschappelijke verhandeling; het is gewoon mijn mening. Wat kun je verwachten in dit artikel? Eerst bespreek ik waarom testen zo cruciaal is in softwareontwikkeling. Daarna ga ik in op een aantal risico’s en hoe je die voorkomt. Vervolgens bekijk ik “sociable testing” en het genereren van testdata.
Zal je iets nieuws leren door dit artikel te lezen? Misschien niet. Maar één doel heb ik wél: ik wil dat je nadenkt over hoe jij je testen aanpakt, zodat je als ontwikkelaar kunt groeien!
Over de afgelopen jaren heb ik cursussen gegeven, collega’s gecoacht en presentaties gehouden. Mijn focus? Kwaliteit! Kwaliteit draait niet enkel om testen, maar het is wel een belangrijke voorwaarde om kwalitatieve software te kunnen opleveren. Mensen die mijn presentaties volgen zeggen vaak: “Ik wist wat je bedoelde, maar ik deed het verkeerd zonder dat ik het doorhad.” Daarom hoop ik dat mijn gedachten je helpen om je eigen manier van werken onder de loep te nemen.
Waarom testen belangrijk is
Zoals ik al in de introductie zei: testen is een voorwaarde om kwaliteit te leveren. Ondanks dat elke ontwikkelaar dit weet, zie ik nog steeds dat testen lichtvoetig behandeld wordt in veel programmeercursussen. De meeste opleidingen behandelen testen slechts oppervlakkig: ze introduceren bibliotheken zoals JUnit, tonen enkele voorbeelden en gaan dan meteen verder met het volgende onderwerp. Alsof er niets meer te zeggen is over testen, toch?
Wanneer ik stagiairs begeleid of junior ontwikkelaars coach, zie ik vaak hetzelfde patroon: ze kennen de basis van hoe tests geschreven moeten worden, maar hebben geen idee wanneer ze een test moeten schrijven, of welke test.
Waarom is dit een probleem? Voor mij is dat vrij evident. Mijn testen zijn de enige manier om mezelf, mijn team én mijn klant te bewijzen dat de code doet wat ik wil. En ze garanderen dat bestaande functionaliteit blijft werken nadat je nieuwe features toevoegt of code herwerkt. Daarom hebben we testen nodig!
Valkuilen bij testen
Een uitgebreide testset is beter dan helemaal geen tests, maar niet alle tests zijn even goed. Er zijn een aantal valkuilen waar je alert op moet zijn om écht goede tests te schrijven.
Test first
Laten we eenvoudig beginnen! Je zou altijd eerst je test moeten schrijven, voordat je je implementatie schrijft. Doe je dat niet, dan loop je het risico dat je de verkeerde dingen aan het testen bent. We zijn allemaal mensen, dus fouten maken hoort erbij.
Stel je voor dat we een complexe berekening schrijven. We starten met onze eerste test, die uiteraard moet falen. Zodra we de code gaan implementeren, genieten we van het uitwerken van die berekening, en voor je het weet, schrijven we te veel code. Meer dan eigenlijk nodig is, want we zouden enkel de code moeten schrijven die nodig is om de test te laten slagen. En daar gaat het mis! We maken een fout in onze berekening, en zonder dat we het weten, introduceren we een nieuwe bug.
Daarna voegen we meer tests toe, om de volledige berekening af te dekken, inclusief elke edge case. Maar… als je naar je code kijkt om je tests te schrijven, loop je het risico te testen dat de bug bestaat in de code, alsof we het zo geïmplementeerd hadden.
En het kan nog erger! Wij zijn ontwikkelaars, dus we zijn lui. En als moderne ontwikkelaars gebruiken we natuurlijk een AI-assistent om ons werk te versnellen. Bijna elke “zelfrespecterende” AI-assistent heeft een functie die unit tests kan genereren op basis van een code snippet. Dus we vragen de AI vrolijk om tests te schrijven voor onze hele berekening. Waarom zouden wij zelf die tests schrijven, als de AI dat ook kan?
Test de juiste dingen!
Een ander probleem is dat we soms het verkeerde aan het testen zijn. Ik had ooit een collega die niet zo vertrouwd was met het schrijven van tests. Terwijl hij werkte aan een relatief eenvoudige feature, de klok aan de client-side synchroniseren met de serverklok, moest hij dat natuurlijk ook testen.
Hij had een simpele endpoint die de servertijd ophaalde, met behulp van LocalDateTime en de Clock-class. Hij zocht online hoe hij dit kon oplossen en uiteindelijke copy-paste hij de voorbeeldtest die hij online vond. De test draaide prima… maar wat testte hij eigenlijk? Niet zijn eigen code! Hij testte dat de Java-implementatie van de fixed clock werkte.
Er is nog iets dat ik wil aanhalen. Dat laatste probleem komt voort uit de manier waarop we onze specifieke tests schrijven. Iedereen zegt dat een unit test in isolatie moet draaien, zodat die snel en makkelijk te schrijven is. Klinkt goed, toch? Maar dit leidt tot een nare ziekte die “Mockitis” noemt. Mockitis is eenvoudig te diagnosticeren: als je een test schrijft en je overal doubles moet opzetten, dan ben je te veel aan het “mocken”. Het resultaat? Breekbare tests, die je code moeilijker te refactoren maken.
We weten allemaal dat refactoring zou moeten gaan over het verbeteren van de structuur van je code, zonder dat het gedrag verandert. In een ideale wereld zou je je code dus moeten kunnen refactoren zonder dat je tests breken. Wel… welkom in de echte wereld! Libraries zoals Mockito kunnen ontzettend nuttig zijn, maar als je ze onvoorzichtig inzet, vertragen ze je ontwikkeling. Natuurlijk hebben we soms mocks nodig in onze tests, maar wees je ervan bewust dat je tests daardoor afhankelijk kunnen worden van de specifieke implementatie.
Dus: als je tests kapotgaan omdat je aan het refactoren bent, weet dan dat je tests eigenlijk je technische implementatie aan het controleren waren, en niet het echte gedrag van je code.
De oplossing: Focus Shift
In veel publicaties lees je dat Test Driven Development (TDD) de oplossing is voor de meeste problemen rond testen. En eerlijk: dat is vaak een goed vertrekpunt. Maar als je de kwaliteit écht wil opkrikken, denk ik dat het niet helemaal voldoende is. Begrijp me niet verkeerd, ik zeg niet dat we TDD overboord moeten gooien, helemaal niet! Ik pleit er juist voor om er nog een extra laag bovenop te leggen.
TDD helpt ons om eerst na te denken over onze code vóór we ze schrijven. Maar het vertelt ons niet hoe we onze tests moeten schrijven, of welke tests we precies nodig hebben. In veel introducties tot TDD zie ik dat de focus bijna uitsluitend ligt op unit tests. Vaak zijn de voorbeelden zo eenvoudig dat je er niet eens de mist mee in kan gaan. Geen complexe problemen om op te lossen, geen nood om echt na te denken over architectuur. Nee, het enige wat je moet doen is de test schrijven voor je de code schrijft.
Laat er geen misverstand over bestaan: TDD is niet slecht. Integendeel. Het is juist heel waardevol om goed na te denken over wat je moet implementeren vóór je begint te coderen. En de gemakkelijkste manier om dat te doen, is door eerst een test te schrijven.
Maar… waar pleit ik dan wél voor? Ik denk dat we ons veel meer moeten gaan richten op het gedrag van onze code in plaats van op de implementatie zelf. Wat we écht willen weten, is dat onze code het juiste resultaat oplevert. Hoe die code precies tot dat resultaat komt, maakt me veel minder uit, want dat verandert hoogstwaarschijnlijk toch doorheen de tijd.
Focus op gedrag
De afgelopen jaren ben ik een enorme fan geworden van Behavior-Driven Development (BDD). Voor veel teams voelt BDD in eerste instantie als overkill. Maar geloof me: dat is het absoluut niet. Waarom denken zoveel teams er dan zo over? Als je snel online zoekt naar BDD, staat Cucumber vaak helemaal bovenaan. Cucumber is inderdaad een van de tools die BDD in softwareprojecten ondersteunt. Voor de duidelijkheid, je hoeft Cucumber (of eender welke tool) niet per se te gebruiken om van de voordelen van BDD te profiteren!
BDD is namelijk geen library of framework waar je van afhankelijk moet zijn. Nee, BDD is in de eerste plaats een manier van werken. Waar TDD zegt dat softwareontwikkeling gestuurd moet worden door tests, legt BDD de nadruk op het gewenste gedrag als belangrijkste drijfveer. Welke verwachtingen moeten we inlossen met de features die we implementeren? BDD zegt dus niet dat het “gedreven wordt door tests”. Het gebruikt tests als middel om te garanderen dat we het verwachte gedrag implementeren. De nadruk ligt veel meer op het exact realiseren van wat de business écht nodig heeft. De business-acceptatiecriteria vormen de basis voor onze (acceptatie)tests.
Aan de slag met BDD
BDD toepassen kan eigenlijk heel eenvoudig: je vertaalt gewoon alle acceptatiecriteria naar geautomatiseerde tests. Het maakt daarbij niet uit of je een ingebouwde tool zoals MockMVC gebruikt, of dat je de voorkeur geeft aan een gespecialiseerd framework zoals Cucumber. Dat is een keuze die elk team zelf moet maken. Heeft je team al ervaring met Cucumber? Dan zie ik geen enkele reden om het niet te gebruiken!
Deze libraries zijn er juist om de overstap naar BDD makkelijker te maken. Ze zorgen ervoor dat tests en testresultaten ook begrijpelijk zijn voor niet-technische stakeholders. En dat is belangrijk, want hoe beter zij begrijpen wat we doen als development team, hoe groter hun betrokkenheid bij het proces. Het resultaat: sneller en precies leveren wat de business van ons vraagt.
Door de stap te zetten naar een meer behior-driven manier van testen, schrijven we automatisch betere en veerkrachtigere tests, tests die de tand des tijds kunnen doorstaan. Gedragstests hangen namelijk niet vast aan een specifieke implementatie. Dus wanneer we beginnen te refactoren, zouden onze tests gewoon moeten blijven werken.
Upgrade je tests!
Moeten we echt BDD introduceren om robuuste tests te kunnen schrijven? Natuurlijk niet! Acceptatietests zijn vaak integratietesten die een volledige application context moeten opstarten. Dat kost tijd en maakt onze build traag. Uiteindelijk leidt dat er vaak toe dat deze tests worden overgeslagen in de vroege fases van de build… en dat is vragen om problemen.
Maar hoe kunnen we dan focussen op gedrag in plaats van op implementatie, zonder dat onze tests traag worden? Niets is sneller dan een unit test, en een unit test draait volledig in isolatie! Dat klopt, maar daar valt wel wat meer over te zeggen.
Wat is een “unit”?
Een unit test is een test voor een specifieke unit. Maar wat bedoelen we daar eigenlijk mee? Er bestaat geen universele definitie van wat een unit precies is. Voor sommige ontwikkelaars is een unit een enkele klasse, of zelfs één methode. Persoonlijk ben ik het daar niet meer mee eens. Een unit kan gerust groter zijn, bijvoorbeeld een volledige flow doorheen de applicatie, zolang we maar vermijden dat we afhankelijk worden van externe systemen of infrastructuur zoals een database.
Het feit dat we meerdere klassen integreren om zo’n flow te testen, betekent dus niet dat onze tests niet langer geïsoleerd zijn. Isolatie betekent dat elke test onafhankelijk draait van andere tests. Elke test maakt zijn eigen instantie aan van de unit die getest wordt, zonder afhankelijk te zijn van een toestand die door een andere test is gewijzigd.
Solitary tests
Martin Fowler schreef hierover een uitstekende blogpost [1], waarin hij twee soorten unit tests onderscheidt. De eerste soort is degene die we leren schrijven wanneer we net beginnen met programmeren: de solitary unit tests [2]. Een solitary test test meestal de methodes van één enkele klasse. Alle afhankelijkheden worden vervangen door testdubbels, zoals Mockito-mocks. Zo kunnen we de code van één specifieke klasse testen, zonder rekening te moeten houden met de implementatie van de klassen waarop ze steunt.
Zoals ik eerder al aanhaalde, kunnen solitary tests echter leiden tot Mockitis — iets wat we koste wat het kost willen vermijden. De enige gekende remedie tegen Mockitis? De solitary unit test inruilen voor een ander type unit test.
Sociable tests
Fowler beschrijft deze tweede type als de sociable unit test. Hierbij vertrouwt de te testen unit op haar afhankelijkheden, in plaats van ze te vervangen door testdubbels. Stel: ik test een service die op een andere, onafhankelijke service rekent om een complexe berekening uit te voeren. Dan hoef ik die tweede service niet te ‘mocken’. Ik kan gewoon mijn systeem en al zijn afhankelijkheden opzetten om het gedrag van de unit die ik wil testen te bekijken.
Hiervoor had ik het over ‘al zijn afhankelijkheden’, maar dat moet ik wat nuanceren: bijna al zijn afhankelijkheden. Ik wil uiteraard niet afhankelijk zijn van een echte externe service of van data in een echte database, zelfs niet van een database in een testcontainer. Mijn tests moeten snel blijven, dus ik integreer niet met componenten die mijn tests kunnen vertragen. Zulke afhankelijkheden vervang ik wel nog door testdubbels. Anders zouden we een volledige application context moeten opstarten om met de juiste database te verbinden of een externe REST-API aan te spreken.
Hoewel aannames in softwareontwikkeling vaak de bron zijn van problemen, mogen we bij het schrijven van sociable tests één aanname wel maken: we mogen ervan uitgaan dat al onze afhankelijkheden zich gedragen zoals verwacht. Waarom? Omdat al die afhankelijkheden hun eigen tests hebben! Daarom hoeven we niet overal testdubbels te gebruiken, en kunnen we onze tests veel beknopter en overzichtelijker maken, zonder al die boilerplate code om het gedrag van onze afhankelijkheden te “faken”.
Test het gedrag, niet de implementatie
In plaats van alles te mocken, schrijven we onze tests met de focus op gedrag. Zoals ik eerder al zei: dat zorgt voor minder code in onze tests. En minder is meer. Hoe minder code we schrijven, hoe beter we doorgaans begrijpen wat er echt gebeurt.
Toch moeten we eerlijk zijn: we kunnen testdubbels niet volledig achterwege laten. In de meeste projecten gebruiken we frameworks die voor ons een aantal cruciale onderdelen voorzien, bijvoorbeeld databaseconnecties. Wanneer we (sociable) unit tests schrijven, willen we geen volledige application context opstarten om dat framework zijn werk te laten doen. We hebben echter wel een implementatie nodig voor onze repositorylaag, en die zullen we meestal vervangen door een testdouble.
In mijn ogen geldt het volgende principe: alles wat binnen de scope van onze applicatie valt en niet rechtstreeks communiceert met een extern systeem, zouden we niet moeten vervangen. Maar als een klasse verantwoordelijk is voor interacties met een externe component, gebruiken we daar wel een test double, gewoon om te vermijden dat we een volledige application context moeten opzetten. Zo’n context vertraagt de testuitvoering te veel om nog zinvol te zijn binnen een unit test.
Ik weet dat het opzetten van sociable tests soms omslachtig kan zijn, zeker in grotere projecten. Zelf ben ik momenteel aan het onderzoeken hoe we dat eenvoudiger kunnen maken, maar het is nog te vroeg om daar al in detail over te schrijven.
Ben je benieuwd hoe dat eruit zou kunnen zien of heb je er zelf ideeën over? Laat gerust van je horen, ik praat er graag verder over! Je vindt me op LinkedIn, X en Bluesky.
[1] https://martinfowler.com/articles/2021-test-shapes.html
[2] Jay Fields came up with the terms “solitary” and “sociable”
Testdata
Een ander belangrijk fundament van goede testen is de data waarmee we die tests uitvoeren. Waar komt die data vandaan? En hoe wordt ze geïnitialiseerd?
Ik zie vaak dat ontwikkelaars hun testdata rechtstreeks in hun tests aanmaken. Dat vervuilt de testcode — zeker als je werkt met complexe, geneste objecten. Ook zie ik vaak dat alle tests vertrouwen op statische testobjecten. En dat heeft enkele grote nadelen.
Wanneer ik test met statische data, kan ik er maar beperkt op vertrouwen dat mijn tests echt robuust zijn. Ik weet dat mijn code werkt met precies die data die in de test gebruikt wordt, maar wat als één waarde verandert? Werkt dan nog alles zoals verwacht? Daar ben ik niet zo zeker van.
Daarom werk ik liever met meer willekeurige waarden in mijn tests. Maar hoe gebruik je die het best? Welke aanpakken bestaan er, en welke is de slimste keuze? Lees verder om te ontdekken hoe ik naar testdata kijk!
Mockaroo
Een eerste stap richting tests met meer willekeurige data kun je zetten met een tool zoals Mockaroo. Mockaroo is een platform waarmee je willekeurige, maar realistische testdata kunt genereren. Je kunt zelf bepalen hoe die data eruit moet zien. Het platform biedt een reeks generators die velden automatisch vullen met realistische waarden, maar je kunt ook complexe formules maken die waarden uit andere kolommen gebruiken om een veldwaarde te berekenen.
Je definieert eenvoudig het schema van de data die je wilt genereren en downloadt daarna een reeks willekeurige records in verschillende formaten, zoals SQL, CSV of JSON.
Die data kun je vervolgens opnemen in je project — dat is de makkelijkste manier om Mockaroo te gebruiken. Let wel: ook al wordt je data willekeurig gegenereerd, zodra je ze toevoegt aan je project, wordt ze statisch. Mockaroo kun je ook gebruiken via API-calls, maar dat brengt weer extra complexiteit met zich mee. Bovendien kunnen je tests falen als de Mockaroo-API even niet bereikbaar is. En dat willen we uiteraard vermijden, want onze tests mogen maar om één reden falen: als er iets mis is in onze eigen code.
Data-generatiebibliotheken
Een andere manier om willekeurige data te creëren, is via libraries in je code. De eerste library die ik ooit tegenkwam om realistische, willekeurige data te genereren, was Datafaker. Datafaker is een bibliotheek die realistische pseudo-random waarden kan aanmaken en wordt geleverd met een hele reeks ingebouwde data providers. Je kunt bovendien ook eigen providers maken en registreren om specifieke waarden te genereren.
Datafaker
Ik heb in het verleden vaak met Datafaker gewerkt. Toen kon je er enkel losse, willekeurige waarden mee genereren — geen volledige objecten gevuld met willekeurige data. Die mogelijkheid is pas in 2022 toegevoegd. Sindsdien kun je een schema definiëren waarin je bepaalt hoe elk veld moet worden ingevuld. Zo kun je met één enkel commando complexe, geneste objecten genereren.
Instancio
Omdat Datafaker dat vroeger niet kon, ben ik op zoek gegaan naar een andere oplossing, en zo kwam ik uit bij Instancio. Instancio is een krachtige bibliotheek die precies dat doet: volledige objecten genereren met willekeurige waarden. Een nadeel van Instancio ten opzichte van Datafaker is dat het bij het genereren van strings écht volledig willekeurige waarden produceert. Bij Datafaker kun je daarentegen zelf bepalen welke provider de stringwaarden moet genereren. Dat gezegd zijnde, kun je in Instancio hetzelfde bereiken door een custom generator te schrijven.
Het mooie is dat je beide libraries ook kunt combineren, aangezien ze allebei gebruikmaken van de standaard java.util.Random-klasse om waarden te genereren. Een groot voordeel van Instancio is dat je makkelijk dezelfde waarden opnieuw kunt genereren door een seed value mee te geven aan je test.Dat kan in Datafaker ook, maar daar is het net iets omslachtiger.
Welke library je kiest, maakt mij eerlijk gezegd niet zoveel uit. Het belangrijkste is dat je ervoor zorgt dat het eenvoudig blijft om van library te wisselen, zonder dat je al je tests hoeft aan te passen. Maar, hoe doe je dat precies?
Introductie van ‘Fixtures’
Om te vermijden dat je elke testklasse moet aanpassen wanneer je besluit om van data-generatielibrary te wisselen, is het belangrijk dat je die library niet rechtstreeks in je tests gebruikt. De netste manier om dat aan te pakken, is door gebruik te maken van een fixture class, of wat Martin Fowler het Object Mother-pattern [1] noemt. De creatie van het testobject verplaats je dan naar die ‘object mother’, waar je een statische factory-methode aanbiedt die in één of meerdere testcases kan worden hergebruikt
Zoals Fowler zelf aanhaalt, kan het gebruik van Object Mother in sommige gevallen tot typische fouten leiden. Maar wanneer je Object Mother combineert met meer willekeurige data, is dat risico een stuk kleiner. Waarom? Heel eenvoudig: de meeste object mother-methodes maken een object aan met statische data, en je tests gaan dan ook afhankelijk worden van die vaste waarden. Gebruik je echter willekeurige data in je factory-methodes, dan kun je niet langer schrijven met vaste verwachtingen. In plaats daarvan gebruik je altijd de waarden uit het aangemaakte object zelf om je assertions op te baseren.
Minder boilerplate
Een ander probleem kan ontstaan wanneer je het Object Mother-patroon gebruikt in combinatie met immutable objecten (zoals bijvoorbeeld een Java record class). Als je dan een testobject nodig hebt met specifieke waarden, moet je een factory-methode maken met parameters voor precies die combinatie. Voor je het weet, eindig je met een onbeheerbare stapel factory-methodes. Dat is dus iets wat we absoluut willen vermijden!
In een van mijn vorige projecten waar we Instancio gebruiken, hebben we nagedacht over hoe we dat konden oplossen. We bedachten een builder-achtige constructie, waarmee we specifieke waarden op een vloeiende manier kunnen instellen. Velden die voor een bepaalde testcase niet relevant zijn, krijgen automatisch een willekeurige waarde wanneer de build-methode wordt aangeroepen. Het heeft een paar iteraties gekost om het stabiel genoeg te maken voor al onze use cases, maar uiteindelijk hadden we iets dat voor ons goed genoeg werkte.
Open Source
Ik heb die opzet uiteindelijk losgetrokken in een apart project, dat nu als artifact [2], is gepubliceerd en door iedereen gebruikt kan worden! Initieel moest de builder nog manueel voor elk datatype uitgewerkt worden, maar dit is ondertussen ook al even verleden tijd! Door gebruik te maken van annotation processing kan je nu automatisch de volledige builder laten genereren. Hierdoor is de hoeveelheid boilerplate code sterk gereduceerd.
[1] https://martinfowler.com/bliki/ObjectMother.html
[2] Visit https://wouter-bauweraerts.github.io/instancio-fixture-builder/ for more details
Conclusie
Geautomatiseerde tests zijn een geweldige manier om de betrouwbaarheid van onze codebase te verhogen. Toch zijn er een paar dingen waar we alert voor moeten blijven. Eén van de belangrijkste: zorg ervoor dat je tests niet geschreven (of door AI gegenereerd) worden op basis van je productiecode. Dat kun je eenvoudig voorkomen door je tests als startpunt te gebruiken voor je implementatie. Een BDD- of TDD-werkwijze helpt daarbij perfect.
Daarnaast is het belangrijk dat onze tests niet afhankelijk zijn van een specifieke implementatie. Dat bereik je door sociable tests te schrijven die controleren of de code zich gedraagt zoals verwacht, in plaats van solitary unit tests die enkel de technische implementatie testen.
Tot slot kunnen we onze tests nog verder verbeteren door een data-generatielibrary zoals Instancio of Datafaker te gebruiken om onze testobjecten te vullen met willekeurige, maar realistische data. Door een abstractielaag te voorzien tussen onze tests en de gekozen library, kunnen we later eenvoudig overstappen naar een andere tool zonder al onze tests te moeten herschrijven.
Het Object Mother-pattern is de eenvoudigste manier om dat te bereiken. Wil je nog wat extra flexibiliteit toevoegen, dan kun je dat combineren met een fluent manier om objecten aan te maken, zodat je geen berg factory-methodes hoeft te onderhouden voor elke mogelijke parametercombinatie in je tests.
Dit artikel werd oorspronkelijk in het Engels geschreven en werd door The Beehive vertaald naar het Nederlands. Het originele artikel van Wouter kan je hier terugvinden: https://javapro.io/2025/08/28/testing-done-right/
Hier en daar zijn er een aantal kleine aanpassingen gedaan aan het originele artikel, omdat de informatie niet meer up to date was.