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:

  1. Generate a wide range of random, valid inputs (integers, strings, arrays, etc.).
  2. Run your test function with each generated input.
  3. Check if the property holds true.
  4. If it finds an input that violates the property, it has found a bug!
  5. 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?

  1. Reversing a string twice should give you the original string back (double reversal).
  2. The length of the reversed string should be the same as the original.
  3. 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.