We gaan aan de slag met Unit Testing, Principles, Practices and Patterns. Vorige keer hoofdstuk 1 behandeld: link, nu is het tijd voor hoofdstuk 2.
De Timo-samenvatting
Unit-testen doe je door een klein deel van de code, snel en geïsoleerd te testen. Er zijn 2 types, de klassieke variant kan een database, e-mail e.d. gebruiken, de London-variant wil alle externe oorzaken uitsluiten en die vervang je dan met "test-doubles/mocks". De London-variant test een class, bij de klassieke methode kun je het gedrag testen, dit kan over classes verdeeld zijn.
Je hebt verschillende testen, in dit boek gaat het over "unit tests". Daar blijk je 2 varianten van te hebben, de "classical" (ook Detroit en classicist genoemd) en de "London"-variant.
Een unit-test is een geautomatiseerde test die een klein deel van de code test (een unit), dit snel doet en het geïsoleerd uitvoert. Op het laatste punt verschillen die 2 methoden. De auteur verwijst naar het boek Test-Driven Development: By Example, geschreven door Kent Beck voor de klassieke methode, voor de London-methode het boek Growing Object-Oriënted Software, Guided by Tests van Steve Freeman en Nat Pryce.
De London-variant staat erop dat je alle dependencies uitsluit door er "test doubles" van te maken. Je database-connectie: een mock-object. Het filesysteem: een mock-object. Als er wat fout gaat weet je zeker dat het komt door je code en niet door een externe afhankelijkheid. De auteur laat een voorbeeld zien, in de flow van een standaard unit-test: arrange - act en assert (AAA). Het boek geeft voorbeelden van frameworks die je kunt gebruiken: Moq, NSubstitute, Mockito, JMock, EasyMock. Het voorbeeld waarbij eerst een concrete class "Shop" werd gebruikt om een Customer 5 artikelen te laten bestellen is nu vervangen met een Mock-object wat geïnitialiseerd wordt met een IShop, een interface-object.
In de klassieke aanpak is het niet zozeer dat de testen geïsoleerd moeten draaien, maar meer dat ze geïsoleerd van elkaar uitgevoerd kunnen worden, zodat je testen parallel uit kunt voeren. Een "shared dependency" is een factor die tussen testen gedeeld wordt en de uitkomst van de testresultaten kan beïnvloeden. Een database is een voorbeeld, als je een persoon toevoegt en een andere test verwijdert deze persoon, dan geeft de Debug.Assert(person.Created()) een false terug. Een "private dependency" is een afhankelijkheid die niet gedeeld wordt. Een "out-of-process" dependency is een afhankelijkheid die buiten het proces uitgevoerd wordt. Als je een database in een eigen Docker-container uitvoert, heeft elke test zijn eigen database. Het is "out-of-process" en niet langer gedeeld met andere testen.
Je kunt dus nog mocks en "test-doubles" gebruiken, maar dat doe je dus voor shared dependency's. Ook zijn er nog "volatile dependencies". Zo'n afhankelijkheid voldoet aan één van de volgende eigenschappen:
- je moet een run-time omgeving opzetten en configureren, naast het project wat je test. Databases en API services zijn typische voorbeelden.
- het levert non-deterministische resultaten op. Bijvoorbeeld door een random getal terug te geven of "de huidige dag + tijd", elke aanroep kan een ander resultaat opleveren.
Vaak zijn dit ook "shared dependencies", maar niet elke shared dependency is volatile (werk je met logbestanden op schijf: je hebt altijd schijfruimte beschikbaar).
De auteur verwijst naar het boek Dependency Injection: Principles, Practises, Patterns van Steven van Deursen en Mark Seemann.
Het verschil tussen de klassieke en de London aanpak is dus wat je moet isoleren, de testen van de omgeving of de testen van elkaar. Het verschil van mening draait om 3 punten:
- de vereiste isolatie, London: units, klassiek: unit tests
- welk deel van de code ga je unit testen? London: een class, klassiek: een class of meerdere classes
- hoe ga je met afhankelijkheden (dependencies) om, hoe gebruik je test-doubles? London: alles behalve immutable dependencies (onwijzigbare afhankelijkheden), klassiek: gedeelde afhankelijkheden.
De voordelen van de London-methode zijn:
- de tests zijn specifiek en testen maar 1 class per keer
- het is makkelijker om een class met allemaal verwijzingen naar ander objecten te testen, omdat die vervangen zijn met test-doubles en je daar niet druk om hoeft te maken
- als een test faalt weet je zeker dat het aan jouw code ligt
Met 1 class per keer test je op de manier zoals developers de code zien. Maar eigenlijk zou je op gedrag moeten testen en dat kan over meerdere classes verspreid zijn. Juist door een (te) klein deel te testen kan de vraag ontstaan: wat wordt hier nu eigenlijk getest? Terwijl je andere de hele flow ziet en begrijpt wat het begin, het proces en uiteindelijk het resultaat zou moeten zijn.
Als je een class met heel veel verwijzingen naar andere objecten hebt, dan zijn mocks en test-doubles de "easy way". Eigenlijk moet je jezelf afvragen, wat doen al die objecten daar en is het eigenlijk "slechte code"? Wat in hoofdstuk 1 ook gezegd werd, door testen te maken kom je er soms achter dat de code niet goed is, een "negative indicator".
Dat je maar in 1 of 2 testen de bug naar voren ziet komen en niet in je andere testen, dat kan ook een onterecht gevoel geven van "het is niet zo erg". In de klassieke testmethode kan iets wat diep zit en overal in het systeem zit zorgen voor een "golfbeweging" door je testen die allemaal kapot gaan. Op die manier is het wel zoeken naar de oorzaak, maar het is wel duidelijk dat je na jouw aanpassing (of die van een collega) ineens 8 van de 10 testen het niet meer doen, het zaak is om die bug op te sporen...
Met Test Driven Development (TDD) kun je het systeem in grote lijnen opzetten, de andere objecten zijn mocks die je later uitwerkt. Met de klassieke methode bouw je juist op. De London-style levert vaak testen op die behoorlijk gekoppeld zijn aan de code, terwijl dit in de klassieke methode minder gekoppeld zit.
We komen bij de integratie-test. In de klassieke methode is een unit-test:
- controleert een enkele unit van gedrag
- doet het snel
- doet dit geïsoleerd van andere tests
Alles wat hier niet aan voldoet is een integratietest.
Een integratietest is een test die controleert of je code werkt met gedeelde afhankelijkheden, out-of-proces afhankelijkheden of code die door een andere onderdeel van de organisatie gebouwd is. Je hebt ook nog een end-to-end test, hierbij worden alle of een substantieel deel van de out-of-process afhankelijkheden getest.