|$ curl https://forge-ai.dev/api/markdown?path=docs/testing/property-based
$cat docs/property-based-testing.md
updated Recently·30 min read·published

Property-Based Testing

TestingIntermediate🎯Free Tools
Introduction

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.

Setup & First Test
install.sh
Bash
1# Install fast-check
2npm install --save-dev fast-check
3
4# Works with any test framework
5npm install --save-dev vitest # or jest
first-test.test.ts
TypeScript
1import { it, expect } from "vitest";
2import fc from "fast-check";
3
4// Property: for any array, sorting preserves length
5it("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
18it("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
34it("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

fc.assert runs 100 random tests by default. Configure with fc.assert(property, { numRuns: 500 }) for more thorough testing.
Arbitraries

Arbitraries generate random values. fast-check provides built-in arbitraries for all common types, and you can create custom ones for domain-specific data.

arbitraries.ts
TypeScript
1import fc from "fast-check";
2
3// Built-in arbitraries
4fc.string(); // random strings
5fc.string({ minLength: 1, maxLength: 100 });
6fc.integer(); // any integer
7fc.integer({ min: 0, max: 100 });
8fc.boolean();
9fc.float();
10fc.double();
11fc.oneof(fc.constant("a"), fc.constant("b"), fc.constant("c"));
12fc.constantFrom("GET", "POST", "PUT", "DELETE");
13fc.array(fc.integer(), { minLength: 1, maxLength: 20 });
14fc.tuple(fc.string(), fc.integer());
15fc.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
22interface User {
23 id: string;
24 name: string;
25 email: string;
26 age: number;
27}
28
29const 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
37it("user validation accepts all valid users", () => {
38 fc.assert(
39 fc.property(arbitraryUser, (user) => {
40 expect(validateUser(user)).toBe(true);
41 })
42 );
43});
Shrinking

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.

shrinking.test.ts
TypeScript
1// Shrinking in action
2it("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
19const 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

Shrink toward smaller, simpler values. For numbers, that means toward 0. For strings, toward "". For arrays, toward []. fast-check does this automatically.
Common Property Patterns
patterns.test.ts
TypeScript
1// Pattern 1: Inverse operations (encode/decode roundtrip)
2it("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)
11it("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
22it("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)
31it("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
43it("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

Focus on properties, not specific values. Ask: "What must always be true?" Roundtrip consistency, idempotency, algebraic laws, and output constraints are the most productive properties to test.
When to Use Property-Based Testing

Best Candidates

Parsers, serializers, and data transformations
Sorting, searching, and data structure algorithms
Authentication, encryption, and encoding
API contracts and protocol implementations
Domain logic with complex invariants

Less Suitable

UI rendering (hard to define properties)
External API integration (non-deterministic)
Performance-critical code (use benchmarks instead)
Simple CRUD with no complex logic
$Blueprint — Engineering Documentation·Section ID: TEST-PB-01·Revision: 1.0