Prerequisites
- Basic understanding of JavaScript ๐
- TypeScript installation โก
- VS Code or preferred IDE ๐ป
What you'll learn
- Understand property-based testing fundamentals ๐ฏ
- Apply fast-check in real projects ๐๏ธ
- Debug common property testing issues ๐
- Write type-safe property tests โจ
๐ฏ Introduction
Welcome to the exciting world of property-based testing! ๐ In this guide, weโll explore how to write smarter, more comprehensive tests that find edge cases you never thought of.
Property-based testing is like having a super-smart testing assistant ๐ค that tries thousands of different inputs to break your code. Instead of testing specific examples, you describe the properties your code should always satisfy. Whether youโre building e-commerce platforms ๐, gaming systems ๐ฎ, or data processing libraries ๐, property-based testing will revolutionize how you think about code quality.
By the end of this tutorial, youโll feel confident using fast-check to write bulletproof tests! Letโs dive in! ๐โโ๏ธ
๐ Understanding Property-Based Testing
๐ค What is Property-Based Testing?
Property-based testing is like having a detective ๐ต๏ธโโ๏ธ that investigates your code with randomly generated evidence! Instead of testing with handpicked examples, you define the rules (properties) your code must follow, and the testing framework generates hundreds of test cases automatically.
Think of it like quality control in a toy factory ๐ญ. Instead of testing just a few specific toys, you define what makes a good toy (safe materials, proper size, no sharp edges) and then test thousands of randomly selected toys against these criteria.
In TypeScript terms, property-based testing helps you:
- โจ Discover edge cases automatically
- ๐ Test with thousands of inputs in seconds
- ๐ก๏ธ Build confidence in your codeโs correctness
- ๐ Generate minimal failing examples
๐ก Why Use Property-Based Testing?
Hereโs why developers love property-based testing:
- Edge Case Discovery ๐: Finds bugs in scenarios you never considered
- Better Test Coverage ๐: Tests more combinations than manual examples
- Living Documentation ๐: Properties describe how your code should behave
- Regression Protection ๐ก๏ธ: Prevents old bugs from coming back
Real-world example: Imagine testing a shopping cartโs discount calculation ๐. Instead of testing a few specific cases, you could verify: โThe total after discount should always be less than or equal to the original total.โ
๐ง Basic Syntax and Usage
๐ฆ Installing Fast-Check
Letโs start by adding fast-check to your project:
# ๐ฆ Install fast-check for TypeScript
npm install --save-dev fast-check @types/jest
# ๐ฏ Or with pnpm (recommended)
pnpm add -D fast-check @types/jest
๐ Your First Property Test
Hereโs a simple example to get you started:
// ๐ Hello, property-based testing!
import fc from 'fast-check';
// ๐จ A simple function to test
function add(a: number, b: number): number {
return a + b;
}
// ๐งช Our first property test
describe('Addition Properties', () => {
test('addition is commutative ๐', () => {
fc.assert(fc.property(
fc.integer(), // ๐ฒ Random integer generator
fc.integer(), // ๐ฒ Another random integer
(a, b) => {
// ๐ฏ The property: a + b should equal b + a
expect(add(a, b)).toBe(add(b, a));
}
));
});
});
๐ก Explanation: This test generates random pairs of integers and verifies that addition is commutative (order doesnโt matter)!
๐ฏ Common Generators
Fast-check provides many built-in generators:
// ๐ฒ Number generators
fc.integer() // Any integer
fc.integer({ min: 0, max: 100 }) // Range: 0-100
fc.float() // Floating-point numbers
fc.nat() // Natural numbers (0, 1, 2, ...)
// ๐ String generators
fc.string() // Random strings
fc.string({ minLength: 1, maxLength: 10 }) // Sized strings
fc.asciiString() // ASCII-only strings
fc.emailAddress() // Valid email addresses! ๐ง
// ๐จ Array generators
fc.array(fc.integer()) // Arrays of integers
fc.array(fc.string(), { minLength: 1 }) // Non-empty string arrays
// ๐๏ธ Object generators
fc.record({
name: fc.string(),
age: fc.integer({ min: 0, max: 120 }),
emoji: fc.constantFrom('๐', '๐', '๐ช') // Pick from options
});
๐ก Practical Examples
๐ Example 1: E-commerce Discount System
Letโs test a discount calculation system:
// ๐๏ธ Product and discount types
interface Product {
id: string;
name: string;
price: number;
emoji: string;
}
interface Discount {
percentage: number; // 0-100
maxAmount?: number; // Optional cap
}
// ๐ฐ Discount calculator
function applyDiscount(product: Product, discount: Discount): number {
const discountAmount = (product.price * discount.percentage) / 100;
const actualDiscount = discount.maxAmount
? Math.min(discountAmount, discount.maxAmount)
: discountAmount;
return Math.max(0, product.price - actualDiscount);
}
// ๐งช Property-based tests
describe('Discount Calculator Properties', () => {
const productGen = fc.record({
id: fc.string({ minLength: 1 }),
name: fc.string({ minLength: 1 }),
price: fc.float({ min: 0.01, max: 1000 }),
emoji: fc.constantFrom('๐ฑ', '๐', '๐ฎ', '๐', 'โ')
});
const discountGen = fc.record({
percentage: fc.integer({ min: 0, max: 100 }),
maxAmount: fc.option(fc.float({ min: 0.01, max: 100 }))
});
test('discounted price is never greater than original ๐ก๏ธ', () => {
fc.assert(fc.property(
productGen,
discountGen,
(product, discount) => {
const discountedPrice = applyDiscount(product, discount);
// ๐ฏ Key property: discounted price โค original price
expect(discountedPrice).toBeLessThanOrEqual(product.price);
}
));
});
test('discounted price is never negative ๐ซ', () => {
fc.assert(fc.property(
productGen,
discountGen,
(product, discount) => {
const discountedPrice = applyDiscount(product, discount);
// ๐ฏ Another property: price can't be negative
expect(discountedPrice).toBeGreaterThanOrEqual(0);
}
));
});
test('100% discount results in zero price ๐ฏ', () => {
fc.assert(fc.property(
productGen,
(product) => {
const fullDiscount = { percentage: 100 };
const discountedPrice = applyDiscount(product, fullDiscount);
// ๐ฏ Special case property
expect(discountedPrice).toBe(0);
}
));
});
});
๐ฎ Example 2: Game Score Validation
Letโs test a gaming leaderboard system:
// ๐ Game score types
interface GameScore {
playerId: string;
score: number;
timestamp: Date;
achievements: string[];
}
// ๐ Leaderboard manager
class Leaderboard {
private scores: GameScore[] = [];
// โ Add score
addScore(score: GameScore): void {
this.scores.push(score);
this.scores.sort((a, b) => b.score - a.score); // Highest first
}
// ๐ฅ Get top N players
getTopPlayers(n: number): GameScore[] {
return this.scores.slice(0, n);
}
// ๐ฏ Get player rank (1-based)
getPlayerRank(playerId: string): number {
const index = this.scores.findIndex(s => s.playerId === playerId);
return index === -1 ? -1 : index + 1;
}
// ๐ Get average score
getAverageScore(): number {
if (this.scores.length === 0) return 0;
const total = this.scores.reduce((sum, s) => sum + s.score, 0);
return total / this.scores.length;
}
}
// ๐งช Property tests for leaderboard
describe('Leaderboard Properties', () => {
const scoreGen = fc.record({
playerId: fc.string({ minLength: 1 }),
score: fc.integer({ min: 0, max: 999999 }),
timestamp: fc.date(),
achievements: fc.array(fc.constantFrom(
'๐ First Steps', '๐ Speed Demon', '๐ Collector',
'๐ Champion', '๐ฏ Sharpshooter'
))
});
test('leaderboard is always sorted by score (descending) ๐', () => {
fc.assert(fc.property(
fc.array(scoreGen, { minLength: 1 }),
(scores) => {
const leaderboard = new Leaderboard();
scores.forEach(score => leaderboard.addScore(score));
const topScores = leaderboard.getTopPlayers(scores.length);
// ๐ฏ Property: each score โฅ next score
for (let i = 0; i < topScores.length - 1; i++) {
expect(topScores[i].score).toBeGreaterThanOrEqual(topScores[i + 1].score);
}
}
));
});
test('top N never returns more than N players ๐ข', () => {
fc.assert(fc.property(
fc.array(scoreGen),
fc.integer({ min: 0, max: 20 }),
(scores, n) => {
const leaderboard = new Leaderboard();
scores.forEach(score => leaderboard.addScore(score));
const topPlayers = leaderboard.getTopPlayers(n);
// ๐ฏ Property: result length โค requested count
expect(topPlayers.length).toBeLessThanOrEqual(n);
expect(topPlayers.length).toBeLessThanOrEqual(scores.length);
}
));
});
test('player rank is consistent with leaderboard position ๐๏ธ', () => {
fc.assert(fc.property(
fc.array(scoreGen, { minLength: 1 }),
(scores) => {
// ๐จ Ensure unique player IDs for this test
const uniqueScores = scores.map((score, index) => ({
...score,
playerId: `player-${index}`
}));
const leaderboard = new Leaderboard();
uniqueScores.forEach(score => leaderboard.addScore(score));
uniqueScores.forEach((score, _) => {
const rank = leaderboard.getPlayerRank(score.playerId);
const topPlayers = leaderboard.getTopPlayers(rank);
// ๐ฏ Property: player at rank N should be in top N
expect(rank).toBeGreaterThan(0);
expect(topPlayers[rank - 1].playerId).toBe(score.playerId);
});
}
));
});
});
๐ Advanced Concepts
๐งโโ๏ธ Custom Generators
When built-in generators arenโt enough, create your own:
// ๐จ Custom generator for valid user data
const userGen = fc.record({
id: fc.uuid(),
email: fc.emailAddress(),
age: fc.integer({ min: 13, max: 120 }),
preferences: fc.record({
theme: fc.constantFrom('light', 'dark', 'auto'),
notifications: fc.boolean(),
language: fc.constantFrom('en', 'es', 'fr', 'de'),
emoji: fc.constantFrom('๐', '๐', '๐')
})
});
// ๐ฏ Generator for complex business rules
const orderGen = fc.record({
id: fc.string({ minLength: 5, maxLength: 10 }),
items: fc.array(fc.record({
productId: fc.string(),
quantity: fc.integer({ min: 1, max: 10 }),
price: fc.float({ min: 0.01, max: 999.99 })
}), { minLength: 1, maxLength: 5 }),
shippingAddress: fc.record({
street: fc.string({ minLength: 5 }),
city: fc.string({ minLength: 2 }),
zipCode: fc.string({ minLength: 5, maxLength: 10 }),
country: fc.constantFrom('US', 'CA', 'UK', 'DE', 'FR')
}),
customerType: fc.constantFrom('regular', 'premium', 'vip')
});
๐๏ธ Stateful Testing
Test stateful systems by modeling state transitions:
// ๐ฎ Stateful shopping cart testing
class ShoppingCartModel {
items: Map<string, number> = new Map();
addItem(productId: string, quantity: number): void {
const current = this.items.get(productId) ?? 0;
this.items.set(productId, current + quantity);
}
removeItem(productId: string): void {
this.items.delete(productId);
}
getQuantity(productId: string): number {
return this.items.get(productId) ?? 0;
}
getTotal(): number {
return Array.from(this.items.values()).reduce((sum, qty) => sum + qty, 0);
}
}
// ๐งช Commands for stateful testing
const addItemCommand = fc.record({
type: fc.constant('add'),
productId: fc.string({ minLength: 1 }),
quantity: fc.integer({ min: 1, max: 10 })
});
const removeItemCommand = fc.record({
type: fc.constant('remove'),
productId: fc.string({ minLength: 1 })
});
const cartCommandGen = fc.oneof(addItemCommand, removeItemCommand);
test('shopping cart maintains consistent state ๐', () => {
fc.assert(fc.property(
fc.array(cartCommandGen, { maxLength: 20 }),
(commands) => {
const cart = new ShoppingCartModel();
for (const command of commands) {
if (command.type === 'add') {
const beforeTotal = cart.getTotal();
cart.addItem(command.productId, command.quantity);
const afterTotal = cart.getTotal();
// ๐ฏ Property: total should increase by quantity
expect(afterTotal).toBe(beforeTotal + command.quantity);
} else {
cart.removeItem(command.productId);
// ๐ฏ Property: removed item has quantity 0
expect(cart.getQuantity(command.productId)).toBe(0);
}
}
// ๐ฏ Final property: total equals sum of all quantities
const expectedTotal = Array.from(cart.items.values())
.reduce((sum, qty) => sum + qty, 0);
expect(cart.getTotal()).toBe(expectedTotal);
}
));
});
โ ๏ธ Common Pitfalls and Solutions
๐ฑ Pitfall 1: Ignoring Preconditions
// โ Wrong way - no input validation
function divide(a: number, b: number): number {
return a / b; // ๐ฅ What if b is 0?
}
test('division property - BROKEN ๐', () => {
fc.assert(fc.property(
fc.float(),
fc.float(),
(a, b) => {
const result = divide(a, b);
// ๐ฅ This will fail when b is 0!
expect(result * b).toBeCloseTo(a);
}
));
});
// โ
Correct way - use preconditions
test('division property - FIXED โจ', () => {
fc.assert(fc.property(
fc.float(),
fc.float().filter(b => b !== 0), // ๐ก๏ธ Exclude zero
(a, b) => {
const result = divide(a, b);
expect(result * b).toBeCloseTo(a, 5); // Allow floating-point precision
}
));
});
๐คฏ Pitfall 2: Non-Deterministic Code
// โ Dangerous - testing random behavior directly
function generateRandomId(): string {
return Math.random().toString(36).substring(2);
}
test('random ID generation - FLAKY ๐ฐ', () => {
fc.assert(fc.property(
fc.anything(),
() => {
const id1 = generateRandomId();
const id2 = generateRandomId();
// ๐ฅ This might fail occasionally!
expect(id1).not.toBe(id2);
}
));
});
// โ
Better approach - test properties, not exact values
test('random ID generation - STABLE ๐ก๏ธ', () => {
fc.assert(fc.property(
fc.anything(),
() => {
const id = generateRandomId();
// ๐ฏ Test properties that should always hold
expect(id.length).toBeGreaterThan(0);
expect(id).toMatch(/^[a-z0-9]+$/);
}
));
});
๐ Pitfall 3: Complex Equality Comparisons
// โ Wrong - comparing floating-point numbers exactly
test('floating point arithmetic - BRITTLE ๐', () => {
fc.assert(fc.property(
fc.float(),
fc.float(),
fc.float(),
(a, b, c) => {
// ๐ฅ Floating-point precision issues!
expect((a + b) + c).toBe(a + (b + c));
}
));
});
// โ
Correct - use appropriate comparison methods
test('floating point arithmetic - ROBUST โจ', () => {
fc.assert(fc.property(
fc.float({ min: -1000, max: 1000 }),
fc.float({ min: -1000, max: 1000 }),
fc.float({ min: -1000, max: 1000 }),
(a, b, c) => {
const left = (a + b) + c;
const right = a + (b + c);
// ๐ก๏ธ Allow for floating-point precision
expect(Math.abs(left - right)).toBeLessThan(1e-10);
}
));
});
๐ ๏ธ Best Practices
- ๐ฏ Focus on Properties: Test invariants, not specific outputs
- ๐ก๏ธ Use Preconditions: Filter inputs to avoid meaningless tests
- ๐ Start Simple: Begin with basic properties, add complexity gradually
- ๐ Minimize Failing Examples: Let fast-check find the smallest failing case
- โก Keep Tests Fast: Use reasonable bounds on generated data
- ๐ Document Properties: Write clear descriptions of what youโre testing
๐งช Hands-On Exercise
๐ฏ Challenge: Build a Type-Safe URL Parser
Create a URL parser with comprehensive property-based tests:
๐ Requirements:
- โ Parse URLs into components (protocol, host, path, query, fragment)
- ๐ก๏ธ Handle edge cases gracefully
- ๐จ Support various URL formats
- ๐ Validate that parsing is reversible where possible
- ๐ Test with randomly generated URLs
๐ Bonus Points:
- Add support for international domain names
- Test URL normalization properties
- Verify security constraints (no malicious URLs)
๐ก Solution
๐ Click to see solution
// ๐ URL component types
interface ParsedUrl {
protocol: string;
host: string;
port?: number;
path: string;
query: Record<string, string>;
fragment?: string;
}
// ๐ง URL parser class
class UrlParser {
static parse(url: string): ParsedUrl | null {
try {
const parsed = new URL(url);
const query: Record<string, string> = {};
parsed.searchParams.forEach((value, key) => {
query[key] = value;
});
return {
protocol: parsed.protocol.replace(':', ''),
host: parsed.hostname,
port: parsed.port ? parseInt(parsed.port) : undefined,
path: parsed.pathname,
query,
fragment: parsed.hash ? parsed.hash.substring(1) : undefined
};
} catch {
return null;
}
}
static serialize(parsed: ParsedUrl): string {
let url = `${parsed.protocol}://${parsed.host}`;
if (parsed.port) {
url += `:${parsed.port}`;
}
url += parsed.path;
const queryString = Object.entries(parsed.query)
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
.join('&');
if (queryString) {
url += `?${queryString}`;
}
if (parsed.fragment) {
url += `#${parsed.fragment}`;
}
return url;
}
}
// ๐งช Property-based tests
describe('URL Parser Properties', () => {
// ๐จ Generate valid URL components
const protocolGen = fc.constantFrom('http', 'https', 'ftp');
const hostGen = fc.domain();
const portGen = fc.option(fc.integer({ min: 1, max: 65535 }));
const pathGen = fc.string().map(s => '/' + s.replace(/[#?]/g, ''));
const queryGen = fc.dictionary(
fc.string({ minLength: 1 }),
fc.string()
);
const fragmentGen = fc.option(fc.string());
const validUrlGen = fc.record({
protocol: protocolGen,
host: hostGen,
port: portGen,
path: pathGen,
query: queryGen,
fragment: fragmentGen
});
test('parsing valid URLs never returns null ๐ก๏ธ', () => {
fc.assert(fc.property(
validUrlGen,
(urlData) => {
const urlString = UrlParser.serialize(urlData);
const parsed = UrlParser.parse(urlString);
// ๐ฏ Property: valid URLs should always parse
expect(parsed).not.toBeNull();
}
));
});
test('parse-serialize round-trip preserves structure ๐', () => {
fc.assert(fc.property(
validUrlGen,
(original) => {
const serialized = UrlParser.serialize(original);
const parsed = UrlParser.parse(serialized);
if (parsed) {
// ๐ฏ Properties that should be preserved
expect(parsed.protocol).toBe(original.protocol);
expect(parsed.host).toBe(original.host);
expect(parsed.port).toBe(original.port);
expect(parsed.path).toBe(original.path);
expect(parsed.query).toEqual(original.query);
expect(parsed.fragment).toBe(original.fragment);
}
}
));
});
test('malformed URLs return null ๐ซ', () => {
const malformedUrlGen = fc.oneof(
fc.constant(''),
fc.constant('not-a-url'),
fc.constant('://missing-protocol'),
fc.constant('http://'),
fc.string().filter(s => !s.includes('://'))
);
fc.assert(fc.property(
malformedUrlGen,
(badUrl) => {
const parsed = UrlParser.parse(badUrl);
// ๐ฏ Property: malformed URLs should return null
expect(parsed).toBeNull();
}
));
});
test('parsed host is never empty for valid URLs ๐', () => {
fc.assert(fc.property(
validUrlGen,
(urlData) => {
const urlString = UrlParser.serialize(urlData);
const parsed = UrlParser.parse(urlString);
if (parsed) {
// ๐ฏ Property: host should never be empty
expect(parsed.host.length).toBeGreaterThan(0);
}
}
));
});
test('port is within valid range ๐ข', () => {
fc.assert(fc.property(
validUrlGen,
(urlData) => {
const urlString = UrlParser.serialize(urlData);
const parsed = UrlParser.parse(urlString);
if (parsed?.port) {
// ๐ฏ Property: port should be in valid range
expect(parsed.port).toBeGreaterThan(0);
expect(parsed.port).toBeLessThanOrEqual(65535);
}
}
));
});
});
// ๐ฎ Example usage test
test('URL parser works with real examples ๐', () => {
const examples = [
'https://example.com/path?query=value#fragment',
'http://localhost:3000/',
'https://api.github.com/repos/owner/repo'
];
examples.forEach(url => {
const parsed = UrlParser.parse(url);
expect(parsed).not.toBeNull();
if (parsed) {
const serialized = UrlParser.serialize(parsed);
const reparsed = UrlParser.parse(serialized);
expect(reparsed).toEqual(parsed);
}
});
});
๐ Key Takeaways
Youโve learned so much! Hereโs what you can now do:
- โ Write property-based tests with fast-check ๐ช
- โ Generate complex test data automatically ๐ฒ
- โ Find edge cases that manual testing misses ๐
- โ Test stateful systems with command sequences ๐ฎ
- โ Create custom generators for domain-specific data ๐จ
- โ Avoid common pitfalls in property testing ๐ก๏ธ
Remember: Property-based testing is like having a tireless testing assistant that never gets bored trying new scenarios! ๐ค
๐ค Next Steps
Congratulations! ๐ Youโve mastered property-based testing with fast-check!
Hereโs what to do next:
- ๐ป Practice with the URL parser exercise above
- ๐๏ธ Add property tests to an existing project
- ๐ Explore fast-checkโs advanced features (stateful testing, async properties)
- ๐ Share your property testing discoveries with your team!
Remember: Every TypeScript expert was once a beginner. Keep testing, keep learning, and most importantly, let the computer find your bugs before your users do! ๐
Happy testing! ๐๐งชโจ