Unit Testing - Hoofdstuk 7

Ingediend door Dirk Hornstra op 20-dec-2021 08:14

Na hoofdstuk 1: link, hoofdstuk 2: link, hoofdstuk 3: link, hoofdstuk 4: link, hoofdstuk 5: link en hoofdstuk 6: link is het nu tijd voor hoofdstuk 7.

De Timo-samenvatting

Advies: lees zelf het hoofdstuk nog even door, in verband met voorbeelden e.d. om de theorie te verduidelijken.

Er zijn 2 dimensies voor je testen, de complexiteit van je code en hoe belangrijk die code voor je "domein"/programma is. Als aan 1 van deze 2 zaken voldaan wordt, zou je de code moeten testen. Vaak is code een wirwar van controles, acties, afhankelijkheden en is het "over-gecompliceerde code". Door het scheiden van de business-logica en de code die zorgt voor de flow/volgorde van de acties kun je de businss-logica in dit type code wel testen. En in de toekomst ook beter refactoren/uitbreiden. Maak gebruik van Precondition.[functie] om zaken af te dwingen. 1) Probeer je code testbaar te houden (weinig externe codebibliotheken e.d.), 2) hou je code simpel (hou het aantal keuzemomenten, code complexity, laag) en 3) probeer de performance stabiel te houden. Met lees- en schrijfacties door "andere classes" te laten uitvoeren maak je jouw code simpeler, heb je minder afhankelijkheden, maar verlies je mogelijk performance, met het "injecteren" van externe objecten hou je code simpel, blijft performance goed, maar zal de testbaarheid minder worden en de voorkeur van het boek: splits je code op in meer sub-stappen/losse functies, waardoor de controller minder "simpel" wordt, maar testbaarheid en performance goed blijven.

 

En dan nu het complete verhaal:

Het refactoren naar waardevolle testen.

In het eerste hoofdstuk hebben we bepaald wat een goede test-suite definieert: integratie in het ontwerp-proces, neemt de belangrijkste delen van jouw code mee, levert maximaal resultaat tegen de laagste inspanning om het te onderhouden. Om aan die laatste eis te kunnen voldoen moet je kunnen zien wat een waardevolle test is (en wat een waardeloze test is) en die ook kunnen schrijven. In hoofdstuk 4 kreeg je hier handvaten voor: beschermd tegen regressie, kan refactoring aan, geeft snelle feedback en is onderhoudbaar. Hoofdstuk 5 legde de nadruk op refactoring.

Om goede testen te schrijven moet je ook goede code schrijven. In hoofdstuk 6 is een voorbeeld gegeven met het refactoren van code om het goed testbaar te maken, in dit hoofdstuk gaan we dit in breder perspectief zien. We gaan de code onderscheiden in 4 types om daarmee de richting aan te geven hoe de refactoring uitgevoerd moet worden.

Ten eerste zijn er 2 dimensies:

  • complexiteit of "belangrijkheid" voor het domein
  • het aantal deelnemers


Complexiteit wordt bepaald door het aantal keuze-momenten in je code. Meer keuze-momenten: complexere code. In computer science is er een formule voor de cyclomatic complexity: 1 + aantal keuze-punten. Een stuk code zonder keuze-punten heeft dus een score van 1.

Belangrijkheid voor het domein geeft aan hoe belangrijk je code voor het probleem domein van je project is. Bepaalde ondersteunende code (trimmen van teksten, regexen op data) heeft weinig "belangrijkheid" voor het domein. Als iets complex is (berekenen van een prijs) hoeft dat niet belangrijk voor je code te zijn. Maar omdat het complex is wil je het wel testen. Hetzelfde geldt andersom, dus een redelijk simpele functie die essentieel is voor je domein, die wil je wel testen.

Het aantal deelnemers, dat is een "mutable" of "out of process" afhankelijkheid (of bevat beide elementen). Hoe meer deelnemers, hoe groter en uitgebreider je testcode is, omdat je zaken moet opstarten om uiteindelijk het resultaat te kunnen valideren. Het type deelnemer maakt ook uit. Out-of-process deelnemers zijn een "no-go" wat betreft het domein-model.

Hiermee bouwen we de 4 types op:

  • Domein model en algoritmes, complexe code is vaak onderdeel van het domein model maar niet altijd. Je kunt een complex algoritme gebruiken wat niet direct een relatie heeft met het probleem domein.
  • Triviale code, voorbeelden hiervan zijn parameter-loze constructors en 1-regelige eigenschappen, deze hebben weinig (tot geen) deelnemers en tonen weinig complexiteit of belangrijkheid voor het domein.
  • Controllers, de code voert geen complexe of essentiële uit maar zorgt voor de coördinatie van het werk van andere componenten zoals domein classes en externe applicaties.
  • Over-gecompliceerde code, dit type code scoort hoogt in de metrieken, veel deelnemers/afhankelijkheden, complex en/of belangrijk. Sommige "fat controllers" dragen geen verantwoordelijkheid over, maar voeren echt alles zelf uit.


Het testen van domein model en algoritmes zal je een goed resultaat geven. Belangrijke code, weinig afhankelijkheden. Triviale code moet je niet testen. Controllers zou je "licht" moeten testen. Over-gecompliceerde code zou je wel moeten testen. Maar hier hikken mensen vaak tegen aan, hoe ga ik dit testen? In dit hoofdstuk gaan we kijken of we dit kunnen splitsen naar algoritmes en naar controllers.

Om "over-gecompliceerde" code aan te pakken, gaan we gebruik maken van een Design Pattern, het "Humble Object design pattern" (vertaling: het 'nederige object' ? ). Gerard Meszaros heeft deze in zijn boek "xUnit Test Patterns: Refactoring Test Code (Addison-Wesley, 2007)" benoemd. Vaak zit je code gekoppeld in een framework en is het moeilijk om dat te testen. Om de code te testen moet je een deel daaruit halen, wat naar een eigen class gaat, daardoor ontkoppeld wordt van het framework. Met een "humble object" plak je die class weer binnen het framework. Dat "plakken" hoef je niet te testen, het gaat ons om de code die eruit gehaald is en nu wel te testen is.

Een andere manier om naar het Humble Object pattern te kijken is het te beschouwen als het "Single Responsibility principe". Code moet één ding doen. Business logica kan als één ding beschouwd worden.

Er zijn wel meer patterns en principes die een soort van Humble Object zijn, ze zijn er om te zorgen dat jouw complexe code losgekoppeld wordt van de code die zorgt voor de aansturing van de processen (de orchestration).

Je heb de Model-View-Presenter en het Model-View-Controller patterns. Die helpen je om de business-logica (model) te scheiden van de UI/interface (view) en de coördinatie tussen die delen (presenter/controller). Presenter en Controller zijn "humble objects", zij plakken de zaken aan elkaar.

Ook heb je nog het Aggregate pattern, afkomstig uit het boek Domain-Driven Design van Eric Evans. Een doel daarvan is om de connectiviteit tussen classes te verminderen en dat op te lossen door zaken te groeperen. Binnen de clusters zijn de classes sterk gekoppeld, maar de clusters zelf zijn "loosely coupled". Minder communicatie, minder connectiviteit, verbeterde testbaarheid.

Het scheiden van business-logica en coördinatie is niet alleen goed voor de testbaarheid, maar ook voor de onderhoudbaarheid. Als je zaken in een controller uitvoert en er komt een nieuwe controller bij die bijna hetzelfde doet, kom je er misschien achter dat je zaken zou moeten kopiëren, als ze niet netjes in het model uitgewerkt zijn.

Het boek gaat nu aan de slag met een voorbeeld. We zien een CRM. Alle gebruikers staan in een database. Het systeem kan nog maar 1 ding: het wijzigen van een e-mailadres. Waarvoor 3 business-rules gelden: als het domein overeen komt met die van het bedrijf, dan is het een werknemer, anders een klant; er wordt bijgehouden hoeveel werknemers er zijn, zou iemand een werk-mailadres omzetten naar een eigen mail-adres (en dus van werknemer klant worden), dan moet die waarde bijgewerkt worden; als een e-mailadres wijzigt, moeten externe systemen hiervan op de hoogte gesteld worden, dit gaat via een message bus.

We zien 1 functie waarin alles gebeurt, controles en vervolgens een Database.Save en MessageBus.Send.

Stap 1: maak impliciete afhankelijkheden expliciet. We zien de Database en de MessageBus. Je zou deze om kunnen zetten naar Interfaces en via dependency-injection kunnen toevoegen (en zo kun je dan ook testen, door mocks te gebruiken). Maar het boek geeft aan, dit is nog steeds geen goede oplossing. Je bent nog steeds "out-of-process" bezig, het zijn proxies naar data welke niet in het geheugen zit. De hexagonale architectuur adviseert hier ook: het domein model zou niet verantwoordelijk moeten zijn voor communicatie met externe systemen.

Stap 2: introductie van een "applicatie service laag". Schuif deze verantwoordelijkheid door naar een andere class, een "humble" controller. Het boek laat een class UserController zien met een private Database en private MessageBus en een functie ChangeEmail. Rechtuit-rechtaan. Maar ook hier moeten zaken aangepast worden: Database en MessageBus worden aangemaakt maar niet via Dependency Injection ingeladen (dat gaat problemen geven); op basis van rauwe data reconstrueert deze controller een user-object, maar dat zou deze controller niet moeten/mogen doen; dezelfde fout wordt uitgevoerd met het opbouwen van een "company-object"; en er zit geen validatie meer in, als een e-mailadres niet wijzigt wordt er toch een save op de database uitgevoerd en acties op de message bus gestart.

We zien nog even het schema, waarbij we zien dat de code aan de ene kant verschoven is uit de "overcomplicated code" naar "domain model, algorithms" voor de class User en UserController nu in "controllers" staat.

Stap 3: verwijder de complexe code uit de UserController. Zo kun je de user opvragen met behulp van een ORM. En mocht je dat niet gebruiken, dan kun je zelf een "factory" aanmaken, in dit geval de class UserFactory met een Create-functie. Hier geef je de input-waardes mee, kun je daar binnen met een Precondition.Requires(inputData.Length >= 3); afdwingen dat je voldoende informatie binnen krijgt.

Stap 4: introduceer een eigen Company class. In de originele code werden zaken opgevraagd op basis van veldnummers (dus companyData[0], companyData[1]), dat wil je niet. Dus er komt een eigen class Company die dit afhandelt. Die krijgt ook een eigen functie ChangeNumberOfEmployees om het aantal werknemers aan te passen en de functie IsEmailCorporate om te controleren of het een bedrijfs- of eigen e-mailadres is.

We zien vervolgens een aantal test-cases. Zo heb je [Fact] met een functie waarbij je een algemeen mailadres wijzigt naar een bedrijfs mailadres, hier test je 1 regel. We krijgen ook een voorbeeld van een functie die de tag [Theory] heeft met 2 regels [InlineData("mailadres", "mailadres", "true of false")] waarmee je meerdere cases kunt valideren.

Dan de vraag of je "Preconditions" moet testen. Dat is afhankelijk van de impact van de precondition. Als je die in een functie hebt en gebruikt om te kijken of er 3 invoerwaardes aangeleverd worden, het is niet echt de moeite waard om dat te testen. Een bepaalde controle of bijvoorbeeld het aantal personen >= 0 is (dus kan/mag nooit negatief zijn) is wel een goede preconditie om te testen. Als het voorkomt geeft het meestal aan dat er een "bug" in je systeem zit, mogelijk met een race-condition/threads. Het raakt je domein logica, dus die moet je eigenlijk wel testen.

Het volgende blok gaat over het afhandelen van conditonele logica in controllers. Het scheiden van business logica en het regelen van de flow die code moet volgen werkt het beste als acties de volgende 3 verschillende stadia hebben: vraag dat op uit een opslag-medium; voer business logica uit; sla de data op in het opslag-medium;

Maar de flow is niet altijd op deze "nette" manier, vaak heb je onderling nog extra acties, binnen de controller, tussen controller en logica. De drie mogelijkheden die je dan hebt zijn:

  • stuur alle lees- en schrijf-acties naar de kant, dus laat "andere code" dat uitvoeren. Hiermee hou je de read-decide-act structuur in tact. Nadeel is dat bepaalde calls uitgevoerd worden die niet nodig zijn. -> performance kan hier afzakken.
  • inject deze "out of process" afhankelijkheden in het domein model en laat de business logica bepalen of deze wel of niet aangeroepen moeten worden. -> zorgt dat testbaarheid moeilijker wordt.
  • splits het "decision-making" proces op in meer sub-stappen, zodat de controller deze acties los uit kan voeren. -> controller wordt minder "simpel".


Hiermee moet je deze 3 attributen in evenwicht proberen te houden:

  • testbaarheid van het domein model, afhankelijk van het aantal deelnemende classes in het domein model.
  • hou je controller "simpel", dit hangt af van het aantal keuzes dat gemaakt moet/kan worden.
  • performance, gedefinieerd op basis van het aantal out of process calls (weer een database-object aanmaken, etc.)


Helaas kun je nooit aan alle 3 voldoen, het maximum waar je aan kunt voldoen, dat zijn er 2, degene waar niet aan voldaan kan worden is bij de 3 mogelijkheden benoemd.

"Dé" oplossing zou het splitsen van je code moeten zijn, zodat het beter te testen is. Het boek licht dit toe met een voorbeeld, op basis van "CanExecute/Execute pattern".

In dit voorbeeld kun je een e-mailadres wijzigen totdat je deze bevestigd hebt, daarna kan het niet meer. Waar ga je nu die controle toevoegen?
We zouden dit in de ChangeEmail-functie kunnen plaatsen. De controller doet dan een validatie. Omdat je nu de read/write actie hebt verschoven, wordt het bedrijf opgevraagd, ook als het e-mailadres niet meer aangepast kan worden (eigenlijk een actie "teveel").

We gaan de controle nu in de controller plaatsen. Maar nu heb je de zaken op meerdere plekken staan (de controller bepaalt nu of je wel of niet door mag gaan) en wat gaan we doen met deze aanpassing (User-object).

De volgende optie is een losse functie maken. Die roep je in de User aan in de functie ChangeEmail met een Precondition. De controller hoeft nu geen "inside info" meer te hebben, en met de precondition dwing je af dat aan de eisen voldaan wordt.

Het volgende blok gaat over "Domain events". Soms wil je weten hoe een object tot een bepaald resultaat gekomen is. Dat wil je niet door de controllers bij laten houden, want daarmee maak je die nodeloos ingewikkeld/uitgebreid. We komen nog even bij het voorbeeld van ons CRM, zo werd er een call naar de Message Bus gedaan, ook als het e-mailadres gelijk bleef (bugje). Hier komen "Domain Events" in beeld, in dit geval de class "EmailChangedEvent" waarin UserId en NewEmail bijgehouden worden. Je maakt een variabele aan die een lijst van deze EmailChangedEvent-items kan bevatten. De controller kan deze naar de Message Bus doorsturen. Maar jij kunt in jouw test-code valideren of deze lijst aangevuld is met jouw wijziging(en). De auteur noemt nog het samenvoegen van events, dat kun je hier nalezen: link.