Ga verder dan traditionele, op voorbeelden gebaseerde tests. Deze uitgebreide gids verkent property-based testing in JavaScript met fast-check, en helpt u meer bugs te vinden met minder code.
Voorbij Voorbeelden: Een Diepgaande Duik in Property-Based Testing in JavaScript
Als softwareontwikkelaars besteden we een aanzienlijke hoeveelheid tijd aan het schrijven van tests. We creƫren zorgvuldig unit tests, integratietests en end-to-end tests om ervoor te zorgen dat onze applicaties robuust, betrouwbaar en vrij van regressies zijn. Het dominante paradigma hiervoor is op voorbeelden gebaseerd testen. We bedenken een specifieke invoer en we verwachten een specifieke uitvoer. Invoer `[1, 2, 3]` zou uitvoer `6` moeten produceren. Invoer `"hello"` zou `"HELLO"` moeten worden. Maar deze aanpak heeft een stille, verborgen zwakte: onze eigen verbeeldingskracht.
Wat als u vergeet te testen met een lege array? Een negatief getal? Een string met Unicode-tekens? Een diep genest object? Elk gemist randgeval is een potentiƫle bug die op de loer ligt. Dit is waar Property-Based Testing (PBT) ten tonele verschijnt, en een krachtige paradigmaverschuiving biedt die ons helpt om zelfverzekerdere en veerkrachtigere software te bouwen.
Deze uitgebreide gids leidt u door de wereld van property-based testing in JavaScript. We zullen onderzoeken wat het is, waarom het zo effectief is en hoe u het vandaag nog in uw projecten kunt implementeren met de populaire bibliotheek `fast-check`.
De Beperkingen van Traditioneel, op Voorbeelden Gebaseerd Testen
Laten we een eenvoudige functie bekijken die een array van getallen sorteert. Met een populair framework zoals Jest of Vitest zou onze test er als volgt uit kunnen zien:
// Een eenvoudige (en ietwat naĆÆeve) sorteerfunctie
function sortNumbers(arr) {
return [...arr].sort((a, b) => a - b);
}
// Een typische, op voorbeelden gebaseerde test
test('sortNumbers should correctly sort a simple array', () => {
const inputArray = [3, 1, 4, 1, 5, 9];
const expectedArray = [1, 1, 3, 4, 5, 9];
expect(sortNumbers(inputArray)).toEqual(expectedArray);
});
Deze test slaagt. We zouden nog een paar `it`- of `test`-blokken kunnen toevoegen:
- Een array die al gesorteerd is.
- Een array met negatieve getallen.
- Een array met een nul.
- Een lege array.
- Een array met dubbele getallen (die we al hebben behandeld).
We voelen ons goed. We hebben de basis behandeld. Maar wat hebben we gemist? Hoe zit het met `[-0, 0]`? Hoe zit het met `[Infinity, -Infinity]`? Hoe zit het met een zeer grote array die mogelijk prestatielimieten of vreemde JavaScript-engine-optimalisaties raakt? Het fundamentele probleem is dat we de data handmatig selecteren. Onze tests zijn slechts zo goed als de voorbeelden die we kunnen bedenken, en mensen zijn notoir slecht in het voorstellen van alle vreemde en wonderlijke manieren waarop data gestructureerd kan zijn.
Op voorbeelden gebaseerd testen valideert dat uw code werkt voor een paar handgekozen scenario's. Property-based testing valideert dat uw code werkt voor volledige klassen van invoer.
Wat is Property-Based Testing? Een Paradigmaverschuiving
Property-based testing draait het script om. In plaats van te beweren dat een specifieke invoer een specifieke uitvoer oplevert, definieert u een algemene eigenschap (property) van uw code die waar zou moeten zijn voor elke geldige invoer. Het testframework genereert vervolgens honderden of duizenden willekeurige invoerwaarden om te proberen uw eigenschap te weerleggen.
Een "property" is een invariantāeen abstracte regel over het gedrag van uw functie. Voor onze `sortNumbers`-functie zouden enkele eigenschappen kunnen zijn:
- Idempotentie: Het sorteren van een reeds gesorteerde array zou deze niet moeten veranderen. `sortNumbers(sortNumbers(arr))` zou hetzelfde moeten zijn als `sortNumbers(arr)`.
- Lengte-invariantie: De gesorteerde array moet dezelfde lengte hebben als de oorspronkelijke array.
- Inhoudsinvariantie: De gesorteerde array moet exact dezelfde elementen bevatten als de oorspronkelijke array, alleen in een andere volgorde.
- Volgorde: Voor elke twee aangrenzende elementen in de gesorteerde array geldt `sorted[i] <= sorted[i+1]`.
Deze aanpak verplaatst uw denkwijze van individuele voorbeelden naar het fundamentele contract van uw code. Deze mentaliteitsverandering is ongelooflijk waardevol voor het ontwerpen van betere, meer voorspelbare API's.
De Kerncomponenten van PBT
Een property-based testing framework heeft doorgaans twee belangrijke componenten:
- Generatoren (of Arbitraries): Deze zijn verantwoordelijk voor het produceren van een breed scala aan willekeurige data volgens gespecificeerde typen (integers, strings, arrays van objecten, etc.). Ze zijn slim genoeg om niet alleen "happy path"-data te genereren, maar ook lastige randgevallen zoals lege strings, `NaN`, `Infinity` en meer.
- Shrinking: Dit is het magische ingrediƫnt. Wanneer het framework een invoer vindt die uw eigenschap falsifieert (d.w.z. een testfout veroorzaakt), rapporteert het niet alleen de grote, willekeurige invoer. In plaats daarvan probeert het systematisch de kleinste en eenvoudigste invoer te vinden die de fout nog steeds veroorzaakt. Dit maakt debuggen exponentieel eenvoudiger.
Aan de Slag: PBT Implementeren met `fast-check`
Hoewel er verschillende PBT-bibliotheken in het JavaScript-ecosysteem zijn, is `fast-check` een volwassen, krachtige en goed onderhouden keuze. Het integreert naadloos met populaire testframeworks zoals Jest, Vitest, Mocha en Jasmine.
Installatie en Configuratie
Voeg eerst `fast-check` toe aan de development dependencies van uw project. We gaan ervan uit dat u een testrunner zoals Jest gebruikt.
npm install --save-dev fast-check jest
# of
yarn add --dev fast-check jest
# of
pnpm add -D fast-check jest
Uw Eerste Property-Based Test
Laten we onze `sortNumbers`-test herschrijven met `fast-check`. We zullen de "volgorde"-eigenschap testen die we eerder hebben gedefinieerd: elk element moet kleiner zijn dan of gelijk zijn aan het element dat erop volgt.
import * as fc from 'fast-check';
// Dezelfde functie als voorheen
function sortNumbers(arr) {
return [...arr].sort((a, b) => a - b);
}
test('the output of sortNumbers should be a sorted array', () => {
// 1. Beschrijf de eigenschap
fc.assert(
// 2. Definieer de arbitraries (invoergeneratoren)
fc.property(fc.array(fc.integer()), (data) => {
// `data` is een willekeurig gegenereerde array van integers
const sorted = sortNumbers(data);
// 3. Definieer het predicaat (de te controleren eigenschap)
for (let i = 0; i < sorted.length - 1; ++i) {
if (sorted[i] > sorted[i + 1]) {
return false; // De eigenschap is gefalsifieerd
}
}
return true; // De eigenschap geldt voor deze invoer
})
);
});
test('sorting should not change the array length', () => {
fc.assert(
fc.property(fc.array(fc.float()), (data) => {
const sorted = sortNumbers(data);
return sorted.length === data.length;
})
);
});
Laten we dit opsplitsen:
- `fc.assert()`: Dit is de runner. Het zal uw eigenschapcontrole vele malen uitvoeren (standaard 100 keer).
- `fc.property()`: Dit definieert de eigenschap zelf. Het neemt een of meer arbitraries als argumenten, gevolgd door een predicaatfunctie.
- `fc.array(fc.integer())`: Dit is onze arbitrary. Het vertelt `fast-check` om een array (`fc.array`) van integers (`fc.integer()`) te genereren. `fast-check` zal automatisch arrays van verschillende lengtes genereren, met verschillende integer-waarden (positief, negatief, nul, etc.).
- Het Predicaat: De anonieme functie `(data) => { ... }` is waar onze logica zich bevindt. Het ontvangt de willekeurig gegenereerde data en moet `true` retourneren als de eigenschap geldt of `false` als deze wordt geschonden. `fast-check` ondersteunt ook predicaatfuncties die een fout gooien bij een mislukking, wat goed integreert met de `expect`-assertions van Jest.
Nu hebben we, in plaats van ƩƩn test met ƩƩn handgekozen array, een test die onze sorteerlogica verifieert tegen 100 verschillende, automatisch gegenereerde arrays, elke keer dat we onze suite draaien. We hebben onze testdekking enorm vergroot met slechts een paar regels code.
Arbitraries Verkennen: De Juiste Data Genereren
De kracht van PBT ligt in het vermogen om diverse en uitdagende data te genereren. `fast-check` biedt een rijke set aan arbitraries om bijna elke datastructuur die u zich kunt voorstellen te dekken.
Basis Arbitraries
Dit zijn de bouwstenen voor uw datageneratie.
- `fc.integer()`, `fc.float()`, `fc.bigInt()`: Voor getallen. Ze kunnen worden beperkt, bijv. `fc.integer({ min: 0, max: 100 })`.
- `fc.string()`, `fc.asciiString()`, `fc.unicodeString()`: Voor strings met verschillende tekensets.
- `fc.boolean()`: Voor `true` of `false`.
- `fc.constant(value)`: Geeft altijd dezelfde waarde terug. Handig om te combineren met `fc.oneof`.
- `fc.constantFrom(val1, val2, ...)`: Geeft een van de opgegeven constante waarden terug.
Complexe en Samengestelde Arbitraries
U kunt basis-arbitraries combineren om complexe datastructuren te creƫren.
- `fc.array(arbitrary, constraints)`: Genereert een array van elementen die door de opgegeven arbitrary zijn gemaakt. U kunt de `minLength` en `maxLength` beperken.
- `fc.tuple(arb1, arb2, ...)`: Genereert een array met een vaste lengte waarbij elk element een specifiek, ander type heeft.
- `fc.object(shape)`: Genereert objecten met een gedefinieerde structuur. Voorbeeld: `fc.object({ id: fc.uuidV(4), name: fc.string() })`.
- `fc.oneof(arb1, arb2, ...)`: Genereert een waarde uit een van de opgegeven arbitraries. Dit is uitstekend voor het testen van functies die meerdere datatypen verwerken (bijv. `string | number`).
- `fc.record({ key: arb, value: arb })`: Genereert objecten die als dictionaries of maps kunnen worden gebruikt, waarbij sleutels en waarden worden gegenereerd uit arbitraries.
Aangepaste Arbitraries Creƫren met `map` en `chain`
Soms heeft u data nodig die niet in een standaardvorm past. `fast-check` stelt u in staat om uw eigen arbitraries te creƫren door bestaande te transformeren.
`.map()` gebruiken
De `.map()`-methode transformeert de uitvoer van een arbitrary naar iets anders. Laten we bijvoorbeeld een arbitrary maken die niet-lege strings genereert.
const nonEmptyStringArb = fc.string({ minLength: 1 });
// Of, door een array van karakters te transformeren
const nonAStringArb = fc.array(fc.char().filter(c => c !== 'a'))
.map(chars => chars.join(''));
`.chain()` gebruiken
De `.chain()`-methode is krachtiger. Het stelt u in staat een nieuwe arbitrary te creƫren op basis van de gegenereerde waarde van een vorige. Dit is essentieel voor het creƫren van gecorreleerde data.
Stel u voor dat u een array moet genereren en vervolgens een geldige index voor diezelfde array. U kunt dit niet doen met twee afzonderlijke arbitraries, omdat de index buiten de grenzen zou kunnen vallen. `.chain()` lost dit perfect op.
// Genereer een array en een geldige index daarvoor
const arrayAndValidIndexArb = fc.array(fc.anything()).chain(arr => {
// Maak op basis van de gegenereerde array `arr` een nieuwe arbitrary voor de index
const indexArb = fc.integer({ min: 0, max: arr.length - 1 });
// Geef een tuple terug van de array en de gegenereerde index
return fc.tuple(fc.constant(arr), indexArb);
});
// Gebruik in een test
test('slicing at a valid index should work', () => {
fc.assert(
fc.property(arrayAndValidIndexArb, ([arr, index]) => {
// Zowel `arr` als `index` zijn gegarandeerd compatibel
const sliced = arr.slice(0, index);
expect(sliced.length).toBe(index);
})
);
});
De Kracht van Shrinking: Eenvoudig Debuggen
De meest overtuigende functie van property-based testing is shrinking. Om het in actie te zien, laten we een opzettelijk foute functie maken.
// Deze functie mislukt als de invoerarray het getal 42 bevat
function sumWithoutBug(arr) {
if (arr.includes(42)) {
throw new Error('This number is not allowed!');
}
return arr.reduce((acc, val) => acc + val, 0);
}
test('sumWithoutBug should sum numbers', () => {
fc.assert(
fc.property(fc.array(fc.integer()), (data) => {
sumWithoutBug(data);
})
);
});
Wanneer u deze test uitvoert, zal `fast-check` vrijwel zeker een falend geval vinden. Maar het zal niet de eerste willekeurige array rapporteren die het vond, wat iets zou kunnen zijn als `[-1024, 500, 42, 987, -2000]`. Een dergelijk foutrapport is niet erg nuttig. U zou het handmatig moeten inspecteren om de problematische `42` te vinden.
In plaats daarvan treedt de shrinker van `fast-check` in werking. Het ziet de fout en begint de invoer te vereenvoudigen:
- Kan ik een element verwijderen? Probeer `[500, 42, 987, -2000]`. Mislukt nog steeds. Goed.
- Kan ik er nog een verwijderen? Probeer `[42, 987, -2000]`. Mislukt nog steeds.
- ...enzovoort, totdat het geen elementen meer kan verwijderen zonder de test te laten slagen.
- Het zal ook proberen de getallen kleiner te maken. Kan `42` `0` zijn? Nee, de test slaagt. Kan het `41` zijn? De test slaagt. Het verkleint de zoekruimte.
Het uiteindelijke foutrapport zal er ongeveer zo uitzien:
Fout: Eigenschap mislukt na 15 tests
{ seed: 12345678, path: "14", endOnFailure: true }
Tegenvoorbeeld: [[42]]
5 keer verkleind
Foutmelding ontvangen: This number is not allowed!
Het vertelt u de exacte, minimale invoer die de fout veroorzaakte: een array die alleen het getal `[42]` bevat. Dit wijst u onmiddellijk naar de bron van de bug, wat u enorm veel tijd en moeite bespaart bij het debuggen.
Praktische PBT-strategieƫn en Voorbeelden uit de Praktijk
PBT is niet alleen voor wiskundige functies. Het is een veelzijdig hulpmiddel dat kan worden toegepast op vele gebieden van softwareontwikkeling.
Eigenschap: Inverse Functies
Als u een functie heeft die data codeert en een andere die het decodeert, zijn ze elkaars inverse. Een geweldige eigenschap om te testen is dat het decoderen van een gecodeerde waarde altijd de oorspronkelijke waarde moet teruggeven.
// `encode` en `decode` kunnen voor base64, URI-componenten of aangepaste serialisatie zijn
function encode(obj) { return JSON.stringify(obj); }
function decode(str) { return JSON.parse(str); }
test('decode(encode(x)) should be equal to x', () => {
// `fc.jsonValue()` genereert elke geldige JSON-waarde: strings, getallen, objecten, arrays
fc.assert(
fc.property(fc.jsonValue(), (originalValue) => {
const encoded = encode(originalValue);
const decoded = decode(encoded);
expect(decoded).toEqual(originalValue);
})
);
});
Eigenschap: Idempotentie
Een operatie is idempotent als het meerdere keren toepassen ervan hetzelfde effect heeft als het eenmaal toepassen. `f(f(x)) === f(x)`. Dit is een cruciale eigenschap voor zaken als data-opschoningsfuncties of `DELETE`-eindpunten in een REST API.
// Een functie die voorloop-/volgspaties verwijdert en meerdere spaties samenvouwt
function normalizeWhitespace(text) {
return text.trim().replace(/\s+/g, ' ');
}
test('normalizeWhitespace should be idempotent', () => {
fc.assert(
fc.property(fc.string(), (originalString) => {
const once = normalizeWhitespace(originalString);
const twice = normalizeWhitespace(once);
expect(twice).toBe(once);
})
);
});
Eigenschap: Stateful (Model-Based) Testen
Dit is een meer geavanceerde maar ongelooflijk krachtige techniek voor het testen van systemen met een interne staat, zoals een UI-component, een winkelwagentje of een state machine. Het idee is om een eenvoudig softwaremodel van uw systeem te maken en een reeks commando's die zowel op uw model als op de echte implementatie kunnen worden uitgevoerd. De eigenschap is dat de staat van het model en de staat van het echte systeem altijd overeen moeten komen.
`fast-check` biedt `fc.commands` voor dit doel. Laten we een eenvoudige teller modelleren:
// De echte implementatie
class Counter {
constructor() { this.count = 0; }
increment() { this.count++; }
decrement() { this.count--; }
get() { return this.count; }
}
// De commando's voor fast-check
const incrementCmd = fc.command(
// check: een functie om te controleren of het commando op het model kan worden uitgevoerd
(model) => true,
// run: een functie om het commando uit te voeren op zowel het model als het echte systeem
(model, real) => {
model.count++;
real.increment();
expect(real.get()).toBe(model.count);
}
);
const decrementCmd = fc.command(
(model) => true,
(model, real) => {
model.count--;
real.decrement();
expect(real.get()).toBe(model.count);
}
);
test('Counter should behave according to the model', () => {
fc.assert(
fc.property(fc.commands([incrementCmd, decrementCmd]), (cmds) => {
const model = { count: 0 };
const real = new Counter();
fc.modelRun(() => ({ model, real }), cmds);
})
);
});
In deze test genereert `fast-check` een willekeurige reeks van `increment`- en `decrement`-commando's, voert deze uit op zowel ons eenvoudige objectmodel als de echte `Counter`-klasse, en zorgt ervoor dat ze nooit van elkaar afwijken. Dit kan subtiele bugs in complexe stateful logica aan het licht brengen die bijna onmogelijk te vinden zouden zijn met op voorbeelden gebaseerd testen.
Wanneer Property-Based Testing NIET te Gebruiken
PBT is een krachtige toevoeging aan uw test-toolkit, maar het is geen vervanging voor alle andere vormen van testen. Het is geen wondermiddel.
Op voorbeelden gebaseerd testen is vaak beter wanneer:
- Specifieke, bekende bedrijfsregels testen. Als een belastingberekening exact `$10,53` moet opleveren voor een specifieke invoer, is een eenvoudige, op voorbeelden gebaseerde test duidelijker en directer. Dit is een regressietest voor een bekende vereiste.
- De "eigenschap" slechts "invoer X produceert uitvoer Y" is. Als er geen abstractere, generaliseerbare regel is over het gedrag van de functie, kan het forceren van een property-based test complexer zijn dan het waard is.
- Gebruikersinterfaces testen op visuele correctheid. Hoewel u de toestandslogica van een UI-component met PBT kunt testen, kan het controleren van een specifieke visuele lay-out of stijl beter worden afgehandeld door snapshot-testen of visuele regressietools.
De meest effectieve strategie is een hybride aanpak. Gebruik property-based tests om uw algoritmen, datatransformaties en stateful logica te stresstesten tegen een universum van mogelijkheden. Gebruik traditionele, op voorbeelden gebaseerde tests om specifieke, kritieke bedrijfsvereisten vast te leggen en regressies op bekende bugs te voorkomen.
Conclusie: Denk in Eigenschappen, Niet Alleen in Voorbeelden
Property-based testing moedigt een diepgaande verschuiving aan in hoe we over correctheid denken. Het dwingt ons om afstand te nemen van individuele voorbeelden en de fundamentele principes en contracten te overwegen die onze code moet naleven. Door dit te doen, kunnen we:
- Verrassende randgevallen ontdekken waarvoor we nooit hadden bedacht tests te schrijven.
- Veel meer vertrouwen krijgen in de robuustheid van onze code.
- Expressievere tests schrijven die het gedrag van ons systeem documenteren in plaats van alleen de uitvoer voor een paar invoerwaarden.
- De debug-tijd drastisch verkorten dankzij de kracht van shrinking.
Het overstappen op property-based testing kan in het begin onwennig aanvoelen, maar de investering is het meer dan waard. Begin klein. Kies een pure functie in uw codebaseāeen die datatransformatie of een complexe berekening afhandeltāen probeer er een eigenschap voor te definiĆ«ren. Voeg ƩƩn property-based test toe aan uw volgende project. Zodra u getuige bent van de eerste niet-triviale bug die het vindt, zult u overtuigd zijn van zijn kracht om betere, betrouwbaardere software te bouwen voor een wereldwijd publiek.
Verdere Bronnen
- Officiƫle Documentatie van fast-check
- Property-Based Testing Begrijpen door Scott Wlaschin (een klassieke, taal-agnostische introductie)