|$ 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

◆Testing◆Intermediate🎯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

Community

Get help on Slack, Discord or VIP

Stuck on a guide? Join the community and ask.