Property-Based Testing
Property-Based Testing with fast-check
Global Watch uses fast-check for property-based testing. Unlike traditional unit tests that verify specific examples, property-based tests verify that certain properties hold true across all valid inputs.
Why Property-Based Testing?
Traditional unit tests check specific examples:
// Traditional unit test
it('should add two numbers', () => {
expect(add(2, 3)).toBe(5);
expect(add(0, 0)).toBe(0);
expect(add(-1, 1)).toBe(0);
});Property-based tests verify universal properties:
// Property-based test
it('should be commutative', () => {
fc.assert(
fc.property(fc.integer(), fc.integer(), (a, b) => {
return add(a, b) === add(b, a);
})
);
});Benefits
- Find edge cases - Automatically discovers inputs you didn't think of
- Better coverage - Tests hundreds of inputs per property
- Document invariants - Properties serve as executable specifications
- Shrinking - When a test fails, fast-check finds the minimal failing case
Getting Started
Installation
fast-check is included in the project dependencies:
pnpm add -D fast-checkBasic Usage
import { describe, it, expect } from 'vitest';
import * as fc from 'fast-check';
describe('String utilities', () => {
it('reverse(reverse(s)) === s', () => {
fc.assert(
fc.property(fc.string(), (s) => {
return reverse(reverse(s)) === s;
})
);
});
});Writing Property Tests
Property Structure
A property test has three parts:
- Arbitraries - Generators for random test data
- Property function - The invariant to verify
- Assertion - Usually implicit (return true/false)
fc.assert(
fc.property(
// 1. Arbitraries (generators)
fc.string(),
fc.integer({ min: 0, max: 100 }),
// 2. Property function
(str, num) => {
// 3. Return true if property holds
return someCondition(str, num);
}
),
// Optional: configuration
{ numRuns: 100 }
);Common Arbitraries
fast-check provides many built-in arbitraries:
// Primitives
fc.integer() // Any integer
fc.integer({ min: 0, max: 100 }) // Bounded integer
fc.nat() // Natural number (>= 0)
fc.float() // Floating point
fc.boolean() // true or false
fc.string() // Any string
fc.string({ minLength: 1 }) // Non-empty string
// Collections
fc.array(fc.integer()) // Array of integers
fc.array(fc.string(), { minLength: 1, maxLength: 10 })
fc.set(fc.integer()) // Set of integers
fc.dictionary(fc.string(), fc.integer())
// Objects
fc.record({
name: fc.string(),
age: fc.nat(),
email: fc.emailAddress(),
})
// Special
fc.uuid() // UUID v4
fc.date() // Date object
fc.emailAddress() // Valid email
fc.ipV4() // IPv4 address
fc.json() // Valid JSONCustom Arbitraries
Create domain-specific generators:
// Custom arbitrary for Project
const projectArbitrary = fc.record({
id: fc.uuid(),
name: fc.string({ minLength: 1, maxLength: 100 }),
accountId: fc.uuid(),
geometry: fc.option(polygonArbitrary),
status: fc.constantFrom('active', 'archived'),
createdAt: fc.date(),
});
// Custom arbitrary for valid email
const validEmailArbitrary = fc
.tuple(
fc.string({ minLength: 1, maxLength: 20 }).filter(s => /^[a-z]+$/.test(s)),
fc.constantFrom('example.com', 'test.org', 'mail.net')
)
.map(([local, domain]) => `${local}@${domain}`);
// Custom arbitrary for GeoJSON polygon
const polygonArbitrary = fc
.array(
fc.tuple(
fc.float({ min: -180, max: 180 }), // longitude
fc.float({ min: -90, max: 90 }) // latitude
),
{ minLength: 4, maxLength: 10 }
)
.map((coords) => ({
type: 'Polygon',
coordinates: [[...coords, coords[0]]], // Close the ring
}));Property Patterns
1. Roundtrip Properties
Verify that encoding/decoding are inverses:
describe('JSON serialization', () => {
it('fromJSON(toJSON(entity)) === entity', () => {
fc.assert(
fc.property(projectArbitrary, (project) => {
const json = project.toJSON();
const restored = Project.fromJSON(json);
return deepEqual(project, restored);
})
);
});
});
describe('URL encoding', () => {
it('decode(encode(s)) === s', () => {
fc.assert(
fc.property(fc.string(), (s) => {
return decodeURIComponent(encodeURIComponent(s)) === s;
})
);
});
});2. Invariant Properties
Verify that operations maintain invariants:
describe('Hectares value object', () => {
it('should always be non-negative', () => {
fc.assert(
fc.property(fc.float({ min: 0 }), (value) => {
const result = Hectares.create(value);
if (result.ok) {
return result.value.toNumber() >= 0;
}
return true; // Invalid input is ok
})
);
});
it('add should increase or maintain value', () => {
fc.assert(
fc.property(
fc.float({ min: 0, max: 1000 }),
fc.float({ min: 0, max: 1000 }),
(a, b) => {
const ha = Hectares.create(a).value!;
const hb = Hectares.create(b).value!;
const sum = ha.add(hb);
return sum.toNumber() >= ha.toNumber();
}
)
);
});
});3. Idempotent Properties
Verify that applying an operation twice has the same effect as once:
describe('Project.archive()', () => {
it('archive is idempotent (conceptually)', () => {
fc.assert(
fc.property(projectArbitrary, (projectData) => {
const project1 = Project.create(projectData);
const project2 = Project.create(projectData);
project1.archive();
project2.archive();
// Second archive would throw, but state is same
return project1.isArchived === project2.isArchived;
})
);
});
});4. Commutativity Properties
Verify that order doesn't matter:
describe('Set operations', () => {
it('union is commutative', () => {
fc.assert(
fc.property(
fc.set(fc.integer()),
fc.set(fc.integer()),
(a, b) => {
const union1 = new Set([...a, ...b]);
const union2 = new Set([...b, ...a]);
return setsEqual(union1, union2);
}
)
);
});
});5. Oracle Properties
Compare implementation against a reference:
describe('calculateArea', () => {
it('should match turf.js calculation', () => {
fc.assert(
fc.property(polygonArbitrary, (geometry) => {
const ourResult = calculateArea(geometry);
const turfResult = turf.area(geometry) / 10000; // m² to ha
return Math.abs(ourResult - turfResult) < 0.01;
})
);
});
});Testing Domain Logic
Testing Entities
// core/domain/project/__tests__/project.property.test.ts
import { describe, it } from 'vitest';
import * as fc from 'fast-check';
import { Project } from '../project.entity';
/**
* Property 1: Project Name Validation
* **Validates: Requirements 2.1**
*/
describe('Project Entity Properties', () => {
const validNameArbitrary = fc.string({ minLength: 1, maxLength: 100 });
const invalidNameArbitrary = fc.constantFrom('', ' ', null, undefined);
it('should accept any non-empty name', () => {
fc.assert(
fc.property(validNameArbitrary, fc.uuid(), (name, accountId) => {
const project = Project.create({ name: name.trim(), accountId });
return project.name.length > 0;
}),
{ numRuns: 100 }
);
});
it('should reject empty names', () => {
fc.assert(
fc.property(fc.uuid(), (accountId) => {
try {
Project.create({ name: '', accountId });
return false; // Should have thrown
} catch {
return true;
}
}),
{ numRuns: 50 }
);
});
});Testing Value Objects
// core/domain/value-objects/__tests__/email.property.test.ts
import { describe, it } from 'vitest';
import * as fc from 'fast-check';
import { Email } from '../email.vo';
/**
* Property 2: Email Validation
* **Validates: Requirements 3.2**
*/
describe('Email Value Object Properties', () => {
it('should normalize all valid emails to lowercase', () => {
fc.assert(
fc.property(fc.emailAddress(), (email) => {
const result = Email.create(email);
if (result.ok) {
return result.value.toString() === email.toLowerCase();
}
return true;
}),
{ numRuns: 100 }
);
});
it('should reject strings without @ symbol', () => {
fc.assert(
fc.property(
fc.string().filter(s => !s.includes('@')),
(invalidEmail) => {
const result = Email.create(invalidEmail);
return !result.ok;
}
),
{ numRuns: 100 }
);
});
});Testing Repository Contracts
// infrastructure/__tests__/repository.property.test.ts
import { describe, it, expect } from 'vitest';
import * as fc from 'fast-check';
/**
* Property 3: Repository CRUD Consistency
* **Validates: Requirements 4.1**
*/
describe('Repository Properties', () => {
it('findById should return what was created', () => {
fc.assert(
fc.asyncProperty(projectArbitrary, async (projectData) => {
const created = await repository.create(projectData);
if (!created.ok) return true;
const found = await repository.findById(created.value.id);
return found.ok && found.value.id === created.value.id;
}),
{ numRuns: 50 }
);
});
it('delete should make findById return not found', () => {
fc.assert(
fc.asyncProperty(projectArbitrary, async (projectData) => {
const created = await repository.create(projectData);
if (!created.ok) return true;
await repository.delete(created.value.id);
const found = await repository.findById(created.value.id);
return !found.ok;
}),
{ numRuns: 50 }
);
});
});Configuration Options
Number of Runs
fc.assert(
fc.property(fc.integer(), (n) => n + 0 === n),
{ numRuns: 1000 } // Run 1000 times instead of default 100
);Seed for Reproducibility
fc.assert(
fc.property(fc.integer(), (n) => n + 0 === n),
{ seed: 42 } // Use specific seed for reproducibility
);Verbose Output
fc.assert(
fc.property(fc.integer(), (n) => n + 0 === n),
{ verbose: true } // Show all generated values
);Handling Failures
Shrinking
When a property fails, fast-check automatically shrinks to find the minimal failing case:
// If this fails with input [1000, 500, 300, 200, 100]
// fast-check will shrink to find minimal case like [1]
it('array should not exceed max length', () => {
fc.assert(
fc.property(fc.array(fc.integer()), (arr) => {
return arr.length <= 5; // Will fail and shrink
})
);
});Debugging Failures
// When a test fails, fast-check provides:
// 1. The failing input
// 2. The seed to reproduce
// 3. The shrunk minimal case
// Example failure output:
// Property failed after 23 tests
// Seed: 1234567890
// Counterexample: [6]
// Shrunk 5 time(s)Reproducing Failures
// Use the seed from failure to reproduce
fc.assert(
fc.property(fc.array(fc.integer()), (arr) => {
return arr.length <= 5;
}),
{ seed: 1234567890 } // Reproduce exact failure
);Integration with Vitest
Test Annotations
/**
* Feature: project-management
* Property 1: Project Area Calculation
* **Validates: Requirements 2.3**
*/
describe('Project Area Properties', () => {
it('area should be non-negative for any valid geometry', () => {
fc.assert(
fc.property(polygonArbitrary, (geometry) => {
const project = Project.create({
name: 'Test',
accountId: 'acc-123',
geometry,
});
return project.calculateArea() >= 0;
}),
{ numRuns: 100 }
);
});
});Running Property Tests
# Run all tests including property tests
pnpm --filter web test:run
# Run only property tests
pnpm --filter web test:run --grep "Property"
# Run with specific seed
FAST_CHECK_SEED=42 pnpm --filter web test:runBest Practices
1. Start with Simple Properties
// Start simple
it('length is non-negative', () => {
fc.assert(fc.property(fc.string(), (s) => s.length >= 0));
});
// Then add complexity
it('split and join are inverses', () => {
fc.assert(
fc.property(fc.string(), fc.string({ minLength: 1 }), (s, sep) => {
return s.split(sep).join(sep) === s || !s.includes(sep);
})
);
});2. Use Preconditions
it('division is inverse of multiplication', () => {
fc.assert(
fc.property(fc.integer(), fc.integer(), (a, b) => {
fc.pre(b !== 0); // Skip when b is 0
return (a * b) / b === a;
})
);
});3. Combine with Unit Tests
describe('Email validation', () => {
// Unit tests for specific cases
it('should accept standard email', () => {
expect(Email.create('user@example.com').ok).toBe(true);
});
it('should reject email without domain', () => {
expect(Email.create('user@').ok).toBe(false);
});
// Property test for general behavior
it('should normalize all valid emails', () => {
fc.assert(
fc.property(fc.emailAddress(), (email) => {
const result = Email.create(email);
return !result.ok || result.value.toString() === email.toLowerCase();
})
);
});
});Next Steps
- Unit Testing - Vitest configuration and patterns
- E2E Testing - Playwright setup and patterns
- Testing Overview - General testing strategy