Zeker weten met property-based testen

(Johannes Link) Het schrijven van een test met een tool zoals JUnit is een essentiële techniek om de kwaliteit van je code te waarborgen. Echter, wanneer een functie veel testgevallen vereist om het te controleren op alle mogelijke problemen, wordt testen omslachtig en foutgevoelig. Property-based testing (PBT) kan je redden en ontslaan van het schrijven van tientallen testcases. In dit artikel leg ik uit wat PBT is, hoe PBT op het JUnit 5-platform te gebruiken en hoe het op voorbeelden gebaseerde testen kan verbeteren en soms zelfs kan vervangen.

Example-Based Testen

Stel dat je werkt aan een Aggregator class die individuele meetwaarden ontvangt en hun frequentie bijhoudt. Om te controleren of je object werkt zoals verwacht, kun je de volgende eenvoudige JUnit 5-test gebruiken:

import java.util.*;

import org.junit.jupiter.api.*;

 

class AggregatorTests {

 

@Test

void tallyOfSeveralValues() {

Aggregator aggregator = new Aggregator();

aggregator.receive(1);

aggregator.receive(2);

aggregator.receive(3);

aggregator.receive(2);

 

Map<Integer, Integer> tally = aggregator.tally();

Assertions.assertEquals(1, (int) tally.get(1));

Assertions.assertEquals(2, (int) tally.get(2));

Assertions.assertEquals(3, (int) tally.get(1));

}

}

Dit soort tests wordt vaak example-based genoemd omdat het een concreet voorbeeld gebruikt en controleert of de geproduceerde uitvoer overeenkomt met de verwachtingen voor een specifieke situatie. De meeste ontwikkelaars hebben jaren of zelfs tientallen jaren dergelijke tests geschreven – meestal met goed gevolg voor het detecteren van veelvoorkomende programmeerfouten. Er is echter één gedachte die altijd in mijn achterhoofd is blijven knagen: hoe kan ik erop vertrouwen dat Aggregator ook voor vijf metingen werkt? Moet ik testen met 5.000 elementen, met geen of met negatieve getallen? Op een slechte dag is er geen einde aan de hoeveelheid twijfel die ik heb over mijn code – en over de code van mijn mede-ontwikkelaars.

Properties

Je kunt de kwestie van correctheid ook vanuit een andere invalshoek benaderen: onder welke randvoorwaarden en beperkingen (bijvoorbeeld het bereik van invoerparameters) moet de te testen functionaliteit leiden tot bepaalde waardes (resultaten van een berekening) en welke invarianten moeten nooit worden geschonden? De combinatie van randvoorwaarden en kwaliteiten die naar verwachting aanwezig zijn, wordt een eigenschap of property genoemd.

Laten we enkele eigenschappen voor Aggregator formuleren in gewone taal:

  • Alle gemeten waarden worden weergegeven als sleutels in een telling.
  • Waarden die nooit worden gemeten, worden niet in een telling weergegeven.
  • De som van alle tellingen is gelijk aan het aantal ontvangen metingen.
  • De volgorde van meten verandert niets.

Alle vier de zinnen doen vrij algemene uitspraken, behalve de laatste, waarvoor minimaal twee metingen nodig zijn; elke eigenschap kan worden toegepast op elke lijst met elementen, ongeacht of de lijst leeg of van enige lengte is. De metingen kunnen het hele bereik van het type Integer vullen en ze kunnen worden herhaald.

Automatiseer Property Testen

Hoe kunnen properties worden gebruikt voor automatisch testen? De statements zelf lijken te kunnen worden vertaald in code. Het formuleren van de eerste eigenschap als een Java-methode is eenvoudig, als volgt:

boolean allMeasuredValuesShowUpAsKeys(List<Integer> measurements) {

Aggregator aggregator = new Aggregator();

measurements.forEach(aggregator::receive);

return measurements.stream()

.allMatch(m -> aggregator.tally().containsKey(m));

}

Wat ontbreekt voor een echte geautomatiseerde test is een manier om een set invoerlijsten te genereren, die lijsten naar de methode te voeren en de test te laten mislukken zodra de voorwaarde false is. Dit zou allemaal kunnen worden gedaan met vanilla JUnit, maar het zou veel specifieke logica vereisen. En het kan worden gedaan voor eenvoudige gevallen, maar het zal lastig worden zodra de te genereren waarden gecompliceerder en meer domeinspecifiek worden. Daarom gebruik ik een andere testmotor: jqwik.

Als je jqwik gebruikt, heeft de vorige code alleen de volgende kleine aanpassingen nodig om een executable property te worden:

import java.util.*;

import net.jqwik.api.*;

 

class AggregatorProperties {

 

@Property

boolean allMeasuredValuesShowUpAsKeys(

@ForAll List<Integer> measurements)

{

Aggregator aggregator = new Aggregator();

measurements.forEach(aggregator::receive);

return measurements.stream()

.allMatch(m -> aggregator.tally().containsKey(m));

}

}

Laten we deze code eens nader bekijken:

  • Een property is een methode binnen een containerklasse. De methode moet een informatieve naam hebben. allMeasuredValuesShowUpAsKeys is een redelijke samenvatting van de intentie van de property.
  • Als je een methode als property-methode wil markeren, moet deze worden geannoteerd met @Property zodat IDE’s en buildtools deze als zodanig herkennen – dat wil zeggen, als ze het JUnit-platform ondersteunen.
  • Parameters toevoegen en annoteren met @ForAll vertelt jqwik dat je wil dat het framework instanties voor je genereert. Het type van een parameter, List<Integer>, wordt beschouwd als de fundamentele preconditie.
  • Het retourneren van een boolean waarde is de eenvoudigste vorm van het communiceren van de noodzakelijke conditie van een property. Als alternatief kun je elke assertion bibliotheek gebruiken, AssertJ of JUnit 5 zelf.

Het uitvoeren van een succesvolle jqwik property is net zo stil als het uitvoeren van een succesvolle JUnit-test. Als het niet anders wordt geïnstrueerd, zal jqwik elke property methode 1000 keer aanroepen met verschillende invoerparameters. Indien nodig kun je het nummer zo hoog of zo laag instellen als je wil.

Falende Properties

Om een property te zien falen, kijken we naar de derde property in de lijst (de som van alle tellingen is gelijk aan het aantal ontvangen metingen):

@Property

boolean sumOfAllCountsIsNumberOfMeasurements(

@ForAll List<Integer> measurements)

{

Aggregator aggregator = new Aggregator();

measurements.forEach(aggregator::receive);

int sumOfAllCounts =

aggregator.tally().values()

.stream().mapToInt(i -> i).sum();

return sumOfAllCounts == measurements.size();

}

Momenteel bevat de telfunctie een bug, dus elke waarde wordt maar één keer geteld. In dit geval zou jqwik een voorbeeld moeten vinden om deze bug te detecteren. En inderdaad. Hier is de uitvoer:

org.opentest4j.AssertionFailedError:

Property [AggregatorProperties:sumOfAllCountsIsNumberOfMeasurements]

falsified with sample [[0, 0]]

 

|——————–jqwik—————–

tries = 11                    | # of calls to property

checks = 11                   | # of not rejected calls

generation-mode = RANDOMIZED  | parameters are randomly generated

seed = -2353742209209314324   | random seed to reproduce generated values

 

sample = [[0, 0]]

originalSample = [[2068037359, -1987879098, 1588557220, -130517, …]]

Dat is nogal wat informatie: je kan het aantal pogingen (tries), het aantal daadwerkelijk uitgevoerde tests (checks), willekeurige startwaarde (seed), het negatieve voorbeeld (sample) en andere informatie zien die soms nuttig kan zijn.

In dit voorbeeld is jqwik erin geslaagd een bug aan het licht te brengen door lijsten met dubbele elementen te genereren. Als je de voorbeeld tests zelf had geschreven, had je misschien wel aan deze variant gedacht. Door een PBT-bibliotheek te gebruiken, kreeg je test diepte zonder dat je extra voorbeelden hoefde te bedenken. Je moet echter weten wat testen op basis van properties niet doet: het kan niet bewijzen dat een property correct is. Het enige wat het doet, is proberen voorbeelden te vinden die een property falsificeren.

Integratie van jqwik met JUnit 5

jqwik is geen op zichzelf staand framewerk. Het is eerder een test engine die aansluit op JUnit 5. JUnit 5 biedt niet alleen een gemoderniseerde aanpak voor het schrijven en uitvoeren van tests, maar is ook ontworpen als platform voor een groot aantal verschillende test engines. Het voordeel van het ontwerp van jqwik is dat IDE’s en buildtools alleen het JUnit-platform moeten integreren, niet de afzonderlijke test engines. Dit is een groot voordeel voor ontwikkelaars van test engines die zich niet bezig hoeven te houden met aspecten zoals openbare API’s om hun testspecificaties te ontdekken en uit te voeren. Test engines nemen automatisch IDE en ondersteuning voor buildtools over.

Bovendien stelt het platform ontwikkelaars in staat om een willekeurig aantal engines parallel te gebruiken. Het enige dat je hoeft te doen, is één extra afhankelijkheid toevoegen aan je Maven- of Gradle-instellingen. Momenteel worden de twee meest gebruikte Java IDE’s – IntelliJ en Eclipse – geleverd met uitstekende ondersteuning voor JUnit 5, net als Gradle en Maven.

Meer jqwik Features

Momenteel heeft jqwik alle essentiële functies die example-based testers vereisen. Veel standaardtypen kunnen bijvoorbeeld direct worden gegenereerd. Alle typen Number, String, Character en Boolean en de ingebouwde container typen List, Set, Stream, Iterator en Optional worden herkend. Je kan dus een parameter van het type Set<List<String>> hebben en jqwik zal automatisch sets met lijsten van strings voor je genereren.

Er zijn veel annotaties waarmee je het genereren van waardes rechtstreeks in de property methode signatuur kunt beïnvloeden. Daarom kun je het type Set<List<String>> als volgt aanvullen:

@ForAll @Size(3) Set<List<

@CharRange(min=’a’, max=’f’) String>>

aSetOfListsOfStrings

Het toepassen van die wijziging genereert alleen sets met een lengte van 3 die zelf lijsten met strings bevatten die alleen de tekens a tot en met f gebruiken.

Het genereren van waardes is niet volledig willekeurig. Het houdt rekening met typische randgevallen zoals lege strings, het getal 0, maxima en minima van reeksen, en een paar andere. Als je beperkingen strak genoeg zijn, genereert jqwik zelfs alle mogelijke combinaties.

Programmatische Waarde Generatie

Soms heb je te maken met klassen waarvoor jqwik geen standaard generatoren heeft. Bij andere gelegenheden zijn de domeinspecifieke beperkingen van een primitief type zo specifiek dat bestaande annotaties niet krachtig genoeg zijn. In deze gevallen kun je het beschikbaar stellen van parameter generatoren delegeren aan een andere methode in je testcontainer klasse. Het volgende voorbeeld laat zien hoe Duitse postcodes kunnen worden gegenereerd met behulp van een provider-methode:

@Property @Report(Reporting.GENERATED)

void letsGenerateZipCodes(@ForAll(“germanZipCode”) String zipCode) { }

@Provide

Arbitrary<String> germanZipCode() {

return Arbitraries.strings()

.withCharRange(‘0’, ‘9’)

.ofLength(5)

.filter(z -> !z.startsWith(“00”));

}

De String waarde van de annotatie @ForAll is een verwijzing naar de naam van een methode binnen dezelfde klasse. Deze methode moet worden geannoteerd met @Provide en moet ook een object van het type @Arbitrary<T> retourneren, waarbij T het statische type van de te verstrekken parameter is.

 

Een probleem dat zich bij willekeurige generatie voordoet, is dat de relatie tussen een willekeurig gekozen gefalsificeerd example en het probleem dat aan de falende property ten grondslag ligt vaak wordt begraven onder veel ruis.

Methoden voor het aanleveren van parameters beginnen meestal met een statische methode-aanroep naar Arbitraries en worden vaak gevolgd door een of meer filter-, toewijzings- of combinatie-acties, zoals beschreven in de volgende sectie.

Filteren, Mappen en Combineren

Als basistype voor het genereren van alle waarden, wordt de klasse Arbitrary geleverd met een paar standaardmethoden die kunnen worden gebruikt om het genereergedrag te wijzigen. Je begint meestal met een van de statische generator functies van de klasse Arbitraries. De meeste generator functies retourneren een specifiek subtype van Arbitrary dat je extra configuratiemogelijkheden geeft via een vloeiende interface.

Laten we aannemen dat je gehele getallen tussen 1 en 300 wil genereren die een veelvoud van 6 zijn. Hier zijn twee manieren om dat te bereiken:

Arbitraries.integers()

.between(1, 300)

.filter(anInt -> anInt % 6 == 0)

Of

Arbitraries.integers()

.between(1, 50)

.map(anInt -> anInt * 6)

Welke manier is beter? Soms is het alleen een kwestie van stijl of leesbaarheid. Op andere momenten kan de manier waarvoor je kiest echter de prestaties beïnvloeden. Als je de twee vorige opties vergelijkt, zie je dat de eerste dichter bij de gegeven specificatie ligt, maar het zal – door filteren – vijf van de zes van alle gegenereerde waarden weggooien. De laatste optie is daarom efficiënter, maar ook minder begrijpelijk. Meestal is het genereren van primitieve waarden zo snel dat de leesbaarheid de efficiëntie overtreft.

Echte domeinobjecten hebben vaak verschillende afzonderlijke en meestal niet-gerelateerde delen – een Person heeft bijvoorbeeld een voornaam en achternaam nodig. Daarom kan het een goed idee zijn om te starten met niet-gerelateerde basis generatoren en deze te combineren. In het volgende voorbeeld wordt een Arbitrary voor domeinklasse Person gemaakt door twee Arbitrary entiteiten in één te combineren:

@Provide
Arbitrary<Person> validPerson() {
Arbitrary<String> firstName = Arbitraries.strings()
.withCharRange(‘a’, ‘z’)
.ofMinLength(2).ofMaxLength(10)
.map(this::capitalize);
Arbitrary<String> lastName = Arbitraries.strings()
.withCharRange(‘a’, ‘z’)
.ofMinLength(2).ofMaxLength(20);
return Combinators
.combine(firstName, lastName).as(Person::new);
}

Je kunt met deze techniek maximaal acht Arbitrary eenheden in één keer combineren. Als je wilt, kun je je eigen Arbitrary eenheden registreren, zodat deze automatisch worden toegepast op alle parameters van jouw domeintype

Het Belang van Reduceren

Een probleem dat zich bij willekeurige generatie voordoet, is dat de relatie tussen een willekeurig gekozen gefalsificeerd exemplaar en het probleem dat aan de falende property ten grondslag ligt vaak wordt begraven onder veel ruis. Een eenvoudig voorbeeld kan deze zorg illustreren:

@Property(shrinking = ShrinkingMode.OFF)
boolean rootOfSquareShouldBeOriginalValue(
@Positive @ForAll int anInt )
{
int square = anInt * anInt;
return Math.sqrt(square) == anInt;
}

De property vermeldt het triviale wiskundige concept dat de vierkantswortel van een kwadraat gelijk moet zijn aan de oorspronkelijke waarde. De eerste regel schakelde reduceren uit met behulp van het shrinking annotatie attribuut. Het uitvoeren van deze property mislukt met een bericht als dit:

originalSample = [1207764160],
sample = [1207764160]

org.opentest4j.AssertionFailedError:
Property [rootOfSquareShouldBeOriginalValue] falsified with sample [1207764160]

Het falende voorbeeld gevonden door jqwik is willekeurig. Het nummer zelf geeft je geen duidelijke hint over de oorzaak van de fout. Zelfs het feit dat het vrij groot is, kan toeval zijn. Op dit moment voeg je extra logging toe of start je de debugger op voor meer informatie over het probleem.

 

PBT is gebaseerd op het idee dat je algemene en gewenste properties voor functies, componenten en hele programma’s kunt vinden, en vaak kunnen deze properties worden gefalsificeerd door het willekeurig genereren van testdata.

 

Laten we een andere aanpak kiezen door reduceren in te schakelen met (ShrinkingMode.FULL) en de property opnieuw uit te voeren. De fout is hetzelfde, maar in de rapportage wordt een verandering van het gevonden gefalsificeerde sample getoond:

sample = [46341] originalSample = [1207764160]

Het nummer 46.441 is veel kleiner en verschilt van het oorspronkelijke sample. Na falen met 1.207.764.160 bleef jqwik proberen een eenvoudiger voorbeeld te vinden dat ook zou mislukken. Deze zoekfase wordt shrinking genoemd omdat deze begint met het oorspronkelijke sample en deze kleiner en kleiner probeert te maken.

Wat is in dit geval het speciale van 46.241? Zoals je misschien al geraden hebt, is het kwadraat van 46.241 gelijk aan 2.147.488.281, wat net iets groter is dan Integer.MAX_VALUE en daarom zal leiden tot een integer overflow. Conclusie: de bovenstaande eigenschap geldt alleen voor gehele getallen tot de vierkantswortel van Integer.MAX_VALUE.

Reduceren is een belangrijk onderwerp in PBT omdat het de analyse van veel gefaalde properties een stuk eenvoudiger maakt. Het vermindert ook de hoeveelheid indeterminisme bij PBT. Het implementeren van goed reduceren is echter een ingewikkelde taak. Vanuit een theoretisch perspectief, word je geconfronteerd met een zoekprobleem met een potentieel zeer grote zoekruimte. Omdat diep zoeken tijdrovend is, worden veel heuristieken toegepast om reduceren zowel effectief als snel te maken.

Patronen om Properties te vinden

Wanneer je je eerste stappen met PBT zet, kan het vinden van geschikte properties een uitdagende taak zijn. Vergeleken met typische property voorbeelden, vereist het identificeren van properties in de echte wereld een ander soort denken. Een set nuttige patronen om je property detectie te begeleiden kan handig zijn. Gelukkig hoef je niet alle dingen zelf te ontdekken. PBT bestaat al een tijdje en er is een kleine maar bekende verzameling property gebaseerde testpatronen. Mijn persoonlijke lijst is zeker onvolledig, maar hier zijn enkele typische bronnen:

Bedrijfsregel als property. Soms kan de domeinspecificatie zelf als een property worden geïnterpreteerd en geschreven. Overweeg een bedrijfsregel zoals: Voor alle klanten met een jaarlijkse doorlooptijd groter dan X € geven we een extra korting van Y procent, als het factuurbedrag groter is dan Z €. Dit kan eenvoudig worden vertaald in een property door arbitraries voor X en Z te gebruiken en te controleren of de berekende korting inderdaad Y is.

Inverse functies. Als een functie een inverse functie heeft, moet het toepassen van de oorspronkelijke functie en vervolgens de inverse functie weer de originele invoer opleveren.

Idempotente functies. De herhaaldelijke toepassing van een idempotente functie mag de resultaten niet veranderen. Als je bijvoorbeeld een lijst een tweede keer sorteert, mag deze niet wijzigen.

Invariant functies. Sommige properties van je code veranderen niet na het toepassen van je logica. Sorteren en toewijzen mag bijvoorbeeld nooit de grootte van een verzameling wijzigen en nadat waarden uit een lijst zijn gefilterd, moeten de resterende waarden zich nog in de oorspronkelijke volgorde bevinden.

Commutativiteit. Als een set functies commutatief is, mag een wijziging van de volgorde bij het toepassen van de functies het eindresultaat niet veranderen. Sorteren en vervolgens filteren moet bijvoorbeeld hetzelfde effect hebben als filteren en vervolgens sorteren.

Een test orakel. Soms ken je een alternatieve implementatie van de functie die wordt getest. Je kunt deze implementatie vervolgens als test orakel gebruiken: elk resultaat van het gebruik van de functie moet hetzelfde zijn voor zowel de originele als de alternatieve implementatie. Hier zijn enkele voorbeelden van alternatieven:

  • Eenvoudig en langzaam versus ingewikkeld maar snel
  • Parallel versus single-threaded
  • Zelfgemaakt versus commercieel
  • Oud (voor refactoring) versus nieuw (na refactoring)

Moeilijk te berekenen, maar gemakkelijk te verifiëren. Sommige logica is moeilijk uit te voeren maar gemakkelijk te controleren. Denk bijvoorbeeld aan het vinden van priemgetallen versus het controleren van een priemgetal.

Inductie (oftewel, los een kleiner probleem eerst op). Je kunt je domein controle mogelijk verdelen in een basisgeval en een algemene regel die van dat basisgeval is afgeleid.

Stateful testen. Vooral in de objectgeoriënteerde wereld kan het gedrag van een object vaak worden beschreven als een state machine met een eindige set toestanden en acties om van toestand te veranderen. Het verkennen van de ruimte van statusovergangen is een belangrijke use case voor PBT en daarom biedt jqwik hier specifieke ondersteuning voor.

Fuzzing. Code zou nooit moeten exploderen, zelfs niet als je haar voedt met veel verschillende en onvoorziene data. Het belangrijkste idee van dit patroon is dus om een grote verscheidenheid aan invoer te genereren, de te testen functie uit te voeren en het volgende te controleren:

  • Er doen zich geen uitzonderingen voor, althans geen onverwachte.
  • Er zijn geen 5xx return codes voor HTTP-aanvragen; misschien heb je zelfs altijd de 2xx-status nodig.
  • Alle return waardes zijn geldig.
  • De looptijd is onder een acceptabele drempel.

Fuzzing wordt vaak achteraf gedaan wanneer je de robuustheid van bestaande code en systemen wil onderzoeken.

Het toepassen van deze patronen op je code vereist oefening. De patronen kunnen echter een goed uitgangspunt zijn om test writersblock te overwinnen. Hoe vaker je nadenkt over eigenschappen van je eigen code, des te meer mogelijkheden je zult herkennen om op property gebaseerde tests uit je op voorbeelden gebaseerde tests af te leiden. Soms kunnen ze als aanvulling dienen; soms kunnen ze zelfs de oude tests vervangen.

Conclusie

PBT is geen nieuwe techniek; het wordt al meer dan tien jaar effectief gebruikt in talen zoals Haskell en Erlang. PBT is gebaseerd op het idee dat je algemene en gewenste properties voor functies, componenten en hele programma’s kunt vinden, en vaak kunnen die properties worden gefalsificeerd door het willekeurig genereren van testdata.

jqwik is een op JVM gebaseerde property test engine. Omdat het is gebouwd voor het JUnit 5-platform, is integratie in alle moderne IDE’s en build-tools naadloos. Als je JUnit 5 (nog) niet gebruikt, zijn er een aantal alternatieven beschikbaar. Dit artikel licht slechts een tipje van de sluier van PBT op. Als je wat dieper wilt duiken, kun je beginnen met deze blogserie.

 

Auteur:

Johannes Link (@johanneslink) is al meer dan 25 jaar bezig met software ontwikkeling als professional. Vanaf 2001 is hij besmet met het test-driven ontwikkelen en schreef hij er een boek over. Hij was een van de core committers voor JUnit 5 gedurende het eerste jaar. Hij is ook de ontwikkelaar van jqwik.

Vertaler:

Thomas Zeeman is inmiddels al meer dan 15 jaar actief als software ontwikkelaar en heeft in die tijd als trainer ook velen geholpen om een start te maken met een carrière als ontwikkelaar.

Origineel artikel: https://blogs.oracle.com/javamagazine/know-for-sure-with-property-based-testing