Property-Based Testing
Property-based testing defines properties (invariants) that your code must satisfy for all valid inputs, then generates hundreds of random inputs to verify those properties. Instead of writing expect(sort([3,1,2])).toEqual([1,2,3]), you write for any array, sort preserves length and produces a non-decreasing sequence.
The key advantage: property-based tests find edge cases that example-based tests miss, because the framework generates inputs you wouldn't think to test.
| 1 | # Install fast-check |
| 2 | npm install --save-dev fast-check |
| 3 | |
| 4 | # Works with any test framework |
| 5 | npm install --save-dev vitest # or jest |
| 1 | import { it, expect } from "vitest"; |
| 2 | import fc from "fast-check"; |
| 3 | |
| 4 | // Property: for any array, sorting preserves length |
| 5 | it("sort preserves array length", () => { |
| 6 | fc.assert( |
| 7 | fc.property( |
| 8 | fc.array(fc.integer()), |
| 9 | (arr) => { |
| 10 | const sorted = [...arr].sort((a, b) => a - b); |
| 11 | return sorted.length === arr.length; |
| 12 | } |
| 13 | ) |
| 14 | ); |
| 15 | }); |
| 16 | |
| 17 | // Property: sorting produces non-decreasing sequence |
| 18 | it("sort produces ordered output", () => { |
| 19 | fc.assert( |
| 20 | fc.property( |
| 21 | fc.array(fc.integer()), |
| 22 | (arr) => { |
| 23 | const sorted = [...arr].sort((a, b) => a - b); |
| 24 | for (let i = 1; i < sorted.length; i++) { |
| 25 | if (sorted[i] < sorted[i - 1]) return false; |
| 26 | } |
| 27 | return true; |
| 28 | } |
| 29 | ) |
| 30 | ); |
| 31 | }); |
| 32 | |
| 33 | // Property: reversing twice gives original array |
| 34 | it("double reverse is identity", () => { |
| 35 | fc.assert( |
| 36 | fc.property( |
| 37 | fc.array(fc.integer()), |
| 38 | (arr) => { |
| 39 | const reversed = [...arr].reverse().reverse(); |
| 40 | return JSON.stringify(reversed) === JSON.stringify(arr); |
| 41 | } |
| 42 | ) |
| 43 | ); |
| 44 | }); |
info
Arbitraries generate random values. fast-check provides built-in arbitraries for all common types, and you can create custom ones for domain-specific data.
| 1 | import fc from "fast-check"; |
| 2 | |
| 3 | // Built-in arbitraries |
| 4 | fc.string(); // random strings |
| 5 | fc.string({ minLength: 1, maxLength: 100 }); |
| 6 | fc.integer(); // any integer |
| 7 | fc.integer({ min: 0, max: 100 }); |
| 8 | fc.boolean(); |
| 9 | fc.float(); |
| 10 | fc.double(); |
| 11 | fc.oneof(fc.constant("a"), fc.constant("b"), fc.constant("c")); |
| 12 | fc.constantFrom("GET", "POST", "PUT", "DELETE"); |
| 13 | fc.array(fc.integer(), { minLength: 1, maxLength: 20 }); |
| 14 | fc.tuple(fc.string(), fc.integer()); |
| 15 | fc.record({ |
| 16 | name: fc.string({ minLength: 1 }), |
| 17 | age: fc.integer({ min: 0, max: 150 }), |
| 18 | email: fc.emailAddress(), |
| 19 | }); |
| 20 | |
| 21 | // Custom arbitrary: generate valid user objects |
| 22 | interface User { |
| 23 | id: string; |
| 24 | name: string; |
| 25 | email: string; |
| 26 | age: number; |
| 27 | } |
| 28 | |
| 29 | const arbitraryUser: fc.Arbitrary<User> = fc.record({ |
| 30 | id: fc.uuid(), |
| 31 | name: fc.string({ minLength: 1, maxLength: 50 }), |
| 32 | email: fc.emailAddress(), |
| 33 | age: fc.integer({ min: 0, max: 150 }), |
| 34 | }); |
| 35 | |
| 36 | // Use it in a test |
| 37 | it("user validation accepts all valid users", () => { |
| 38 | fc.assert( |
| 39 | fc.property(arbitraryUser, (user) => { |
| 40 | expect(validateUser(user)).toBe(true); |
| 41 | }) |
| 42 | ); |
| 43 | }); |
When a property fails, fast-check automatically shrinks the failing input to the minimal case. This makes debugging much easier — you get the smallest input that reproduces the bug.
| 1 | // Shrinking in action |
| 2 | it("parser handles all valid JSON strings", () => { |
| 3 | fc.assert( |
| 4 | fc.property( |
| 5 | fc.jsonValue(), |
| 6 | (json) => { |
| 7 | const str = JSON.stringify(json); |
| 8 | const parsed = parseJSON(str); |
| 9 | expect(parsed).toEqual(json); |
| 10 | } |
| 11 | ) |
| 12 | ); |
| 13 | // If parsing fails, fast-check will shrink to: |
| 14 | // fc.jsonValue() -> smallest JSON value that fails |
| 15 | // e.g., "" (empty string) or a specific edge case |
| 16 | }); |
| 17 | |
| 18 | // Custom shrinking for domain types |
| 19 | const sortedArray: fc.Arbitrary<number[]> = fc |
| 20 | .array(fc.integer({ min: 0, max: 100 })) |
| 21 | .map((arr) => [...arr].sort((a, b) => a - b)) |
| 22 | .filter((arr) => arr.length > 0); |
| 23 | |
| 24 | // The .map() transforms the generated value |
| 25 | // fast-check still shrinks the underlying array |
| 26 | // before applying the map function |
info
| 1 | // Pattern 1: Inverse operations (encode/decode roundtrip) |
| 2 | it("base64 encode/decode is identity", () => { |
| 3 | fc.assert( |
| 4 | fc.property(fc.string(), (str) => { |
| 5 | expect(atob(btoa(str))).toBe(str); |
| 6 | }) |
| 7 | ); |
| 8 | }); |
| 9 | |
| 10 | // Pattern 2: Idempotency (applying twice = once) |
| 11 | it("sort is idempotent", () => { |
| 12 | fc.assert( |
| 13 | fc.property(fc.array(fc.integer()), (arr) => { |
| 14 | const once = [...arr].sort((a, b) => a - b); |
| 15 | const twice = [...once].sort((a, b) => a - b); |
| 16 | expect(twice).toEqual(once); |
| 17 | }) |
| 18 | ); |
| 19 | }); |
| 20 | |
| 21 | // Pattern 3: Algebraic properties |
| 22 | it("string concat: (a + b) + c === a + (b + c)", () => { |
| 23 | fc.assert( |
| 24 | fc.property(fc.string(), fc.string(), fc.string(), (a, b, c) => { |
| 25 | expect((a + b) + c).toBe(a + (b + c)); |
| 26 | }) |
| 27 | ); |
| 28 | }); |
| 29 | |
| 30 | // Pattern 4: Post-condition (output meets criteria) |
| 31 | it("hash function produces consistent output", () => { |
| 32 | fc.assert( |
| 33 | fc.property(fc.string(), (input) => { |
| 34 | const hash1 = computeHash(input); |
| 35 | const hash2 = computeHash(input); |
| 36 | expect(hash1).toBe(hash2); // Deterministic |
| 37 | expect(hash1.length).toBeGreaterThan(0); // Non-empty |
| 38 | }) |
| 39 | ); |
| 40 | }); |
| 41 | |
| 42 | // Pattern 5: Model-based testing |
| 43 | it("stack push/pop invariant", () => { |
| 44 | fc.assert( |
| 45 | fc.property( |
| 46 | fc.array(fc.constantFrom( |
| 47 | { type: "push" as const, value: fc.integer() }, |
| 48 | )), |
| 49 | (commands) => { |
| 50 | // model: stack size matches net pushes - pops |
| 51 | } |
| 52 | ) |
| 53 | ); |
| 54 | }); |
best practice