Property-Based Testing: Let the Computer Write Your Test Cases
Example-based testing is great, but it can't cover every edge case. Property-based testing generates hundreds of random test cases to find bugs you never thought to look for.
When we write unit tests, we typically use example-based testing. We think of a few specific inputs and assert that they produce the expected outputs.
test('Math.abs should return the positive version of a number', () => {
expect(Math.abs(-1)).toBe(1);
expect(Math.abs(5)).toBe(5);
expect(Math.abs(0)).toBe(0);
});
This is good, but it has a weakness: it only tests the examples you can think of. What about edge cases like Infinity, NaN, or very large numbers? We’re limited by our own imagination.
Property-based testing flips this on its head. Instead of writing tests for specific examples, you define a property that should hold true for all valid inputs. Then, you let a testing framework generate hundreds of random inputs to try and prove that property wrong.
The Core Idea: Properties, Not Examples
A property is a high-level statement about your code’s behavior. For example, for a sorting function, a good property would be: “for any list of numbers, the output list should be sorted.” Another would be: “the output list should contain the exact same numbers as the input list.”
A property-based testing framework will then:
- Generate a wide range of random, valid inputs (integers, strings, arrays, etc.).
- Run your test function with each generated input.
- Check if the property holds true.
- If it finds an input that violates the property, it has found a bug!
- Shrink the failing input to the smallest and simplest possible case that still causes the failure. This is a killer feature that makes debugging much easier.
TypeScript Example: Testing a sum function
Let’s use the fast-check library in a Jest environment. First, install it:
npm install --save-dev fast-check jest
An Incorrect sum Function
Here’s a simple function with a subtle bug.
// utils.ts
export function sum(arr: number[]): number {
// Bug: This will fail spectacularly for very large numbers
// due to floating-point inaccuracies.
return arr.reduce((acc, val) => acc + val, 0);
}
An Example-Based Test (That Passes)
A traditional test would likely miss the bug.
// utils.test.ts
import { sum } from './utils';
test('sum should add numbers correctly', () => {
expect(sum([1, 2, 3])).toBe(6);
expect(sum([-1, 1])).toBe(0);
expect(sum([])).toBe(0);
});
// This test passes, giving us a false sense of security.
A Property-Based Test (That Finds the Bug)
Now, let’s define a property. A simple property for sum is that sum([a, b]) should always equal a + b.
// utils.test.ts
import { sum } from './utils';
import * as fc from 'fast-check'; // Import fast-check
describe('sum properties', () => {
it('should be equivalent to adding two numbers', () => {
// fc.property defines a property-based test
// fc.integer() is an "Arbitrary", a generator for random integers.
fc.assert(
fc.property(fc.integer(), fc.integer(), (a, b) => {
// The property we are testing:
expect(sum([a, b])).toBe(a + b);
})
);
});
it('should be commutative', () => {
// Another property: the order of elements in an array shouldn't matter.
fc.assert(
fc.property(fc.array(fc.float()), (arr) => {
const reversedArr = [...arr].reverse();
// This will find the floating point bug!
expect(sum(arr)).toBe(sum(reversedArr));
})
);
});
});
When you run this, fast-check will generate hundreds of inputs. It will quickly find a case with large floating-point numbers where sum(arr) does not equal sum(reversedArr) due to the order of operations and precision loss. It will then shrink the failure to a simple example like [0.1, 0.2, 0.3] vs [0.3, 0.2, 0.1], showing you the exact kind of data that causes the problem.
Python Example: Testing a String Reversal Function
Let’s use the popular hypothesis library in Python.
pip install pytest hypothesis
The Function to Test
# codec.py
def reverse_string(s: str) -> str:
return s[::-1]
What properties should reverse_string have?
- Reversing a string twice should give you the original string back (
double reversal). - The length of the reversed string should be the same as the original.
- Reversing an empty string results in an empty string.
The Property-Based Test
# test_codec.py
from hypothesis import given, strategies as st
from codec import reverse_string
# @given is a decorator that turns a test into a property-based test.
# st.text() is a "strategy" that generates arbitrary text strings.
@given(st.text())
def test_reversing_twice_gives_original_string(s: str):
"""
Property: reverse(reverse(s)) == s
"""
assert reverse_string(reverse_string(s)) == s
@given(st.text())
def test_length_is_preserved_after_reversal(s: str):
"""
Property: len(reverse(s)) == len(s)
"""
assert len(reverse_string(s)) == len(s)
# You can combine strategies to create more complex data
@given(st.lists(st.integers()))
def test_reversing_a_list(li: list[int]):
original = list(li) # Make a copy
li.reverse()
assert len(original) == len(li)
li.reverse()
assert original == li
When you run this with pytest, Hypothesis will generate a huge variety of strings to test your function: empty strings, very long strings, strings with Unicode characters, emojis, whitespace, and more—all cases you would likely never think of writing examples for. If it finds a bug (for example, if our function handled a certain emoji incorrectly), it would report the exact minimal string that causes the failure.
When to Use Property-Based Testing
Property-based testing doesn’t replace example-based testing; it complements it.
-
Use Example-Based Testing for:
- Key, known business cases (“A premium user should get a 10% discount”).
- Documenting the primary use cases of your code.
- Testing specific, known edge cases you’ve fixed before (regression tests).
-
Use Property-Based Testing for:
- Testing pure functions, especially mathematical or data transformation logic.
- Finding edge cases in serialization/deserialization logic.
- Testing stateful systems by generating sequences of actions.
- Any time you find yourself writing many repetitive, similar-looking example-based tests.
Thinking in properties forces you to understand your code at a deeper, more abstract level. It encourages you to define the fundamental truths about your functions, and then uses the brute-force power of the computer to try and find any exceptions to those truths. It’s a powerful technique for building more robust and reliable software.