Unit Testing - Hoofdstuk 6

Ingediend door Dirk Hornstra op 21-nov-2021 19:34

De tijd vliegt, er komt alweer bijna een back-end overleg aan, dus na hoofdstuk: link, hoofdstuk 2: link, hoofdstuk 3: link, hoofdstuk 4: link, en hoofdstuk 5: link is het nu tijd voor hoofdstuk 6.

De Timo-samenvatting

Testen kunnen onderscheiden worden in output-based testing (gooi er data in en controleer wat eruit komt), state-based testing (gooi er data in en controleer na de tijd de status van je class, de bestanden in een map, de records in een database) of communication-based testing (gebruik mocks voor je testen).

Probeer je code zoveel mogelijk functioneel op te bouwen: een deel wat beslist wat er moet gebeuren (dit ga je testen) en de code die uitvoert wat er beslist is (die test je dus niet). Dit kun je output-based testen, het type test wat volgens het boek de beste test-methode is. Let wel, met functionele code kun je een slechtere performance hebben. Je moet dus valideren of het de moeite waard is om je testen goed leesbaar en onderhoudbaar (en gescheiden qua functionaliteit) te hebben en de performance die je systeem moet hebben.

 

In hoofdstuk 4 hebben we het gehad over de 4 attributen van een goede unit-test:

  • bescherming tegen regressie
  • code is stabiel en kan refactoring "overleven"
  • snelle feedback
  • onderhoudbaar

In hoofdstuk 5 ging het over het gebruik van mocks. In dit hoofdstuk gaan we het over de verschillende stijlen unit-testen hebben. Dat zijn output-based, state-based en communication-based testing. Waarbij je qua kwaliteit ook deze volgorde aanhoudt, output-based is het beste, state-based tweede keus en communication-based zou je bijna nooit moeten gebruiken. Je kunt trouwens wel alle 3 stijlen in 1 test gebruiken.

Hier zit dan wel de restrictie bij dat code niet altijd makkelijk/goed output-based te testen is. Het boek raadt aan om je code functioneel op te bouwen. Het boek gaat de basis behandelen, dus als je hier meer van wilt weten/je er meer in wilt verdiepen wordt het tijd voor een ander boek. En die kun je hier vinden: link.

Output-based style

Je stopt waardes in de test, voert de te testen code uit en kijkt naar het resultaat. Hierbij heeft de code die getest is geen globale of interne "state" aangepast. Deze manier van testen wordt ook wel functional (functioneel) genoemd omdat het code zonder bij-effecten is.

State-based style

Bij deze manier van testen controleer je na het uitvoeren van de code die getest wordt de status van het systeem, de state. Dit kan betrekking hebben op de waardes die je als input aangeleverd hebt (welke waardes hebben deze nu), zaken die ook mee gedraaid hebben (collaborators) of een out-of-proces afhankelijkheid, zoals een database op file-system (is er een log-bestand aangemaakt?).

Communication-based style

Bij deze stijl maak je gebruik van mocks om communicatie tussen het systeem wat getest wordt en de collaborators (meewerkende code) te testen.

Ook hier wordt werken de verschillende "scholen" anders, de classical school geeft de voorkeur aan state-based testen boven communication-based, de London-school juist aan de communication-based (wat op zich logisch is, want in de vorige hoofdstukken hebben we gezien dat de London-school zoveel mogelijk met mocks wil doen).

Het boek legt vervolgens de 4 attributen naast de verschillende manieren van testen;

Bescherming tegen regressie / Snelle feedback

Hier zit niet een echt verschil tussen de verschillende stijlen. Dit item hangt samen met: hoeveelheid code die tijdens een test getest wordt, de complexiteit van de code en de belangrijkheid voor het domein (test niet de simpele functies, maar de functies die essentieel zijn). Alleen bij de communication-based test zou een heel klein deel van de code kunnen testen als de rest allemaal via mocks uitgewerkt wordt, maar dat zou wel een uitzondering zijn. Ook de snelle feedback is bij alle stijlen ongeveer gelijk.

Code is stabiel en kan refactoring "overleven"

Dit punt is andere koek. Dit punt komt neer op het aantal false positives wat gegeven wordt na het aanpassen van code. Output-based testing zou hier weinig tot geen problemen mee moeten hebben. Voor dit type test is de te testen code een redelijke "black box". Bij state-based testing wordt ook met de state van de te testen class gewerkt. Omdat de koppeling minder abstract is, heb je het risico dat er meer implementatie-details in je test komen. En communication-bases testing is helemaal gevoelig voor dit type fouten. Juist omdat je met test-doubles werkt en die mogelijk ook aangepast moeten worden.

De test-code is onderhoudbaar

De onderhoudbaarheid van een test wordt gevormd door "hoe makkelijk is de test te begrijpen", dat houdt verband met het aantal coderegels en hoe makkelijk is het om een test op te starten (of moet je eerst allemaal extra zaken inregelen/toevoegen voordat je überhaupt kunt starten?

Ook hier geldt dat output-based testing het best te onderhouden is (je stopt er wat in, en kijkt naar wat eruit komt. Als de functionaliteit die getest wordt eerst uit 2 interne functies bestond en nu uit 25, dat maakt niet uit, als de input maar verwerkt kan worden en de output maar gelijk blijft).

State-based testing is minder goed onderhoudbaar, omdat het opzetten van de structuur in code vaak al meer ruimte inneemt.

Communication-based testing is nog minder goed onderhoudbaar, mede doordat test-doubles opgezet moeten worden e.d.

Functioneel programmeren.

Het boek legt uit wat dit is. Er zijn geen verborgen inputs of outputs. Hoe vaak je de functie ook aanroept, als je steeds dezelfde input geeft, krijg je altijd dezelfde output terug. Het boek heeft de functie "BerekenKorting" waarbij als parameter een lijst met producten meegegeven wordt. Deze functie geeft een decimaal terug.

Voorbeelden van "hidden inputs" zijn:

  • Side effect(s): een output die niet in het resultaat naar voren komt. In de functie wordt de state van een instantie van een class aangepast, een bestand op schijf aangepast, enzovoort.
  • Exceptie(s): als in de BerekenKorting een exceptie gegeven wordt (stel dat op basis van een waarde er door 0 gedeeld wordt) wordt het contract verbroken. Je krijgt niet een decimaal terug, maar een exceptie (die mogelijk ergens anders afgevangen wordt).
  • Referentie naar interne of externe state: de functie kan intern een DateTime.Now aanroepen, gegevens uit een database opvragen of wegschrijven, iets met een aanpasbare private variabele doen. Allemaal zaken die niet uit de declaratie van de functie blijken.


Om te bepalen of jouw functie een wiskundige functie is (mathematical function) kun je kijken of je een aanroep naar de functie kunt vervangen met de waarde die je ontvangt zonder de code aan te passen, dit wordt "referential transparence" genoemd;

Dat klinkt een beetje cryptisch, maar wordt met deze voorbeelden duidelijk gemaakt:


// dit is een wiskundige functie
// want beide statements zijn geldig
public int Increment(int x)
{
return x + 1;
}

int y = Increment(4);
int y = 5;

-----

// onderstaande is dit niet
int x = 0;
public int Increment()
{
x++;
return x;
}

// omdat het dit is:
int y = Increment();
int y = ????

Side effects komen het meest voor. Want zo zien we het voorbeeld van het boek de functie AddComment(string text). Hij krijgt input, geeft output, maar intern wordt in een private array het comment toegevoegd: een side-effect.

Wat is functionele architectuur?

Side-effecten zijn er om je code werkend te maken. Dus is het logisch dat dit gebeurt, want je wilt dingen in je winkelwagen zetten, orders met orderregels aanmaken. De functionele architectuur staat voor de methode om zoveel mogelijk code te schrijven waarbij de objecten "immutable" zijn (dus niet aan te passen) en de functies waarbij data wel aangepast wordt, daar zo weinig mogelijk van te hebben in je code.

Dit kan door je code te scheiden op basis van deze criteria:

  • code welke een beslissing maakt: hier hoeven geen side-effecten op te treden en kun je (dus) via wiskundige funtie(s) uitwerken.
  • code welke iets moet doen op basis van een beslissing: hierbij worden aanpassingen uitgevoerd.


De code die beslissingen maakt, die wordt vaak genoemd als "functional core" of "immutable core".  De code die iets moet doen wordt genoemd als de "mutable shell". De mutable shell krijgt de input, de functional core bepaalt de uitkomsten, de shell zet deze om naar "side-effects".

Om dit werkend te krijgen moet je zorgen dat de mutable shell voldoende informatie krijgt, waardoor deze niet zelf beslissingen hoeft te nemen. De mutable shell is eigenlijk alleen een get/set en meer niet. Als je zorgt dat je de functional core met voldoende output-based tests valideert je een goede test-suite hebt.

Vergelijking van functionele architectuur met hexagonale architectuur

Beide architecturen zijn gericht op het scheiden van aandacht/problemen (seperation of concern). De details daarvan verschillen echter nogal.

De hexagonale architectuur differentieert tussen de domain-laag en de applicatie-services-laag. De domain-laag zorgt voor de business logic, de applicatie-services-laag zorgt voor de communicatie met externe applicaties zoals een database of SMTP-server. Dit komt overeen met de functionele architectuur met het scheiden van de beslissingen en de acties. Hetzelfde geldt voor de "one-way-flow" van de afhankelijkheden. In de hexagonale architectuur zijn classes binnen de domeinlaag afhankelijk van elkaar, ze moeten geen afhankelijkheden hebben van classes in de applicatie-services-laag. Hetzelfde geldt voor de functionele architectuur, waarbij de immutable core niet afhankelijk mag zijn van de mutable shell.

Het verschil is de aanpak van de side-effects. De functionele architectuur zet al deze acties door naar de mutable shell. Aan de andere kant vindt de hexagonale architectuur het prima als in de domain-layer side-effects optreden, zolang dit maar betrekking heeft op de domain-layer.

In het boek staat als "Note" dat functionele architectuur als een subset van hexagonale architectuur beschouwd kan worden.

Omzetten naar een functionele architectuur en output-based testing

Het boek gaat voorbeelden geven hoe we dit kunnen bereiken. We nemen daarbij een voorbeeld-case waarbij bezoekers met hun naam en bezoektijd in een logbestand worden bijgeschreven. Dat logbestand heeft een maximale grootte, is deze bereikt, dan wordt een nieuwe aangemaakt. We zien hoe de code opgebouwd is, waarbij deze behoorlijk "gekoppeld" zit aan de bestanden waarin de gegevens worden weggeschreven.

Omdat het filesysteem hier een bottleneck  is, gaan we kijken of dit met mocks aan te pakken is. Er wordt een interface IFileSystem aangemaakt waarmee te testen is of als een bestand "vol loopt" er een nieuw logbestand aangemaakt wordt.

Of je gaat de boel refactoren naar een nieuwe class. In AuditManager wordt nu "onder water" bepaald dat een logbestand "vol" is en er een nieuwe gemaakt moet worden. Maar die check halen we hier uit de class en zetten we om naar een nieuwe class "Persister".

Functionele architectuur is niet altijd optimaal

Niet elke functie is om te zetten naar een "wiskundige functie". Het boek geeft het voorbeeld van het controleren van het toegangsniveau van een bezoeker als hij/zij een bepaalde treshold overschreven heeft. Dat wordt opgeslagen in een database. Maar die database kun je niet zo even als parameter meegeven. Je zou het als volgt op kunnen lossen:

  • vraag voor je test het toegangsniveau van de bezoeker op.
  • of je voegt een functie IsAccessLevelCheckRequired() in AuditManager toe en zorgt dat deze aangeroepen wordt voor je AddRecord() aanroept.


Beide methodes hebben nadelen. Bij de eerste voer je een query op de database uit terwijl dat in 9 van de 10 gevallen niet nodig is. In het tweede geval bepaal jij dat de functie aangeroepen wordt en niet de Auditmanager.

De auteur vindt het de afhankelijkheid van de database niet goed is.

Slechtere performance

Het bouwen van je code op de functionele manier, de performance is hier vaak een bottleneck. Het boek geeft als voorbeeld het voorbeeld met de logbestanden, bij de "normale manier" hoefde je de bestaande logbestanden niet te controleren, met nu het omgebouwd is moet je ze wel allemaal controleren. De beslissing om te kiezen voor functioneel programmeren (of juist niet) is afhankelijk van de keuze of je de snelheid goed wilt houden óf dat je de onderhoudbaarheid goed wil houden.

Hetzelfde geldt voor de grootte van je code. Door het scheiden van de code die beslissingen maakt en code die beslissingen uitvoert krijg je meer code. Maar het zorgt er wel voor dat je code goed leesbaar en goed te onderhouden is.