Related articles: Also see mutation testing as a complementary technique for finding test gaps, coverage metrics improved by property-based test generation, and snapshot testing as a deterministic complement to property-based tests.
Property-Based Testing in JavaScript: Finding Bugs You Never Knew Existed
In the world of software development, we spend a significant amount of time writing tests to ensure our code behaves as expected. The most common approach is example-based testing. We think of a few inputs, write out the expected outputs, and assert that our function produces the correct result.
For a simple add function, we might write:
test('should add two numbers correctly', () => {
expect(add(2, 3)).toBe(5);
expect(add(-1, 1)).toBe(0);
expect(add(0, 0)).toBe(0);
});
This is a great start, but it has a fundamental limitation: we are only testing the cases we can think of. What about large numbers? Floating-point inaccuracies? NaN or Infinity? What if we forget a crucial edge case? This is where Property-Based Testing (PBT) comes in, offering a more powerful and comprehensive way to validate our code.
ScanlyApp is dedicated to improving testing standards, and PBT is a technique every modern QA engineer and developer should have in their toolkit. It shifts the focus from verifying individual examples to defining general properties that should hold true for any valid input.
What is Property-Based Testing?
Property-based testing is a technique where you define a property of your code�a statement or invariant that should always be true. Then, a testing framework automatically generates a large number of random inputs (often hundreds or thousands) to try and falsify that property.
If the framework finds an input for which the property is false, it has found a bug. The real magic is that it then shrinks the failing input down to the smallest, simplest possible example that still causes the failure. This makes debugging incredibly efficient.
Think of it as having a tireless, creative QA engineer who does nothing but try to break your code with weird and wonderful inputs, 24/7.
Example-Based vs. Property-Based Testing
Let's compare the two approaches with a simple table:
| Feature | Example-Based Testing | Property-Based Testing |
|---|---|---|
| Core Idea | "I expect that for input X, the output is Y." | "I expect that for any valid input, this property holds." |
| Test Cases | Manually written by the developer. | Automatically generated by the framework. |
| Coverage | Limited to the developer's imagination and diligence. | Covers a vast range of inputs, including many edge cases. |
| Goal | Confirm known behavior. | Falsify properties and discover unknown bugs. |
| Effort | High effort to write many diverse test cases. | High effort to define a good property, low effort for cases. |
| Key Benefit | Simple, explicit, and easy to understand. | Excellent at finding subtle bugs and surprising edge cases. |
| Example Tool | Jest, Mocha, Vitest (as assertion runners) | fast-check (for JavaScript), Hypothesis (Python) |
Introducing fast-check: Your PBT Powerhouse for JavaScript
In the JavaScript ecosystem, the leading library for property-based testing is fast-check. It's powerful, flexible, and integrates seamlessly with popular testing frameworks like Jest, Vitest, and Mocha.
To get started, you'll need to install it:
npm install --save-dev fast-check
The core of fast-check is the fc.assert and fc.property functions, along with a rich set of "arbitraries."
- Arbitraries (
fc.string(),fc.integer(), etc.): These are the generators for your random data.fast-checkhas dozens, from simple primitives to complex objects, arrays, and tuples. fc.property(...): This function takes your arbitraries and a test function. It defines the property you want to test.fc.assert(...): This is the runner. It takes a property and a configuration, then executes the test by generating inputs and checking for failures.
A Practical Example: Sorting an Array
Let's test a sort function. An example-based test might look like this:
test('sorts an array of numbers', () => {
const inputArray = [3, 1, 4, 1, 5, 9, 2, 6];
const expectedArray = [1, 1, 2, 3, 4, 5, 6, 9];
expect(customSort(inputArray)).toEqual(expectedArray);
});
This test is fine, but it only checks one specific array. How can we define the properties of a correctly sorted array?
- Idempotence: Sorting an already sorted array should not change it.
- Length Invariance: The sorted array must have the same length as the original.
- Element Invariance: The sorted array must contain the exact same elements as the original.
- Order: Every element in the sorted array must be less than or equal to the element that follows it.
Let's write a property-based test for these using fast-check and Vitest.
import { test, expect } from 'vitest';
import * as fc from 'fast-check';
// Let's assume this is our function to test
const customSort = (arr) => [...arr].sort((a, b) => a - b);
test('the output of customSort should be a sorted array', () => {
// We use fc.assert to run the property test
fc.assert(
// fc.property defines the inputs we want to generate
// Here, we generate an array of integers
fc.property(fc.array(fc.integer()), (data) => {
const sorted = customSort(data);
// Property 1: Length Invariance
expect(sorted.length).toBe(data.length);
// Property 2: Order
for (let i = 0; i < sorted.length - 1; ++i) {
expect(sorted[i]).toBeLessThanOrEqual(sorted[i + 1]);
}
// Property 3: Idempotence (on the output)
// Sorting the already sorted array shouldn't change it
expect(customSort(sorted)).toEqual(sorted);
}),
);
});
Now, instead of one test case, fast-check will run this logic 100 times (by default) with arrays of different lengths, containing different integers (positive, negative, zero, MAX_SAFE_INTEGER, etc.). If it finds a single array for which any of these expect statements fail, the test fails.
The Power of Shrinking
Imagine our customSort function has a subtle bug:
// Buggy sort: mishandles numbers greater than 1000
const buggySort = (arr) => {
return [...arr].sort((a, b) => {
if (a > 1000 || b > 1000) {
return b - a; // Incorrectly sorts in descending order
}
return a - b;
});
};
A property-based test would quickly find this. It might first fail with a large, complex array like [10, 500, 1001, 2, 8000].
Instead of just showing you that array, fast-check's shrinker will work backward to find the simplest failure. It will try removing elements, reducing their values, and simplifying the structure until it reports a failure like this:
Error: Property failed after 12 tests
{ seed: 123456, path: "11:0:0", endOnFailure: true }
Counterexample: [[1001]]
Shrunk 5 time(s)
Got: Error: expect(received).toBeLessThanOrEqual(expected) // deep equality
Expected: <= 1001
Received: 1002 // Example of a hypothetical failure
The counterexample [1001] is far easier to debug than the original large array. This is one of the most significant advantages of PBT.
The PBT Workflow
Here's a structured way to approach property-based testing:
graph TD
A[1. Identify a Function/System to Test] --> B{2. Brainstorm Properties};
B --> C[3. Choose Arbitraries for Inputs];
C --> D[4. Write the Property Test using fc.assert/fc.property];
D --> E{5. Run the Test};
E -- Fails --> F[6. Analyze the Shrunken Counterexample];
F --> G[7. Fix the Bug];
G --> E;
E -- Passes --> H[8. Consider More Properties or Refine Arbitraries];
H --> B;
Advanced Arbitraries
The real power of fast-check lies in its composable arbitraries. You can generate almost any data structure you can imagine.
fc.record({ key: fc.string(), value: fc.nat() }): Generates objects with a specific shape.fc.tuple(fc.string(), fc.boolean()): Generates arrays with fixed length and types.fc.oneof(fc.integer(), fc.string()): Generates a value that is either an integer or a string.fc.constantFrom('a', 'b', 'c'): Picks one of the provided constants.fc.map(fc.nat(), (n) => \user_${n}`)`: Transforms the output of one arbitrary into something else.fc.chain(fc.nat(5), (n) => fc.array(fc.string(), { minLength: n, maxLength: n })): Generates a numbern, then usesnto define the length of an array.
Example: Testing a User Validation Function
Let's test a function that validates a user object.
function isUserValid(user) {
if (typeof user.id !== 'string' || !user.id.startsWith('user_')) {
return false;
}
if (typeof user.email !== 'string' || !user.email.includes('@')) {
return false;
}
if (typeof user.age !== 'number' || user.age < 18) {
return false;
}
return true;
}
// Property: A validly generated user object should always pass validation
test('a valid user object should always be valid', () => {
// Define an arbitrary for a valid user
const userArbitrary = fc.record({
id: fc.nat().map(n => \`user_\${n}\`), // e.g., "user_123"
email: fc.emailAddress(),
age: fc.integer({ min: 18, max: 120 }),
});
fc.assert(
fc.property(userArbitrary, (user) => {
expect(isUserValid(user)).toBe(true);
})
);
});
This test ensures that our generator and our validator are in sync. If we change the validation logic (e.g., require age to be 21+), this test will fail, telling us our arbitrary for "valid users" is now incorrect. This is a powerful way to document and enforce data contracts.
When to Use Property-Based Testing
PBT is not a replacement for example-based testing; it's a powerful complement.
Use Property-Based Testing for:
- Pure functions with complex logic: Algorithms, data transformations, parsers, serializers.
- Functions with a wide range of inputs: Anything that takes strings, numbers, or complex objects.
- Stateful systems: You can model a sequence of actions as an array and test that your system's state remains consistent. This is known as "stateful property-based testing" and is an advanced but powerful technique.
- Testing for invariants: Any rule that must always hold true. For example, "encoding then decoding a value should return the original value."
Stick to Example-Based Testing for:
- Specific business logic with fixed inputs: e.g.,
calculateTax('resident', 50000). - UI interactions: While possible to model with PBT, it's often simpler to use example-based E2E tests (e.g., with Playwright).
- Simple functions where the range of inputs is tiny and obvious.
Conclusion
Property-based testing forces you to think about your code at a higher level of abstraction. Instead of focusing on individual examples, you define the fundamental truths�the properties�that make your code correct. By pairing this thinking with a powerful generative testing engine like fast-check, you can automatically explore thousands of possibilities, uncovering subtle bugs and edge cases that would be nearly impossible to find manually.
It requires a shift in mindset, but the payoff is immense: more robust, reliable, and resilient software. Start small with a pure function, define a simple property, and let the machine do the hard work of trying to break it.
Ready to elevate your testing game? Sign up for ScanlyApp today and integrate cutting-edge QA strategies into your development workflow.
