Property-Based Testing in JavaScript with fast-check
Discover how property-based testing with fast-check automatically generates test cases and finds edge cases that traditional example-based tests miss in your JavaScript applications.
Property-Based Testing in JavaScript with fast-check
While I was looking over some unit tests in one of my older projects the other day, I realized something that made me cringe a little. I had written the same pattern over and over: test with an empty string, test with a normal string, test with a special character. Sound familiar? I was once guilty of spending hours writing example-based tests that still managed to miss obvious edge cases. Little did I know there was a completely different approach that would change how I think about testing forever.
Why Property-Based Testing Changes Everything
When I finally decided to explore property-based testing, I discovered it flips traditional testing on its head. Instead of writing specific examples like "when input is 5, output should be 25", you define properties that should always be true. The testing framework then generates hundreds or thousands of random inputs to verify those properties hold.
Here's what hit me: I was writing tests that proved my code worked for the cases I thought of. But what about the cases I didn't think of? That's where property-based testing becomes fascinating!
The framework generates random data automatically. It explores edge cases you never considered. When it finds a failing case, it shrinks the input to the smallest example that reproduces the failure. This approach has caught bugs in my code that would have taken weeks to surface in production.
Understanding Property-Based Testing vs Example-Based Testing
Let me show you what I mean with a concrete example. I cannot stress this enough: seeing the difference in practice makes everything click.
Traditional example-based testing looks like this:
describe('reverse function', () => {
it('should reverse a simple string', () => {
expect(reverse('hello')).toBe('olleh');
});
it('should handle empty strings', () => {
expect(reverse('')).toBe('');
});
it('should handle single characters', () => {
expect(reverse('a')).toBe('a');
});
});I spent years writing tests exactly like this. The problem? I was testing the cases I remembered to test. What about strings with emojis? Unicode characters? Extremely long strings?
Property-based testing with fast-check looks completely different:
import fc from 'fast-check';
describe('reverse function properties', () => {
it('reversing twice should return original string', () => {
fc.assert(
fc.property(fc.string(), (str) => {
expect(reverse(reverse(str))).toBe(str);
})
);
});
it('reversed string should have same length as original', () => {
fc.assert(
fc.property(fc.string(), (str) => {
expect(reverse(str).length).toBe(str.length);
})
);
});
});Notice what happened? I'm not providing specific test cases anymore. I'm describing properties that should always be true, and fast-check generates hundreds of random strings to verify them. When I ran this against my original reverse function, it found a Unicode bug within seconds that my example-based tests completely missed!

Getting Started with fast-check
Luckily we can get started with fast-check in just a few minutes. I came across this library while researching alternatives to manually writing edge cases, and installation is straightforward:
npm install --save-dev fast-checkThe beauty of fast-check is that it integrates seamlessly with whatever test runner you're already using. I use Jest, but it works just as well with Mocha, Vitest, or even plain Node.js.
Here's a basic example that opened my eyes to how powerful this approach is:
import fc from 'fast-check';
// A simple function we want to test
function add(a: number, b: number): number {
return a + b;
}
describe('addition properties', () => {
it('should be commutative', () => {
fc.assert(
fc.property(fc.integer(), fc.integer(), (a, b) => {
return add(a, b) === add(b, a);
})
);
});
it('should have identity element of 0', () => {
fc.assert(
fc.property(fc.integer(), (a) => {
return add(a, 0) === a && add(0, a) === a;
})
);
});
});When I first ran this, fast-check automatically generated 100 different test cases. In other words, it tested my addition function with 100 random integer pairs to verify commutativity, and another 100 random integers to verify the identity property. All without me writing a single specific test case!
Writing Your First Property Tests with Arbitraries
The term "arbitrary" confused me at first, but it's actually simple. An arbitrary is just a generator for random values of a specific type. Fast-check provides arbitraries for everything: numbers, strings, arrays, objects, dates, and even custom types.
Here's a more practical example I recently used in a real project. I was building a function to normalize email addresses:
function normalizeEmail(email: string): string {
const [local, domain] = email.split('@');
return `${local.toLowerCase().trim()}@${domain.toLowerCase().trim()}`;
}My example-based tests looked fine, but when I wrote property-based tests, I immediately found edge cases:
describe('normalizeEmail properties', () => {
it('should always produce lowercase output', () => {
fc.assert(
fc.property(fc.emailAddress(), (email) => {
const normalized = normalizeEmail(email);
expect(normalized).toBe(normalized.toLowerCase());
})
);
});
it('should be idempotent', () => {
fc.assert(
fc.property(fc.emailAddress(), (email) => {
const once = normalizeEmail(email);
const twice = normalizeEmail(once);
expect(once).toBe(twice);
})
);
});
it('should preserve @ symbol count', () => {
fc.assert(
fc.property(fc.emailAddress(), (email) => {
const originalCount = (email.match(/@/g) || []).length;
const normalizedCount = (normalizeEmail(email).match(/@/g) || []).length;
expect(normalizedCount).toBe(originalCount);
})
);
});
});Wonderful! These tests caught a bug where my function failed on emails with multiple @ symbols (which are technically invalid but my API was receiving them). I was handling the happy path perfectly but completely missed validation.
Advanced fast-check Patterns: Custom Arbitraries and Combinators
Fast-check becomes even more powerful when you start creating custom arbitraries. I learned this when testing a user registration system that required specific validation rules.
Let me show you a pattern I use constantly now:
// Custom arbitrary for valid usernames
const usernameArbitrary = fc
.string({ minLength: 3, maxLength: 20 })
.filter(str => /^[a-zA-Z0-9_]+$/.test(str));
// Custom arbitrary for valid passwords
const passwordArbitrary = fc
.string({ minLength: 8, maxLength: 128 })
.filter(str =>
/[A-Z]/.test(str) &&
/[a-z]/.test(str) &&
/[0-9]/.test(str)
);
// Combine arbitraries into a user object
const userArbitrary = fc.record({
username: usernameArbitrary,
email: fc.emailAddress(),
password: passwordArbitrary,
age: fc.integer({ min: 13, max: 120 })
});
describe('user registration', () => {
it('should accept valid users', () => {
fc.assert(
fc.property(userArbitrary, (user) => {
const result = validateUser(user);
expect(result.isValid).toBe(true);
})
);
});
});This approach generates realistic test data automatically. I cannot stress this enough: the time savings here are enormous. Instead of maintaining fixture files or factory functions, the arbitraries describe the shape and constraints of valid data.

Integrating fast-check with Jest and Other Test Runners
When I finally decided to integrate fast-check into my existing Jest test suite, I discovered it was remarkably seamless. The fc.assert function works perfectly inside any it or test block.
Here's my typical setup pattern:
import fc from 'fast-check';
describe('MyComponent', () => {
// You can configure fast-check globally
const testConfig = {
numRuns: 1000, // Run 1000 test cases instead of default 100
verbose: true,
seed: 42 // Use seed for reproducible tests
};
it('should handle any valid input', () => {
fc.assert(
fc.property(
fc.record({
id: fc.uuid(),
name: fc.string(),
age: fc.nat()
}),
(input) => {
const result = processInput(input);
expect(result).toBeDefined();
}
),
testConfig
);
});
});The verbose option has saved me countless debugging hours. When a property test fails, fast-check shows you the exact input that caused the failure, plus the shrunk version. The shrinking algorithm is brilliant—it automatically reduces a complex failing case to the simplest possible example.
Real-World Use Cases: Where Property-Based Testing Shines
After using property-based testing for over a year now, I've identified specific scenarios where it provides massive value.
Serialization and deserialization functions are perfect candidates. If you're converting between JSON and objects, between different data formats, or encoding and decoding data, property-based tests can verify round-trip consistency:
fc.assert(
fc.property(fc.anything(), (data) => {
const serialized = JSON.stringify(data);
const deserialized = JSON.parse(serialized);
expect(deserialized).toEqual(data);
})
);Parser functions benefit enormously. I wrote a markdown parser and used fast-check to generate thousands of random markdown strings. It found edge cases I would never have thought to test manually—nested lists with mixed formatting, code blocks inside blockquotes, malformed link syntax.
Data transformation pipelines are another sweet spot. Any function where you transform data from one shape to another should preserve certain properties. For example, filtering an array should never increase its length, sorting should preserve element count, and mapping should maintain array length.
Validation functions practically beg for property-based testing. Instead of writing examples of valid and invalid inputs, you can generate random data and verify your validator correctly identifies valid vs invalid cases.
Building a Robust Testing Strategy with fast-check
Here's what I've learned about integrating property-based testing into a comprehensive strategy: it complements example-based tests rather than replacing them.
I still write example-based tests for specific edge cases I know are important—null checks, boundary conditions, and specific bug reproductions. But I also write property-based tests to explore the space of possible inputs and verify general invariants hold true.
My typical test file now looks like this: a few concrete example tests for readability and documentation, followed by property-based tests that verify the function's general behavior across a wide range of inputs. This combination has dramatically increased my confidence in my code.
The ROI on learning fast-check was immediate. Within a week of adopting it, I found three production bugs in code I thought was well-tested. The initial time investment of understanding arbitraries and properties paid for itself many times over in prevented bugs and reduced debugging time.
And that concludes the end of this post! I hope you found this valuable and look out for more in the future!