Property-based testing can feel like magic, but it’s actually a systematic way to explore the edge cases of your code by generating inputs based on your assumptions.
Let’s say you have a function sortArray(arr) that’s supposed to sort an array of numbers in ascending order.
// src/sortArray.js
function sortArray(arr) {
return arr.slice().sort((a, b) => a - b);
}
// test/sortArray.test.js
const { test, expect } = require('@jest/globals');
const sortArray = require('../src/sortArray');
test('sortArray should sort numbers correctly', () => {
const unsorted = [3, 1, 4, 1, 5, 9, 2, 6];
const sorted = sortArray(unsorted);
expect(sorted).toEqual([1, 1, 2, 3, 4, 5, 6, 9]);
});
This works for a specific, hand-picked array. But what about an empty array? Or an array with one element? Or an array with negative numbers? Or duplicate numbers? Property-based testing aims to answer these questions automatically.
We’ll use jest-pbt for this. First, install it:
npm install --save-dev jest-pbt
Now, let’s rewrite our test using property-based testing. The core idea is to define properties that should always hold true for any valid input, and then let the testing framework generate inputs to try and falsify those properties.
// test/sortArray.test.js
const { test, expect } = require('@jest/globals');
const { forAll, integer } = require('jest-pbt');
const sortArray = require('../src/sortArray');
// Property 1: The output array should have the same length as the input array.
forAll(integer().array(), 'an array of integers', (arr) => {
expect(sortArray(arr).length).toBe(arr.length);
});
// Property 2: The output array should be sorted in ascending order.
forAll(integer().array(), 'an array of integers', (arr) => {
const sortedArr = sortArray(arr);
for (let i = 0; i < sortedArr.length - 1; i++) {
expect(sortedArr[i]).toBeLessThanOrEqual(sortedArr[i + 1]);
}
});
// Property 3: Sorting an already sorted array should not change it.
forAll(integer().array(), 'an array of integers', (arr) => {
const sortedArr = sortArray(arr);
const resortedArr = sortArray(sortedArr);
expect(resortedArr).toEqual(sortedArr);
});
// Property 4: Sorting an array and then reversing it should produce the same result
// as sorting the reversed original array.
forAll(integer().array(), 'an array of integers', (arr) => {
const sortedArr = sortArray(arr);
const reversedSortedArr = sortArray([...arr].reverse()); // Create a reversed copy
expect(sortedArr).toEqual(reversedSortedArr);
});
// Property 5: The smallest element in the sorted array should be the minimum of the original array.
forAll(integer().array({ min: 1 }), 'a non-empty array of integers', (arr) => {
const sortedArr = sortArray(arr);
const minVal = Math.min(...arr);
expect(sortedArr[0]).toBe(minVal);
});
// Property 6: The largest element in the sorted array should be the maximum of the original array.
forAll(integer().array({ min: 1 }), 'a non-empty array of integers', (arr) => {
const sortedArr = sortArray(arr);
const maxVal = Math.max(...arr);
expect(sortedArr[sortedArr.length - 1]).toBe(maxVal);
});
Here, forAll is the core function from jest-pbt. It takes a generator (like integer().array()), a description of the generated data, and a test function. jest-pbt will then call your test function many times with different, randomly generated inputs for arr.
The generators provided by jest-pbt are powerful. integer().array() generates arrays of integers. You can customize this: integer({ min: -100, max: 100 }).array() generates arrays of integers between -100 and 100. integer().array({ min: 1 }) ensures the array has at least one element. You can also generate other types: string(), boolean(), float(), etc.
The key benefit is that property-based testing often finds bugs that are hard to anticipate with manual test case design. For instance, if sortArray had a bug where it incorrectly handled duplicate numbers, the second property (expect(sortedArr[i]).toBeLessThanOrEqual(sortedArr[i + 1])) would likely fail when jest-pbt generates an array like [2, 1, 2].
The magic happens when jest-pbt tries to shrink failing test cases. If a test fails with a large, complex array, jest-pbt will try to generate smaller, simpler versions of that array that still cause the failure, helping you pinpoint the exact problematic input.
The most surprising thing about property-based testing is how quickly it reveals subtle bugs in seemingly simple functions. You define what "correct" means in terms of observable properties, and the framework does the heavy lifting of finding inputs that violate those properties. It’s not just about testing specific examples; it’s about testing the rules your code is supposed to follow.
When you run these tests, jest-pbt will report successes for each property, showing how many test cases were generated and passed. If a test fails, it will show you the specific input that caused the failure and the result of the shrinking process.
The next step is to explore how to combine different generators or create custom generators for more complex data structures.