Unit Testing - Hoofdstuk 8

Ingediend door Dirk Hornstra op 13-jan-2022 19:54

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

De Timo-samenvatting

Test zoveel mogelijk met unit-testen. Maak een integratie-test voor het happy path waarbij zoveel mogelijk zaken geraakt worden. Afhankelijkheden die je zelf beheerst (database, filesysteem) die test je met "echte objecten", afhankelijkheden die je niet zelf beheerst, die vervang je met mocks. Maak geen interface voor iets waar maar 1 class een implementatie voor levert. Voor een interface heb je minimaal 2 implementerende classes nodig. Zorg dat je domeinmodel duidelijk begrenst is in je code, verminder het aantal lagen in je applicatie waar code doorheen gaat zodat het voor een developer te begrijpen en met onze testen te testen is en verwijder ciculaire afhankelijkheden. Laat een instantie van class A niet een functie op een instantie van class B aanroepen en zichzelf als parameter meegeven.


En dan nu het complete verhaal:

Dit hoofdstuk gaat over integratie-testen. Het boek begint met: "heb je wel eens gehad dat al je testen werken, maar je programma het niet doet?". Dat is niet echt wat je wilt, dan heb ik liever dat een test het niet doet ;)

Hoewel je met unit-testen je volledige business-logica kunt testen is je product in werkelijkheid afhankelijk van (externe) bronnen zoals een database, filesysteem en mogelijk externe API's. Om dat goed te testen komt in dit hoofdstuk voorbij hoe we naar code gaan kijken. Wat kun je mocken en waar wil je echte objecten gebruiken. Maak de grenzen duidelijk (dus waar draait jouw code en waar draait externe code waar je van afhankelijk bent). Verminder het aantal lagen in je applicaties en verwijder circulaire afhankelijkheden.

Wat is een integratie test?

In hoofdstuk 2 is genoemd wat een unit-test is

  • het controleren en valideren van 1 "unit van gedrag"
  • doet dat snel
  • doet het onafhankelijk van andere testen

Een test die aan geen van deze 3 eisen voldoet is een integratie test.

In hoofdstuk 7 hebben we een "code kwadrant" gezien, een vierkant met:

  • linksboven: weinig deelnemers, complexe belangrijk voor het domein (domein model en algoritmes): unit testen
  • linksonder: weinig deelnemers, simpele code (triviale code): niet de moeite van het testen waard
  • rechtsboven: veel deelnemers, complexe code en belangrijk voor het domein (overcomplicated code): moet je niet willen testen
  • rechtsonder: veel deelnemers, simpele code (controllers): hier zitten vaak de integratie testen

Als je bij de controllers alles met mocks vervangt is het eigenlijk ook deel voor unit testen.

Je moet een goede balans hebben tussen unit- en integratie testen. Met veel "out of bound" zaken (databases, externe api's) wordt je test (een stuk) trager. Je moet de externe zaken operationeel houden, ook voor de testen en er zijn meer deelnemers in je code/testen, dus je code wordt uitgebreider.

De algemene stelregel is: controleer zo veel mogelijk scenario's die "een error geven" met unit-testen, gebruik een integratie test voor het "happy path" en een uitzonderingsgeval welke niet met een unit test te valideren is.

We zien vervolgens de Test Pyramide. Bij standaard projecten zul je veel unit testen hebben, iets minder integratie testen en nog wat minder "end to end" testen. Waarbij unit testen vaak snel uitvoerbaar zijn en goed te onderhouden zijn en aan de andere kant de "end to end" zich meer richten op bescherming tegen regressie en het werkend houden als code gerefactord wordt.

Integratie test versus failing fast

Maak met je "happy path" test een test die alle zaken met externe afhankelijkheden test, dus zo uitgebreid mogelijk. Lukt dat niet, maak dan meerdere testen. Het andere geval, de "edge case" die met unit testen niet te bouwen is, die hoef je niet te bouwen als je systeem er al voor zorgt dat bij dat scenario het hele systeem stopt. Testen maken is goed, maar het is beter geen test te maken dan een slechte test (die niets toevoegt).

We krijgen nog even een toelichting op het Fail Fast principe. Zodra er iets fout gaat knal je een exceptie omhoog en wordt de rest van de code niet uitgevoerd. Hierdoor maak je de feedback loop korter, hoe sneller je de bug vindt hoe sneller je 'm kan fixen (voordat je deze versie op productie zet). Het beschermt de integriteit van je systeem. Als zaken deels in een database weggeschreven worden moet je mogelijk nog opruimacties doorvoeren op het moment dat de bug gefixt is.

Met throw new Exception("...") zorg je voor Fail Fast. En met de Precondition die het boek laat zien kun je valideren of de data de je binnenkrijgt voldoet aan wat je verwacht, voordat je allemaal acties uit gaat voeren en aan het einde pas een functie crasht omdat er foutieve data binnengekomen is.

Je out-of-process afhankelijkheden kun je in 2 categorieën verdelen:

  • managed dependencies: deze zaken beheer je zelf, bijvoorbeeld een database. Een externe partij ziet die niet, kan er niet bij, alleen via jouw code.
  • unmanaged dependencies: deze zaken zijn extern zichbaar, een SMTP server, een message bus.

Managed dependencies: hier moet je de echte gebruiken, unmanaged: hier gebruik je mocks.

Het boek komt met een voorbeeld waarbij een afhankelijkheid in beide categorieën kan vallen. De database hoort bij jouw applicatie. Maar nu heeft een externe applicatie ook bepaalde data nodig en haalt die uit een aantal tabellen in jouw database. Sowieso is dat geen fijne implementatie, had via een API zaken beschikbaar gemaakt. Maar goed, als je zo'n situatie hebt en er zijn tabellen die door externe partijen gebruikt worden, beschouw die als "unmanaged".

Vervolgens komt het boek met de vraag, stel dat je niet een "echte database" kunt gebruiken, dan alsnog een mock gebruiken? Het antwoord is een duidelijke "nee". Daarmee zorg je voor mogelijke problemen bij refactoring. Ook zijn je testen niet goed beschermd tegen regressie. Het advies is om dan helemaal geen integratie testen te maken maar te focussen op unit testen van het domeinmodel.

We krijgen vervolgens het voorbeeld, de code waarbij een user zijn/haar e-mailadres kon wijzigen. Omdat je het "langste pad" wilt testen ga je voor het scenario waarbij iemand met een bedrijfs e-mailadres dat wijzigt naar een algemeen @hotmail.com mailadres. In de database worden gebruiker en bedrijf bijgewerkt en er wordt een bericht naar de message-bus gestuurd.

In de voorbeeld test krijgen we een echte database en de message-bus wordt een mock.

We gaan vervolgens kijken naar Interfaces. Dat wordt vaak gebruikt om out-of-process koppelingen abstract te maken zodat het "los gekoppeld" is. En je kunt extra code toevoegen zonder de bestaande code aan te passen, het Open-Closed principe.

Maar als je 1 interface hebt en 1 class die deze implementeert, dan is die interface overbodig. Voor een interface heb je minimaal 2 classes nodig die deze implementeren. En volgens de auteur is het kunnen toevoegen van code ook een foutieve aanname, een YAGNI (you aren't gonna need it) omdat:

  • opportunity costs: je investeert tijd in iets wat waarschijnlijk toch niet nodig is.
  • hoe minder code in je project, hoe beter.


Er zijn een aantal uitzonderingen, maar dat zijn er weinig. De auteur verwijst naar dit artikel voor meer informatie: link.

Waarom zou je interfaces gebruiken? Simpel: omdat je dan mocks kunt gebruiken. Dus pas als je een mock nodig hebt gebruik je een interface.

Best practises voor integratie testen

  • Maak duidelijk wat de grenzen van jouw domeinmodel zijn
  • Zorg voor zo weinig mogelijk lagen in je applicatie
  • En verwijder circulaire verwijzingen


Het domeinmodel is de plaats waar de code staat met de kennis over hoe het probleem wat je probeert op te lossen opgelost moet worden.

Een controller met een domein, die een provider heeft die weer helpers gebruikt, die een datalaag gebruiken en daar weer bepaalde extensies op. Voor iets wat "simpel" is kan de code soms complex zijn. En ook: te complex. Abstractie is goed, maar probeer al die lagen te beperken.

En met circulaire verwijzingen, hierbij roept class A een functie aan bij class B en geeft zichzelf mee. Als je de code wilt testen is het moeilijk om het beginpunt te bepalen (begin ik nu bij A of B) en als je dan met die functie aan de slag gaat, moet je soms ook naar de volledige code van de andere class kijken. Dat wil je niet.

En gebruik meerdere "Act" secties in een test. In hoofdstuk 3 hebben we gezegd dat als een test meerdere Arrange, Act, Assert onderdelen heeft dat een teken is dat er meerdere zaken getest worden en dat is niet goed.
De enige uitzondering op deze regel is dat er gebruik wordt gemaakt van een extern systeem waar beperkingen op zitten, je kunt maar X aanroepen doen, er kan maar 1 record aangemaakt worden, dus je moet ook je testrecord direct weer verwijderen. Alleen dan kunnen er meerder secties in 1 stuk test-code zitten.

Loggen

De auteur van het boek zegt dat als loggen onderdeel van je applicatie uit maakt (de klant kan de logs zien), dan is het observable behaviour (zichtbaar gedrag) en moet je het testen. Als het alleen door de ontwikkelaars gebruikt wordt, dan is het een implementatie-detail en hoef je het niet te testen.

We kunnen het loggen in 2 categorieën onder verdelen

  • support logging: voor support staff of systeembeheerders
  • diagnostic logging: inzicht voor ontwikkelaars wat de code gedaan heeft.


We krijgen een voorbeeld waarbij de diagnostic logging via een interface gaat, maar de logging voor de business via een domainlogger, die speciaal hiervoor gebouwd is.

We krijgen nog uitgelegd wat "structured logging" is. Hier wordt het opvangen van log-data losgekoppeld van het weergeven van de data. In een normale logger wordt de data weggeschreven naar een tekstbestand. Log bestanden zijn dan groot en moeilijk te doorgronden.
Structured logging berekent een hash van de data (de data zelf wordt opgeslagen in een opslaglocatie) en combineert de hash met input parameters om een set van "opgevangen data" te maken. Dit kan gerenderd worden in een plat tekstbestand, maar ook via JSON of CSV.

Hoeveel loggen is "genoeg"? Uiteindelijk zo weinig mogelijk. Loggen heeft geen relatie met je code/acties die je aan het uitvoeren bent dus maakt je code minder leesbaar. En hoe meer je logt, hoe moeilijker iets te vinden is.

En hoe transporteer je loggers door je code? Vaak zet je in je class een static logger variabele met LogManager.GetLogger(...).
Steven van Deursen en Mark Seeman zeggen in hun boek Dependency Injection Principles, Practices, Patterns dat dit een anti-pattern is. Je afhankelijkheid is namelijk verborgen en moeilijk aan te passen (je wilt een andere logger gebruiken en moet nu al je classes aanpassen) en het maakt je testen moeilijker.

Het alternatief is om hem mee te geven als een argument in de functie die je aanroept of als je het op meerdere plekken wilt gebruiken: via de constructor. Met onze dependency-injection gebruik zal dat de meest gebruikte manier zijn.