Problem: Example Tests Miss Edge Cases
Your unit tests pass, but production crashes on unexpected inputs like NaN, negative zero, or strings with emojis. You need hundreds of test cases to feel confident.
You'll learn:
- Why property-based testing catches what examples miss
- How to use fast-check to generate test data
- Using AI to design better properties
- When NOT to use this approach
Time: 20 min | Level: Intermediate
Why This Happens
Example-based tests check specific inputs you thought of. Property-based testing generates hundreds of random inputs and verifies your code's invariants hold for all of them.
Common symptoms:
- Production errors you never tested for
- Test suites with 50+ similar examples
- "It worked in my test data" moments
- Refactoring breaks edge cases
Solution
Step 1: Install fast-check
npm install --save-dev fast-check
# or
pnpm add -D fast-check
Expected: Package installed, no peer dependency warnings.
Step 2: Write Your First Property Test
Start with a simple function that has hidden bugs:
// src/currency.ts
export function formatCurrency(amount: number): string {
return `$${amount.toFixed(2)}`;
}
Traditional test might look like:
// ❌ Example-based - only checks what you remembered
test('formats currency', () => {
expect(formatCurrency(10)).toBe('$10.00');
expect(formatCurrency(0.1)).toBe('$0.10');
});
Property-based version:
// ✅ Property-based - tests invariants
import fc from 'fast-check';
test('currency format is always valid', () => {
fc.assert(
fc.property(
fc.float(), // Generates random floats including edge cases
(amount) => {
const result = formatCurrency(amount);
// Properties that MUST always be true:
// 1. Always starts with $
expect(result).toMatch(/^\$/);
// 2. Has exactly 2 decimal places
expect(result).toMatch(/\.\d{2}$/);
// 3. No NaN or Infinity in output
expect(result).not.toContain('NaN');
expect(result).not.toContain('Infinity');
}
)
);
});
Why this works: fast-check runs this 100 times by default with random values including NaN, Infinity, -0, and extreme numbers.
Run it:
npm test
You'll see it fail:
Error: Property failed after 3 tests
Counterexample: [NaN]
Got: "$NaN"
Shrunk 0 time(s)
Step 3: Use AI to Design Properties
This is where AI excels - identifying invariants you might miss. Ask Claude:
Prompt:
I have this function:
function formatCurrency(amount: number): string {
return `$${amount.toFixed(2)}`;
}
What properties should ALWAYS be true about currency formatting?
List 5-7 invariants to test with fast-check.
Claude's Response (example):
// Properties Claude might suggest:
test('currency formatting properties', () => {
fc.assert(
fc.property(
fc.float({ min: -1e10, max: 1e10, noNaN: true }),
(amount) => {
const result = formatCurrency(amount);
// 1. Idempotency: formatting twice = formatting once
expect(formatCurrency(parseFloat(result.slice(1)))).toBe(result);
// 2. Reversibility: can parse back to similar value
const parsed = parseFloat(result.slice(1));
expect(Math.abs(parsed - Math.round(amount * 100) / 100)).toBeLessThan(0.01);
// 3. Sign consistency
if (amount < 0) expect(result).toContain('-');
if (amount >= 0) expect(result).not.toContain('-');
// 4. Length bounds (for practical UI)
expect(result.length).toBeGreaterThan(4); // At least $0.00
expect(result.length).toBeLessThan(20); // Reasonable max
// 5. No scientific notation
expect(result).not.toMatch(/[eE]/);
}
)
);
});
If it fails:
- "Property failed after X tests": This is good - you found a bug!
- Test times out: Reduce iterations with
fc.assert(property, { numRuns: 50 })
Step 4: Fix the Function Based on Failures
// src/currency.ts - Production-ready version
export function formatCurrency(amount: number): string {
// Handle edge cases fast-check found
if (!Number.isFinite(amount)) {
throw new Error('Amount must be a finite number');
}
// Round to avoid floating point issues
const rounded = Math.round(amount * 100) / 100;
// Format with proper sign handling
const formatted = Math.abs(rounded).toFixed(2);
return rounded < 0 ? `-$${formatted}` : `$${formatted}`;
}
Step 5: Advanced - Custom Arbitraries
For domain-specific data, create custom generators:
// test/arbitraries.ts
import fc from 'fast-check';
// Generate realistic email addresses
export const emailArbitrary = fc.tuple(
fc.stringMatching(/^[a-z0-9]+$/),
fc.constantFrom('gmail.com', 'test.com', 'example.org')
).map(([user, domain]) => `${user}@${domain}`);
// Generate realistic product prices
export const priceArbitrary = fc.integer({ min: 1, max: 100000 })
.map(cents => cents / 100);
// Use in tests
test('email validation properties', () => {
fc.assert(
fc.property(emailArbitrary, (email) => {
expect(validateEmail(email)).toBe(true);
expect(email).toContain('@');
})
);
});
Ask AI to generate arbitraries:
Generate a fast-check arbitrary for valid US phone numbers
in formats: (555) 555-5555, 555-555-5555, 5555555555
Step 6: Integration with AI Code Review
Use AI to review your properties:
Prompt:
Review these fast-check properties for my password validation:
[paste your test code]
Are there edge cases I'm missing? What other invariants should hold?
AI often catches:
- Unicode edge cases (emoji, RTL text)
- Timezone/locale issues
- Boundary conditions you assumed away
- Performance properties (max execution time)
Verification
Run the full test suite:
npm test -- --coverage
You should see:
- All property tests passing
- Higher code coverage than example tests alone
- Fast-check reports like
Ok, passed 100 tests
Typical output:
PASS src/currency.test.ts
✓ currency formatting properties (245ms)
Ok, passed 100 tests.
Test Suites: 1 passed, 1 total
Tests: 1 passed, 1 total
What You Learned
- Property-based testing verifies invariants, not examples
- fast-check generates edge cases you wouldn't think of
- AI excels at identifying properties to test
- Custom arbitraries model your domain accurately
Limitations:
- Slower than example tests (100+ executions)
- Requires thinking in invariants, not examples
- Can be overkill for simple CRUD operations
- Shrinking complex failures takes debugging skill
When NOT to use:
- Simple getters/setters with no logic
- Tests that need specific sequence of events
- Pure integration tests with external APIs
- Performance benchmarks (use dedicated tools)
Real-World Example: URL Parser
import fc from 'fast-check';
// Properties for URL parsing
test('URL parser invariants', () => {
fc.assert(
fc.property(
fc.webUrl(), // Built-in URL generator
(url) => {
const parsed = parseURL(url);
// Reconstruction property
const reconstructed = buildURL(parsed);
expect(normalizeURL(reconstructed)).toBe(normalizeURL(url));
// Component properties
if (parsed.protocol) {
expect(parsed.protocol).toMatch(/^https?$/);
}
if (parsed.port) {
expect(parsed.port).toBeGreaterThan(0);
expect(parsed.port).toBeLessThan(65536);
}
// No injection vulnerabilities
expect(parsed.path).not.toContain('<script>');
expect(parsed.query).not.toContain('javascript:');
}
),
{ numRuns: 500 } // More runs for security-critical code
);
});
AI Prompting Tips
Good prompts for property generation:
✅ "What invariants must hold for a shopping cart total calculation?" ✅ "List 7 properties to test for date range validation" ✅ "What can go wrong with this string sanitization function?"
Bad prompts:
❌ "Write tests for this function" (too vague) ❌ "Is this code correct?" (needs specific properties) ❌ "Generate 100 test cases" (you want properties, not examples)
Quick Reference
Common fast-check arbitraries:
// Primitives
fc.integer() // Random integers
fc.float() // Random floats with edge cases
fc.string() // Random strings
fc.boolean() // true/false
// Constrained
fc.integer({ min: 0, max: 100 })
fc.string({ minLength: 5, maxLength: 20 })
fc.constantFrom('a', 'b', 'c')
// Structured
fc.array(fc.integer()) // Arrays of integers
fc.record({ name: fc.string(), age: fc.integer() })
fc.tuple(fc.string(), fc.integer())
// Domain-specific
fc.emailAddress() // RFC-compliant emails
fc.webUrl() // Valid URLs
fc.uuid() // Valid UUIDs
fc.date() // Date objects
fc.ipV4() // IP addresses
// Custom
fc.string().map(s => s.toUpperCase())
fc.integer().filter(n => n % 2 === 0)
Configuration options:
fc.assert(
fc.property(/* ... */),
{
numRuns: 1000, // More iterations
seed: 42, // Reproducible tests
timeout: 5000, // Max time per test (ms)
verbose: true, // Show all failures
endOnFailure: true // Stop after first failure
}
);
Tested on fast-check 3.15.0, TypeScript 5.5, Node.js 22.x, Vitest 1.2