De meeste tests in de piramide zijn integratietesten

Ik was een blogpost en presentatie aan het voorbereiden over Testcontainers: een handig stuk gereedschap om tijdens integratietests een Docker container te starten. Ideaal voor lichtgewicht databases met herbruikbare testdata. Ik had tips over hoe je dat het beste kunt inrichten en vroeg mij af waar zulke testen horen in de testpiramide. Dat zal moeten wachten tot deel twee, want mijn focus werd naar een zijspoor getrokken door dat netelige begrip ‘integratietest’. Controversieel vanwege het verschil van mening over de definitie. Testen we nu expliciet de integratie tussen componenten, of integreren we componenten om de test mogelijk te maken? Nou, allebei, maar liefst niet tegelijkertijd. 

De veelgebruikte testpiramide is een handige metafoor om het scala aan tests op te delen in een oplopende mate van integratie tussen de afzonderlijke delen. Aan de basis ligt een uitgebreide suite van gedetailleerde tests die de logica van elk deel valideert in afzondering. De samenwerking tussen de delen laten we nog zo veel mogelijk buiten beschouwing. Bovenaan de piramide worden alle bewegende delen in samenhang getest, in een omgeving die zo goed als het kan overeenkomt met productie. Overigens worden externe dienstverleners meestal nog wel gefaket. Denk aan een distributiecentrum, koeriersdienst, of een bank. Op de testomgeving van een webwinkel zal niet met echt geld echte pakketjes worden verstuurd — daar ga ik althans van uit.

Zoekresultaten voor de term Test Pyramid

Naarmate we hoger in de piramide zitten worden de testen minder talkrijk, minder gedetailleerd, maar wel ingewikkelder om op te zetten, omdat we steeds meer componenten aan elkaar gaan koppelen. Ze duren daardoor ook langer om uit te voeren, zeker als er een database bij betrokken is. Voor handmatige testen geldt dit al helemaal. Het is dus zaak om zo veel mogelijk logica die besloten ligt in de applicatie in kleine, geïsoleerde unit tests te valideren. Die isolatie is nog niet zo makkelijk te bereiken. Je hebt hierin grofweg twee opvattingen, die je gerust kampen mag noemen. Vladimir Khorikov beschrijft dit in zijn uitstekende boek Unit Testing: Principles, Practices, and Patterns (Manning, 2020).

Bij de zogenaamde Londense school ligt de focus van een unit test op een afgezonderd stuk code. Er is meestal een een-op-een relatie tussen een klasse en een testklasse. Afhankelijkheden met andere klassen vervang je door test doubles, eventueel met behulp van een mocking framework. Op die manier blijft de unit test ‘puur’ omdat hij de volledige controle neemt over het gedrag van de afhankelijkheden. Bij de klassieke school daarentegen ligt de focus op gedrag. De unit test is een unit of behaviour. Zulke unit tests valideren scenario’s vanuit het oogpunt van de gebruiker van de code via een publieke API en zijn veel minder gericht op de details van implementatie. Afhankelijkheden hoeven we niet meer te mocken, tenzij dat om praktische overwegingen (snelheid, gebruiksgemak, complexiteit) handiger is.

Ik ben zelf een voorstander van de klassieke school. Tests zijn gemakkelijker te schrijven, intuïtiever om te lezen en beter te onderhouden. En de testdekking lijdt er niet onder. Maar strikt genomen ben je al in de basis van de testpiramide aan het integreren, zij het dan met andere klassen. Om gemakkelijk zuivere unit tests te kunnen schrijven zonder mocking is het dus zaak om essentiële, ingewikkelde logica te isoleren.

Maar vanuit de klassieke school worden veel tests van controllers en services al snel integratietests, omdat de aangeroepen code niet op zichzelf staat, maar andere code aanroept en/of bepaalde libraries, middleware of services (database!) nodig heeft. Wat wel verschilt is de focus op de raakpunten van integratie. Onderaan de piramide testen we die raakpunten expliciet; bovenaan de piramide vertrouwen we erop dat ze goed werken. Een paar voorbeelden.

class MemberController {
  @Autowired
  
  private MemberService memberService;
  
  @PreAuthorize("hasRole('ADMIN')")
  @DeleteMapping("/members/{id}")
    public void deleteMember(@PathVariable(value = "id") long memberId) {        
       memberService.deleteMember(memberId);
  }

}

Stel een simpele REST controller die een GET request delegeert naar een service. Dit werkt niet als we in onze test new MemberController() aanroepen. De memberService is dan null. Makkelijker is het om de dependency injection van Spring zelf te gebruiken in onze test. Die integratie met Spring is hier een hulpmiddel: je had het ook op een andere manier op kunnen lossen, maar dat is omslachtiger. 

Waar we Spring wel echt nodig hebben is voor de PreAuthorize mapping annotatie. In mijn test wil ik valideren dat normale gebruiker Bob geen schrijfrechten heeft en beheerder Alice met ADMIN rechten wel. Ook hier gebruikt onze test een Spring context, maar hier doet die integratie er terdege toe. Onze specifieke configuratie van Spring bepaalt namelijk het gedrag van de applicatie, en als Bob ineens toch schrijfrechten blijkt te hebben door een foutieve instelling willen we dat een test dat afvangt. De focus van deze test is duidelijk een unit of behavior: als ik als normale gebruiker andermans account probeer te wissen krijg ik een http FORBIDDEN om mijn oren. Een dergelijke test hoort onder in de piramide en is eigenlijk een unit test, ondanks dat we moeten integreren met het Spring framework. Hij is klein, cruciaal, gedetailleerd en – belangrijker nog – valt onder ons eigen beheer. Wij zijn zelf verantwoordelijk voor de juiste configuratie.

Soms vallen afhankelijkheden niet binnen het beheer van de applicatie. Stel dat ons e-commerce platform dagelijks een bestand moet verwerken dat een logistieke dienstverlener beschikbaar stelt op hun server. Onze goede vrienden van systeembeheer kopiëren dit naar een Windows share waar onze code het kan openen. De implementatie is zo simpel als wat:

@Service
class CourierDao {
  @Value("${dal.rejected_deliveries.file_location}")
  private String file;

  List<String> getRejectedDeliveries(){
     return FileUtils.readLines(new File(file), Charset.defaultCharset());
  }
}

Er kan van alles misgaan bij het ophalen en uitlezen van een dergelijk bestand, maar het meeste valt buiten onze invloedssfeer. We kunnen een integratietest schrijven waarbij IOUtils een lokaal bestand uitleest, maar als unit of behavior is bovenstaande code erg triviaal. Er valt weinig aan te testen. FileUtils is een stabiele Apache library. Als die een fout geeft is er duidelijk iets mis met het bestand, maar de reactie op zo’n runtime fout kunnen we prima afvangen met een mock. Dat geldt ook voor de inhoud van het bestand: die is interessant, maar we hebben geen fysiek bestand nodig om de verwerkingslogica te testen. Ook testen of de geïnjecteerde bestandsnaam verwijst naar een echt bestand levert niet zoveel op: in productie zal deze toch een andere waarde hebben. We kunnen de integratie met echte bestanden voor een test als deze dus prima boven in de piramide plaatsen.

Ik schreef eerder dat we aan de basis van de piramide ten eerste de componenten moeten integreren die we zelf gecodeerd en geconfigureerd hebben. Naarmate het minder onder ons beheer valt, is er minder te testen en mag het hoger in de piramide. Waar valt dan de database onder? Dat ligt er maar aan in hoeverre die onder ons beheer valt.

Het voorbeeld zoals hierboven had er ook zo uit kunnen zien:

@Query(“Select * from dhl.rejected_deliveries“)
List<String> getRejectedDeliveries()

Als de interface naar de database van onze koeriersdienst zo simpel is dan geldt hetzelfde als voor de versie met een bestand: geen unit test nodig. Maar vaker zit de database zelf vol logica. SQL zelf is een rijke declaratieve programmeertaal. Het is code waar niet zelden ook business logica in verwerkt zit. Het maakt daarbij niet uit of die code in Java klassen staat, uit tekstbestanden wordt gelezen of vervat is in views of stored procedures op de database zelf. Het maakt ook niet uit of we een library als Hibernate gebruiken die doet alsof er geen SQL bestaat. SQL-code vormen al heel snel units of behaviour en die moeten we als een unit test benaderen. Dit betekent dat we ze in afzondering, gedetailleerd moeten kunnen aanroepen en valideren. Zeker als de code deel uitmaakt van het systeem onder test kun je het je niet permitteren de validatie daarvan uit stellen dat een globale end-to-end test.

Code die de database benadert moet je dus goed isoleren, achter een interface. Alleen zo kun je de database-interacties goed afzonderlijk testen. Op deze manier wordt het ook veel gemakkelijk om test doubles te gebruiken voor code die de database niet nodig heeft. In deel twee van deze post gaan we kijken hoe je een op maat gesneden database snel en efficiënt in je tests kunt integreren.