Article JAVA Magazine 04 -2021
Wie automatische testen schrijft, weet dat er ook een nadeel aan zit: ze moeten onderhouden worden. Waar testen eigenlijk bedoeld waren om veilig een refactoring te kunnen doen, zitten ze in de praktijk vaak meer in de weg dan dat ze er bij helpen. Kan dit ook anders? Jazeker!
Ter illustratie (zie Afbeelding 1) gebruiken we een voorbeeld van hoe een test in een gemiddelde enterprise applicatie er uit zou kunnen zien. De code is bewust klein afgedrukt, omdat het niet nodig is om alles te lezen. Het betreft in dit geval een class die de waarde van een aandelenportfolio met meerdere valuta berekent.
Afbeelding 1.
Vaak zijn er een stuk of twee, drie van dit soort testen per class. Als je de pech hebt dat iemand in je team volledige testdekking wil, dan zijn het er nog veel meer. Ik wens je sterkte met het reviewen en onderhouden van deze testen!
Voordat ik laat zien hoe je de testen beter kan structureren, bespreek ik eerst drie valkuilen die ik zie in deze en veel andere automatische testen. De eerste ligt voor de hand als je ziet dat er nog een paar van dit soort methods zijn: duplicatie.
{ Valkuil 1: duplicatie }
Tijdens het schrijven van de testen is het misschien verleidelijk om een bestaande test te kopiëren en de code een beetje aan te passen voor het nieuwe scenario, maar met het onderhouden van de testen werkt dit vrij contraproductief. Voor de lezer van de code is het niet prettig is als er veel duplicatie in zit, want:
- Het zijn meer regels code om te lezen, dus het kost doorgaans meer tijd.
- Het is saai om steeds vergelijkbare stukken te lezen. De concentratie zal daarbij afnemen en subtiele verschillen kunnen daarbij over het hoofd worden gezien.
- Het is lastiger om verschillen tussen de testen te ontdekken.
- Van elk verschil tussen de testen is het lastiger om erachter te komen of dit verschil bewust is gemaakt, of dat het ontstaan is vanwege een aanpassing, waarbij de ene test wel is bijgewerkt, maar de andere is vergeten.
- Doordat er zoveel regels code zijn, wordt het lastiger om het overzicht te hebben van wat er allemaal al getest is en wat nog niet.
- De naamgeving is doorgaans niet optimaal (bijvoorbeeld een test met de variabelen ‘company1’ en ‘company3’).
Duplicatie kan dus beter vermeden worden. Een risico daarbij is overigens wel dat de code complexer kan worden als duplicatie echt tot een minimum gebracht wordt. De testen moeten niet complexer worden dan de onderliggende code. Dus enige terughoudendheid is op zijn plaats. Toch is er vaak nog wel ruimte om duplicatie te verminderen. Bovendien kan het introduceren van een herbruikbare method de leesbaarheid juist ook vergroten. De code in de test method zelf zit dan op een wat hoger abstractieniveau dan de helper methods.
{ Valkuil 2: kennis van de implementatie }
Kijk bijvoorbeeld naar de volgende regels in de test:
ExchangeRate usdToUsd = mock(ExchangeRate.class);
when(usdToUsd.getFactor())
.thenReturn(DefaultNumberValue.of(BigDecimal.ONE));
when(exchangeRateProvider.getExchangeRate(Monetary.getCurrency(“USD”), Monetary.getCurrency(“USD”)))
.thenReturn(usdToUsd);
Dit heeft helemaal niets meer met het testscenario te maken, maar is puur een implementatiedetail. Blijkbaar heeft de huidige implementatie ook een USD naar USD conversie nodig. Vermoedelijk zal dit in vrijwel elke test terugkomen.
Een ander implementatiedetail is dat hieruit blijkt dat ExchangeRateProvider.getExchangeRate wordt aangeroepen. Ook dit is niet specifiek voor het testscenario, want een andere implementatie zou net zo goed de CurrencyConversion class kunnen gebruiken. Bij een refactoring kan de testcode dus behoorlijk in de weg zitten.
Deze valkuil kom je overigens extra vaak tegen als er vastgehouden wordt aan de gedachte dat iedere testclass exact één implementatieclass moet testen. Alle dependencies naar andere classes moeten dan worden gemockt. Die aanpak zorgt er misschien voor dat er makkelijk een hoge line coverage gehaald kan worden, maar de toegevoegde waarde van deze testen is veel lager. De testen worden dan namelijk erg low-level en ontstaan eigenlijk vanuit de productiecode. Je zou ze ook ‘synthetische testen’ kunnen noemen.
Synthetische testen vormen juist een belemmering bij het refactoren, in plaats van dat ze daarbij helpen. Wanneer er iets in de implementatie wijzigt, is de kans groot dat ook de testen aangepast moeten worden: er wordt een andere method aangeroepen, of een deel van de logica wordt verplaatst. Het gevolg is dat de ontwikkelaar gewend raakt om na het aanpassen van de implementatie ook de testcode aan te passen. Het is dan echter veel lastiger in te schatten of de betreffende wijziging ook invloed heeft op andere classes. Bij een grotere refactoring hebben de testen eigenlijk geen nut meer, en zullen ze makkelijk weggegooid worden. Gevolg: de testen borgen niet de intentie van de originele auteur.
Als je waardevolle testen wil schrijven, doe je er dus goed aan om de samenhang tussen testcode en productiecode te verminderen, zodat het eenvoudiger wordt om de code te verbeteren.
{ Valkuil 3: meerdere abstractieniveaus }
Het grootste probleem van lange methoden is dat er meestal verschillende abstractieniveaus door elkaar lopen. Voor productiecode letten we hier meestal wel (een beetje) op, maar voor testcode geldt het net zo. Een test kan op hoofdlijnen zeggen: ‘Gegeven’ een aandelenportefeuille met twee aandelen van verschillende valuta; ‘als’ de totale waarde berekend wordt ‘dan’ verwacht ik dat de valuta op de juiste manier omgezet en opgeteld is. Een statement als: `StockExchange nasdaq = new StockExchange(“NASDAQ”);` is van een heel ander abstractieniveau dat nauwelijks te mappen is op het testscenario op het hoogste abstractieniveau. Door alle details (en irrelevante parameters!) wordt de code lastig te begrijpen.
Comments zouden hierbij wellicht kunnen helpen, maar die hebben andere nadelen en zijn meestal een indicatie dat er gerefactord moet worden. Laten we eens kijken hoe de code er dan uit zou kunnen zien.
{ Refactoren (bottom-up) }
Ik laat eerst een manier zien die jullie al kennen; daarna beschrijf ik een nieuw Design Pattern dat de genoemde valkuilen nog beter oplost.
De meest voor de hand liggende manier om duplicatie te verminderen en ruis weg te filteren, is het introduceren van methods. De test zou er dan zo uit kunnen zien:
@Test
void multipleCurrencies() {
Portfolio.Builder portfolio = Portfolio.builder();
SharePriceProvider sharePriceProvider = mock(SharePriceProvider.class);
addPortfolioLine(portfolio, sharePriceProvider, 2, “USD 2.50”);
addPortfolioLine(portfolio, sharePriceProvider, 10, “EUR 100”);
ExchangeRateProvider exchangeRateProvider = mock(ExchangeRateProvider.class);
addExchangeRate(exchangeRateProvider, “EUR”, “USD 1.20”);
addExchangeRate(exchangeRateProvider, “USD”, “USD 1”);
PortfolioValueCalculator calculator = new PortfolioValueCalculator(sharePriceProvider, exchangeRateProvider);
MonetaryAmount portfolioValue = calculator.calculateValue(portfolio.build(), Monetary.getCurrency(“USD”));
assertThat(portfolioValue).isEqualTo(parseMoney(“USD 1205”));
}
Op deze manier is de test al een stuk leesbaarder dan het origineel. Toch bevat het nog steeds wat ruis: de variabelen die aangemaakt en doorgegeven worden, leiden af van waar het scenario werkelijk over gaat. In dit geval kan dat opgelost worden door de variabelen te promoten naar velden. Mijn ervaring leert dat dit echter ook niet altijd ideaal is, omdat:
- Naarmate het aantal testen groeit, ontstaat er vaak een wildgroei aan velden die bovendien niet altijd een goede naam krijgen.
- De state van het veld is niet altijd duidelijk: is het al geïnitialiseerd? Kan het een waarde hebben van een vorige test?
- De plek waar het veld wordt geïnitialiseerd, is soms direct bij de declaratie, soms in een setup methode en soms in de betreffende test of een helper method. Het is lastig om dit consistent te houden.
- Er moet vaak heel wat gescrolled worden om te zien waar het veld gedeclareerd, geïnitialiseerd en uitgelezen wordt.
Een ander nadeel van deze aanpak is dat naarmate het aantal testen groeit, er vaak ook verschillende overloads van die helper methods gemaakt moeten worden. Initieel was er wellicht al een method createPortfolio(50, “USD 10.00”), maar dat werkt weer niet wanneer je meerdere een portfolio met verschillende aandelen wil maken. Daarnaast ontstaan er ook makkelijk inconsistenties tussen testen, omdat de ene test bijvoorbeeld een helper method calculateValue(“USD”) aanroept, terwijl een andere direct de inline variant gebruikt.
Al met al signalen dat ook deze aanpak niet altijd ideaal is.
{ Tester Pattern }
Een andere aanpak is het gebruik van het Tester Pattern. In plaats van het extracten van methods vanuit de low-level code (bottom-up), begin je hier met het nadenken over wat de test zou moeten doen en werk je zo richting de implementatie (top-down).
@Test
void multipleCurrencies() {
tester().withPortfolioLine(2, “USD 2.50”)
.withPortfolioLine(10, “EUR 100”)
.withExchangeRate(“EUR”, “USD 1.20”)
.withTargetCurrency(“USD”)
.calculateValue()
.shouldBe(“USD 1205”);
}
Het Tester Pattern lijkt veel op het Builder Pattern. De methods die met ‘with’ beginnen, zetten meestal een veld en geven daarna weer ‘this’ terug, een ‘Tester’ object. In dit voorbeeld doet het wat meer, omdat het ook het stubbing gedrag moet zetten. Dit is slechts een implementatiedetail dat op deze manier netjes van het testscenario gescheiden is.
Daar waar het Builder Pattern een ‘build’ method heeft, heeft het Tester Pattern een method waar het system under test aangeroepen wordt (in het voorbeeld gebeurt dat in ‘calculateValue’). Het resultaat dat daarbij teruggegeven wordt, wordt verpakt in een simpele class die ‘Asserter’ heet, zodat je meteen door kunt chainen om de assertions te doen.
Door het Tester Pattern toe te passen, krijg je automatisch een scheiding tussen het functionele scenario en de technische aanroep. De test wordt dus minder afhankelijk van de implementatie en dat betekent dat je vrij eenvoudig kunt refactoren, zonder dat je de testen hoeft bij te werken (met alle risico’s van dien).
Een test van tientallen regels code is vaak te herschrijven naar een overzichtelijk statement van ongeveer vijf regels. Bovendien helpt de IDE je om (zonder copy-paste) makkelijk een vergelijkbaar scenario in een nieuwe test op te schrijven. Dit zijn allemaal ingrediënten waardoor je gemotiveerd wordt om meer goede testen toe te voegen.
Het Tester Pattern blijkt breed inzetbaar te zijn: zowel voor unit testen als voor integratietesten; zowel voor test-driven development als voor het herschrijven van complexe (test)code.
Als je hier zelf mee aan de slag wil, kijk dan eens op testerpattern.nl, waar het Design Pattern nog wat verder uitgewerkt staat. Veel plezier met het schrijven van onderhoudbare testen!
Bio:
Dirk Koning is softwareontwikkelaar bij NS en werkt aan het systeem waarmee de materieelplanning gemaakt wordt.