Gå utover tradisjonelle eksempelbaserte tester. Denne omfattende veiledningen utforsker egenskapsbasert testing i JavaScript ved hjelp av fast-check, og hjelper deg med å finne flere feil med mindre kode.
Utover eksempler: En dypdykk i egenskapsbasert testing i JavaScript
Som programvareutviklere bruker vi mye tid på å skrive tester. Vi lager omhyggelig enhetstester, integrasjonstester og ende-til-ende-tester for å sikre at applikasjonene våre er robuste, pålitelige og fri for regresjoner. Det dominerende paradigmet for dette er eksempelbasert testing. Vi tenker på en spesifikk input, og vi hevder en spesifikk output. Input `[1, 2, 3]` skal produsere output `6`. Input `"hello"` skal bli `"HELLO"`. Men denne tilnærmingen har en stille, lurende svakhet: vår egen fantasi.
Hva om du glemmer å teste med en tom array? Et negativt tall? En streng som inneholder Unicode-tegn? Et dypt nestet objekt? Hvert ubesøkt grensetilfelle er en potensiell feil som venter på å skje. Det er her egenskapsbasert testing (PBT) kommer inn i bildet, og tilbyr et kraftig paradigmeskifte som hjelper oss med å bygge mer sikker og robust programvare.
Denne omfattende veiledningen vil ta deg gjennom verden av egenskapsbasert testing i JavaScript. Vi skal utforske hva det er, hvorfor det er så effektivt, og hvordan du kan implementere det i dine prosjekter i dag ved hjelp av det populære biblioteket `fast-check`.
Begrensningene ved tradisjonell eksempelbasert testing
La oss vurdere en enkel funksjon som sorterer en array med tall. Ved hjelp av et populært rammeverk som Jest eller Vitest, kan testen vår se slik ut:
// En enkel (og litt naiv) sorteringsfunksjon
function sortNumbers(arr) {
return [...arr].sort((a, b) => a - b);
}
// En typisk eksempelbasert test
test('sortNumbers skal sortere en enkel array riktig', () => {
const inputArray = [3, 1, 4, 1, 5, 9];
const expectedArray = [1, 1, 3, 4, 5, 9];
expect(sortNumbers(inputArray)).toEqual(expectedArray);
});
Denne testen passerer. Vi kan legge til noen flere `it`- eller `test`-blokker:
- En array som allerede er sortert.
- En array med negative tall.
- En array med en null.
- En tom array.
- En array med dupliserte tall (som vi allerede har dekket).
Vi føler oss bra. Vi har dekket det grunnleggende. Men hva har vi gått glipp av? Hva med `[-0, 0]`? Hva med `[Infinity, -Infinity]`? Hva med en veldig stor array som kan treffe ytelsesgrenser eller merkelige JavaScript-motoroptimaliseringer? Det grunnleggende problemet er at vi manuelt velger dataene. Testene våre er bare så gode som eksemplene vi kan tenke oss, og mennesker er notorisk dårlige til å forestille seg alle de rare og fantastiske måtene data kan struktureres på.
Eksempelbasert testing validerer at koden din fungerer for noen få håndplukkede scenarier. Egenskapsbasert testing validerer at koden din fungerer for hele klasser av input.
Hva er egenskapsbasert testing? Et paradigmeskifte
Egenskapsbasert testing snur manuset. I stedet for å hevde at en spesifikk input gir en spesifikk output, definerer du en generell egenskap for koden din som skal være gyldig for enhver gyldig input. Testrammeverket genererer deretter hundrevis eller tusenvis av tilfeldige input for å prøve å bevise at egenskapen din er feil.
En "egenskap" er en invariant – en regel på høyt nivå om funksjonens oppførsel. For vår `sortNumbers`-funksjon kan noen egenskaper være:
- Idempotens: Sortering av en allerede sortert array skal ikke endre den. `sortNumbers(sortNumbers(arr))` skal være det samme som `sortNumbers(arr)`.
- Lengdeinvarians: Den sorterte arrayen skal ha samme lengde som den opprinnelige arrayen.
- Innholdsinvarians: Den sorterte arrayen skal inneholde nøyaktig de samme elementene som den opprinnelige arrayen, bare i en annen rekkefølge.
- Rekkefølge: For to tilstøtende elementer i den sorterte arrayen, `sorted[i] <= sorted[i+1]`.
Denne tilnærmingen flytter deg fra å tenke på individuelle eksempler til å tenke på den grunnleggende kontrakten for koden din. Dette skiftet i tankesett er utrolig verdifullt for å designe bedre, mer forutsigbare API-er.
Kjernekomponentene i PBT
Et egenskapsbasert testrammeverk har vanligvis to nøkkelkomponenter:
- Generatorer (eller arbitrære): Disse er ansvarlige for å produsere et bredt spekter av tilfeldige data i henhold til spesifiserte typer (heltall, strenger, arrayer av objekter osv.). De er smarte nok til å generere ikke bare "happy path"-data, men også vanskelige grensetilfeller som tomme strenger, `NaN`, `Infinity` og mer.
- Shrinking: Dette er den magiske ingrediensen. Når rammeverket finner en input som falsifiserer egenskapen din (dvs. forårsaker en testfeil), rapporterer det ikke bare den store, tilfeldige inputen. I stedet prøver den systematisk å finne den minste og enkleste inputen som fortsatt forårsaker feilen. Dette gjør feilsøking eksponentielt enklere.
Kom i gang: Implementere PBT med `fast-check`
Mens det finnes flere PBT-biblioteker i JavaScript-økosystemet, er `fast-check` et modent, kraftig og godt vedlikeholdt valg. Det integreres sømløst med populære testrammeverk som Jest, Vitest, Mocha og Jasmine.
Installasjon og oppsett
Først, legg til `fast-check` i prosjektets utviklingsavhengigheter. Vi antar at du bruker en testkjører som Jest.
npm install --save-dev fast-check jest
# or
yarn add --dev fast-check jest
# or
pnpm add -D fast-check jest
Din første egenskapsbaserte test
La oss omskrive `sortNumbers`-testen vår ved hjelp av `fast-check`. Vi vil teste "rekkefølge"-egenskapen vi definerte tidligere: hvert element skal være mindre enn eller lik det som følger det.
import * as fc from 'fast-check';
// Den samme funksjonen som før
function sortNumbers(arr) {
return [...arr].sort((a, b) => a - b);
}
test('utgangen av sortNumbers skal være en sortert array', () => {
// 1. Beskriv egenskapen
fc.assert(
// 2. Definer de arbitrære (inputgeneratorer)
fc.property(fc.array(fc.integer()), (data) => {
// `data` er en tilfeldig generert array av heltall
const sorted = sortNumbers(data);
// 3. Definer predikatet (egenskapen som skal sjekkes)
for (let i = 0; i < sorted.length - 1; ++i) {
if (sorted[i] > sorted[i + 1]) {
return false; // Egenskapen er falsifisert
}
}
return true; // Egenskapen gjelder for denne inputen
})
);
});
test('sortering skal ikke endre arraylengden', () => {
fc.assert(
fc.property(fc.array(fc.float()), (data) => {
const sorted = sortNumbers(data);
return sorted.length === data.length;
})
);
});
La oss bryte dette ned:
- `fc.assert()`: Dette er løperen. Den vil utføre egenskapskontrollen din mange ganger (100 som standard).
- `fc.property()`: Dette definerer selve egenskapen. Den tar en eller flere arbitrære som argumenter, etterfulgt av en predikatfunksjon.
- `fc.array(fc.integer())`: Dette er vår arbitrære. Det forteller `fast-check` å generere en array (`fc.array`) av heltall (`fc.integer()`). `fast-check` vil automatisk generere arrayer av forskjellige lengder, med forskjellige heltallsverdier (positive, negative, null osv.).
- Predikatet: Den anonyme funksjonen `(data) => { ... }` er der logikken vår lever. Den mottar de tilfeldig genererte dataene og må returnere `true` hvis egenskapen gjelder eller `false` hvis den er brutt. `fast-check` støtter også predikatfunksjoner som kaster en feil ved feil, noe som integreres fint med Jests `expect`-påstander.
Nå, i stedet for én test med én håndplukket array, har vi en test som bekrefter sorteringslogikken vår mot 100 forskjellige, automatisk genererte arrayer hver gang vi kjører suiten vår. Vi har massivt økt testdekningen vår med bare noen få linjer med kode.
Utforske arbitrære: Generere de riktige dataene
Kraften i PBT ligger i dens evne til å generere mangfoldig og utfordrende data. `fast-check` tilbyr et rikt sett med arbitrære for å dekke nesten hvilken som helst datastruktur du kan tenke deg.
Grunnleggende arbitrære
Dette er byggeklossene for datagenereringen din.
- `fc.integer()`, `fc.float()`, `fc.bigInt()`: For tall. De kan begrenses, f.eks. `fc.integer({ min: 0, max: 100 })`.
- `fc.string()`, `fc.asciiString()`, `fc.unicodeString()`: For strenger av forskjellige tegnsett.
- `fc.boolean()`: For `true` eller `false`.
- `fc.constant(value)`: Returnerer alltid den samme verdien. Nyttig for blanding med `fc.oneof`.
- `fc.constantFrom(val1, val2, ...)`: Returnerer en av de oppgitte konstante verdiene.
Komplekse og sammensatte arbitrære
Du kan kombinere grunnleggende arbitrære for å lage komplekse datastrukturer.
- `fc.array(arbitrary, constraints)`: Genererer en array av elementer opprettet av den oppgitte arbitrære. Du kan begrense `minLength` og `maxLength`.
- `fc.tuple(arb1, arb2, ...)`: Genererer en array med fast lengde der hvert element har en spesifikk, annen type.
- `fc.object(shape)`: Genererer objekter med en definert struktur. Eksempel: `fc.object({ id: fc.uuidV(4), name: fc.string() })`.
- `fc.oneof(arb1, arb2, ...)`: Genererer en verdi fra en av de oppgitte arbitrære. Dette er utmerket for testing av funksjoner som håndterer flere datatyper (f.eks. `string | number`).
- `fc.record({ key: arb, value: arb })`: Genererer objekter som skal brukes som ordbøker eller kart, der nøkler og verdier genereres fra arbitrære.
Opprette egendefinerte arbitrære med `map` og `chain`
Noen ganger trenger du data som ikke passer en standard form. `fast-check` lar deg lage dine egne arbitrære ved å transformere eksisterende.
Bruke `.map()`
Metoden `.map()` transformerer utdataene fra en arbitrær til noe annet. La oss for eksempel lage en arbitrær som genererer ikke-tomme strenger.
const nonEmptyStringArb = fc.string({ minLength: 1 });
// Eller, ved å transformere en array av tegn
const nonAStringArb = fc.array(fc.char().filter(c => c !== 'a'))
.map(chars => chars.join(''));
Bruke `.chain()`
Metoden `.chain()` er kraftigere. Den lar deg opprette en ny arbitrær basert på den genererte verdien av en tidligere. Dette er viktig for å lage korrelerte data.
Tenk deg at du trenger å generere en array og deretter en gyldig indeks for den samme arrayen. Du kan ikke gjøre dette med to separate arbitrære, siden indeksen kan være utenfor grensene. `.chain()` løser dette perfekt.
// Generer en array og en gyldig indeks i den
const arrayAndValidIndexArb = fc.array(fc.anything()).chain(arr => {
// Basert på den genererte arrayen `arr`, opprett en ny arbitrær for indeksen
const indexArb = fc.integer({ min: 0, max: arr.length - 1 });
// Returner en tuple av arrayen og den genererte indeksen
return fc.tuple(fc.constant(arr), indexArb);
});
// Bruk i en test
test('slicing ved en gyldig indeks skal fungere', () => {
fc.assert(
fc.property(arrayAndValidIndexArb, ([arr, index]) => {
// Både `arr` og `index` er garantert å være kompatible
const sliced = arr.slice(0, index);
expect(sliced.length).toBe(index);
})
);
});
Kraften i shrinking: Feilsøking gjort enkelt
Den desidert mest overbevisende funksjonen ved egenskapsbasert testing er shrinking. For å se det i aksjon, la oss lage en bevisst buggy funksjon.
// Denne funksjonen mislykkes hvis input-arrayen inneholder tallet 42
function sumWithoutBug(arr) {
if (arr.includes(42)) {
throw new Error('Dette tallet er ikke tillatt!');
}
return arr.reduce((acc, val) => acc + val, 0);
}
test('sumWithoutBug skal summere tall', () => {
fc.assert(
fc.property(fc.array(fc.integer()), (data) => {
sumWithoutBug(data);
})
);
});
Når du kjører denne testen, vil `fast-check` nesten helt sikkert finne et mislykket tilfelle. Men det vil ikke rapportere den første tilfeldige arrayen den fant, som kan være noe slikt som `[-1024, 500, 42, 987, -2000]`. En feilrapport som det er ikke veldig nyttig. Du må manuelt inspisere den for å finne den problematiske `42`.
I stedet vil `fast-check` sin shrinker slå inn. Den vil se feilen og begynne å forenkle inputen:
- Kan jeg fjerne et element? Prøv `[500, 42, 987, -2000]`. Feiler fortsatt. Bra.
- Kan jeg fjerne en annen? Prøv `[42, 987, -2000]`. Feiler fortsatt.
- ...og så videre, til den ikke kan fjerne flere elementer uten å få testen til å passere.
- Den vil også prøve å gjøre tallene mindre. Kan `42` være `0`? Nei, testen passerer. Kan den være `41`? Testen passerer. Den snevrer det ned.
Den endelige feilrapporten vil se omtrent slik ut:
Error: Property failed after 15 tests
{ seed: 12345678, path: "14", endOnFailure: true }
Counterexample: [[42]]
Shrunk 5 time(s)
Got error: This number is not allowed!
Det forteller deg den nøyaktige, minimale inputen som forårsaket feilen: en array som bare inneholder tallet `[42]`. Dette peker deg umiddelbart til kilden til feilen, og sparer deg for enorm tid og krefter i feilsøking.
Praktiske PBT-strategier og eksempler fra virkeligheten
PBT er ikke bare for matematiske funksjoner. Det er et allsidig verktøy som kan brukes på mange områder av programvareutvikling.
Egenskap: Inverse funksjoner
Hvis du har en funksjon som koder data og en annen som dekoder den, er de inverser av hverandre. En flott egenskap å teste er at dekoding av en kodet verdi alltid skal returnere den opprinnelige verdien.
// `encode` og `decode` kan være for base64, URI-komponenter eller tilpasset serialisering
function encode(obj) { return JSON.stringify(obj); }
function decode(str) { return JSON.parse(str); }
test('decode(encode(x)) skal være lik x', () => {
// `fc.jsonValue()` genererer en hvilken som helst gyldig JSON-verdi: strenger, tall, objekter, arrayer
fc.assert(
fc.property(fc.jsonValue(), (originalValue) => {
const encoded = encode(originalValue);
const decoded = decode(encoded);
expect(decoded).toEqual(originalValue);
})
);
});
Egenskap: Idempotens
En operasjon er idempotent hvis det å bruke den flere ganger har samme effekt som å bruke den én gang. `f(f(x)) === f(x)`. Dette er en avgjørende egenskap for ting som datarensingsfunksjoner eller `DELETE`-endepunkter i et REST API.
// En funksjon som fjerner ledende/etterfølgende mellomrom og kollapser flere mellomrom
function normalizeWhitespace(text) {
return text.trim().replace(/\s+/g, ' ');
}
test('normalizeWhitespace skal være idempotent', () => {
fc.assert(
fc.property(fc.string(), (originalString) => {
const once = normalizeWhitespace(originalString);
const twice = normalizeWhitespace(once);
expect(twice).toBe(once);
})
);
});
Egenskap: Stateful (modellbasert) testing
Dette er en mer avansert, men utrolig kraftig teknikk for testing av systemer med intern tilstand, som en UI-komponent, en handlekurv eller en tilstandsmaskin. Tanken er å lage en enkel programvaremodell av systemet ditt og en serie kommandoer som kan kjøres mot både modellen og den virkelige implementeringen. Egenskapen er at tilstanden til modellen og tilstanden til det virkelige systemet alltid skal samsvare.
`fast-check` tilbyr `fc.commands` for dette formålet. La oss modellere en enkel teller:
// Den virkelige implementeringen
class Counter {
constructor() { this.count = 0; }
increment() { this.count++; }
decrement() { this.count--; }
get() { return this.count; }
}
// Kommandoene for fast-check
const incrementCmd = fc.command(
// check: en funksjon for å sjekke om kommandoen kan kjøres på modellen
(model) => true,
// run: en funksjon for å kjøre kommandoen på både modellen og det virkelige systemet
(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 skal oppføre seg i henhold til modellen', () => {
fc.assert(
fc.property(fc.commands([incrementCmd, decrementCmd]), (cmds) => {
const model = { count: 0 };
const real = new Counter();
fc.modelRun(() => ({ model, real }), cmds);
})
);
});
I denne testen vil `fast-check` generere en tilfeldig sekvens av `increment`- og `decrement`-kommandoer, kjøre dem mot både vår enkle objektmodell og den virkelige `Counter`-klassen, og sikre at de aldri divergerer. Dette kan avdekke subtile feil i kompleks stateful logikk som ville være nesten umulig å finne med eksempelbasert testing.
Når du IKKE skal bruke egenskapsbasert testing
PBT er et kraftig tillegg til testverktøykassen din, men det er ikke en erstatning for alle andre former for testing. Det er ikke en sølvkule.
Eksempelbasert testing er ofte bedre når:
- Testing av spesifikke, kjente forretningsregler. Hvis en skatteberegning må produsere nøyaktig `$10.53` for en spesifikk input, er en enkel eksempelbasert test klarere og mer direkte. Dette er en regresjonstest for et kjent krav.
- "Egenskapen" er bare "input X produserer output Y". Hvis det ikke er noen generell regel på høyere nivå om funksjonens oppførsel, kan det å tvinge en egenskapsbasert test være mer kompleks enn det er verdt.
- Testing av brukergrensesnitt for visuell korrekthet. Mens du kan teste tilstandslogikken til en UI-komponent med PBT, håndteres sjekking av en spesifikk visuell layout eller stil bedre av snapshot-testing eller visuelle regresjonsverktøy.
Den mest effektive strategien er en hybridtilnærming. Bruk egenskapsbaserte tester for å stressteste algoritmene dine, datatransformasjonene og stateful logikk mot et univers av muligheter. Bruk tradisjonelle eksempelbaserte tester for å feste ned spesifikke, kritiske forretningskrav og forhindre regresjoner på kjente feil.
Konklusjon: Tenk i egenskaper, ikke bare eksempler
Egenskapsbasert testing oppmuntrer til et dyptgående skifte i hvordan vi tenker om korrekthet. Det tvinger oss til å tre tilbake fra individuelle eksempler og vurdere de grunnleggende prinsippene og kontraktene koden vår skal opprettholde. Ved å gjøre det kan vi:
- Avdekke overraskende grensetilfeller som vi aldri ville ha tenkt å skrive tester for.
- Få mye høyere tillit til robustheten til koden vår.
- Skrive mer uttrykksfulle tester som dokumenterer oppførselen til systemet vårt i stedet for bare outputen på noen få input.
- Dramatisk redusere feilsøkingstiden takket være kraften i shrinking.
Å ta i bruk egenskapsbasert testing kan føles uvant i begynnelsen, men investeringen er vel verdt det. Start i det små. Velg en ren funksjon i kodebasen din – en som håndterer datatransformasjon eller en kompleks beregning – og prøv å definere en egenskap for den. Legg til en egenskapsbasert test i ditt neste prosjekt. Når du er vitne til at den finner sin første ikke-trivielle feil, vil du bli overbevist om dens kraft til å bygge bedre, mer pålitelig programvare for et globalt publikum.
Ytterligere ressurser
- fast-check Offisiell dokumentasjon
- Understanding Property-Based Testing av Scott Wlaschin (en klassisk, språkagnostisk introduksjon)