TDD en de testpiramide

Ik ruimde een boekenkast in op het werk, toen een collega me vroeg of ik wist hoe je een call naar een keyed service uit de IServiceProvider mockt. Nee, niet direct, zei ik. Maar waarom zou je dat überhaupt willen?

Wat bleek: ze haalde wat data op uit een Azure Blob Storage en liet daar wat domeinlogica op los. Maar om de domeinlogica te kunnen testen, moest ze de halve wereld in haar class injecteren. En om niet de halve wereld te hoeven injecteren, had ze de IServiceProvider geïnjecteerd en in de class zelf aangegeven welke (al dan niet keyed) services ze allemaal nodig had.

Vanaf daar ontspon er een gesprek over Test-Driven Development (TDD), het ontwerp van code en de verschillende delen van de testpiramide.

TDD het (niet)

Ik raad iedereen te pas en te onpas aan om te TDD’en, precies om dit soort situaties te voorkomen. Door de test te schrijven vóórdat je aan de implementatie begint, wordt het praktisch onmogelijk om code te schrijven die moeilijk te testen is. Want ja, een class die de halve wereld als afhankelijkheid nodig heeft, is moeilijk te testen.

Maar TDD heeft een stijle leercurve. Het is een lastige techniek als je het niet gewend bent om programmeren op die manier aan te vliegen. Wat niet helpt, is dat de meeste voorbeelden van TDD zich op pure domeinlogica richten. Maar in dit geval ging het niet alleen om het uitprogrammeren van business rules, maar ook over het ophalen van data.

“TDD het” was in dit geval niet een productieve tip. Er was meer context nodig. Hoe zou ik deze situatie aanvliegen?

Hardop nagedacht

“Eerst vraag ik me af,” zo dacht ik hardop na, “wat wil ik bereiken? Ik maak een lijst van dingen die de code moet doen wanneer ik klaar ben. Dit zijn mijn acceptatiecriteria, als het ware. (Maar let op: het is niet zo dat ik dit één keer doe, vooraf. Tijdens de implementatie zullen er nieuwe ideeën in me opkomen. De acceptatiecriteria werk ik dus continu bij.)”

“Ik vraag me af: wat voor soort code ben ik hier aan het schrijven? Is het code die interacteert met een extern systeem, die bijvoorbeeld data uit een database ophaalt? Is het domeinlogica? Heeft mijn feature een mix van beide nodig? Dan moet ik in kaart brengen welk deel er onder het ophalen van data valt en welke onder domeinlogica.”

“Voor de repositories schrijf ik integratietests. In mijn eigen project maak ik daarvoor gebruik van een echte SQL-database, dat kan met hulp van TestContainers. Jij haalt data op uit een Blob Storage. Het kan zijn dat daar een mock van bestaat die door de ontwikkelaars van die dienst wordt aangeboden. Of misschien is er een TestContainers-implementatie van, dat zou je uit moeten zoeken. Zou Azurite zijn wat je zoekt? Hoe dan ook, deze tests zijn niet uitputtend, maar voor mij puur om te kijken of ik geen fouten in mijn SQL heb gemaakt.”

“Voor domeinlogica schrijf ik unittests. Die maken gebruik van simpele C#-objecten die dus veelal uit mijn repositories komen. Ik maak mijn eigen leven het makkelijkst wanneer mijn domeinobjecten geen enkele afhankelijkheid hebben naar repositories of iets dergelijks. Dat is een van de redenen waarom ik over het algemeen af ben gestapt van het gebruik van services en liever gebruik maak van pure functies. Maar dat is voor een deel ook een kwestie van stijl.”

“Om te valideren dat beide soorten code goed aan elkaar zijn geknoopt, schrijf ik meestal ook nog een end to end-test (of E2E-test), in mijn geval een test die via de API gevalideerde data ophaalt die uit een echte (TestContainers-)database komt. Soms is dat de eerste test die ik schrijf, soms komt die achteraf. Deze tests hebben niet als doel om na te gaan dat de logica goed werkt, maar alleen om te zien dat alle systemen met elkaar praten.”

TDD en de testpiramide

Wat zegt dit ons? TDD beperkt zich niet tot het schrijven van domeinlogica. Je kunt integratietests en zelfs E2E-tests mee schrijven.

Maar nog belangrijker dan dat, is dat TDD als hulpmiddel dient om domeinlogica en infrastructurele zaken van elkaar te scheiden. Wanneer je met een test begint, voelt het onlogisch om een repository (laat staan een echte database!) te moeten injecteren om alleen wat logica te valideren. En andersom denk je wel twee keer na voordat je domeinlogica introduceert op het moment dat je een integratietest schrijft.

Het helpt je dus om scherp zicht te houden op de verschillende soorten code die Vladimir Khorikov onderscheidt in zijn Unit Testing: Principles, Practices, and Patterns (het beste boek over softwareontwikkeling dat ik in 2021 las). Ik schreef er ook over in deze blog.

Dat is waarom men zegt: TDD is (ook) een ontwerpdiscipline. Logisch ook, want tests zijn (of: kunnen fungeren als) een ontwerpmiddel. De tests geven feedback op het ontwerp. Als je ze schrijft terwijl je de code ontwikkelt, geleiden ze je ontwerp naar een zinvolle decompositie. Als je ze achteraf schrijft, vertellen ze je in hoeverre je gekozen decompositie succesvol was.

Als de set up van je test een enorme lap code beslaat – aan rechtstreekse afhankelijkheden óf via een IServiceProvider –, dan moet je terug naar de tekentafel. “Luister naar je test,” zei ik, “die vertelt je dat de verschillende verantwoordelijkheden je ontwerp nog niet voldoende van elkaar gescheiden zijn. Wat zijn die verantwoordelijkheden? Dát is de vraag die je jezelf moet stellen, niet hoe je keyed services mockt.”

clean code · integratietests · end to end tests · test-driven development · testen · testpiramide · unit tests