0x55aa
Back to Blog

🎲 Property-Based Testing: Let the Computer Find Bugs You Forgot to Write Tests For

|
5 min read

You've got 94% test coverage. Your CI is green. You ship with confidence.

Then a user submits a username that's exactly 256 characters, or a price that's 0.1 + 0.2, or a date string that's technically ISO 8601 but also technically cursed β€” and your API returns 500.

The problem isn't that you're bad at writing tests. It's that you're human. You write tests for the inputs you thought of. Property-based testing lets the computer think of inputs you didn't.

The Core Idea

Traditional unit testing is example-based: you pick specific inputs and verify specific outputs.

test('reverses a string', () => {
  expect(reverse('hello')).toBe('olleh');
  expect(reverse('')).toBe('');
  expect(reverse('a')).toBe('a');
});

That's three examples. You felt thorough. But you missed 'racecar' (fine), 'πŸ™‚' (breaks multibyte handling), and null (throws instead of returning gracefully).

Property-based testing flips this: instead of writing examples, you write properties β€” invariants that must hold for any input the framework generates.

import fc from 'fast-check';

test('reverse(reverse(s)) === s for any string', () => {
  fc.assert(
    fc.property(fc.string(), (s) => {
      expect(reverse(reverse(s))).toBe(s);
    })
  );
});

That single test runs 100 random strings by default β€” including empty strings, Unicode, emoji, null bytes, and strings that are suspiciously long. If any of them fail, the framework shrinks the counterexample down to the smallest failing input and hands it to you.

This is the magic. You didn't think of 'οΏ½'. The computer did.

Setting Up fast-check

fast-check is the go-to library for JavaScript/TypeScript property-based testing. It integrates with Jest, Vitest, and Node's built-in test runner.

npm install --save-dev fast-check

No other config needed. It works alongside your existing tests.

A Real Backend Example: Input Validation

Here's where this earns its keep. Suppose you have a function that validates and normalises a monetary amount β€” strips currency symbols, trims whitespace, ensures it's a non-negative number with at most 2 decimal places.

With example-based tests you'd cover "$10.00", " 5 ", "abc". With property-based tests you describe what must always be true:

import fc from 'fast-check';
import { parseAmount } from '../lib/currency';

describe('parseAmount properties', () => {
  test('valid amounts always parse to a non-negative finite number', () => {
    // Generate non-negative numbers with at most 2 decimal places
    const validAmount = fc.float({ min: 0, max: 1_000_000, noNaN: true })
      .map(n => n.toFixed(2));

    fc.assert(
      fc.property(validAmount, (input) => {
        const result = parseAmount(input);
        expect(typeof result).toBe('number');
        expect(isFinite(result)).toBe(true);
        expect(result).toBeGreaterThanOrEqual(0);
        // No more precision than 2 decimal places
        expect(result).toBeCloseTo(parseFloat(input), 2);
      })
    );
  });

  test('invalid strings always return null, never throw', () => {
    // Strings that are definitely not valid amounts
    const garbage = fc.string().filter(s => !/^\s*\$?\s*\d+(\.\d{1,2})?\s*$/.test(s));

    fc.assert(
      fc.property(garbage, (input) => {
        expect(() => parseAmount(input)).not.toThrow();
        // Either returns null or a sensible default β€” never crashes
      })
    );
  });
});

The second property is the one that saves you in production. It doesn't care what parseAmount returns for garbage β€” it only cares that it doesn't throw. A defensive property, not a prescriptive one.

I added this kind of test at Cubet after we traced a production incident to an amount field that accepted "1e308" β€” technically a number, technically not NaN, but way outside anything our downstream payment processor could handle. The property result <= MAX_ALLOWED_AMOUNT would have caught it on the first automated run.

The Shrinking Superpower

When fast-check finds a failing case, it doesn't just hand you the raw generated input β€” which might be a 200-character garbage string. It shrinks it: repeatedly tries simpler versions until it finds the smallest input that still fails.

So instead of: test failed with input: "a9#​f\x00́helloοΏ½..."

You get: test failed with input: "\x00"

That's the null byte. That's the bug. Fix it.

This shrinking step is why property-based testing is so much more actionable than fuzzing. A fuzzer might find the same bug but give you an input that's hard to reason about. fast-check gives you the minimal reproducer.

What Makes a Good Property?

The hardest part isn't the library β€” it's thinking in properties. Some patterns that work well for backend code:

Round-trip properties β€” encode then decode should return the original value. Perfect for serialisation, encryption, compression, base64, JSON transformers.

Idempotency β€” applying an operation twice gives the same result as once. Works for normalisation, deduplication, caching layers.

Metamorphic properties β€” if you change the input in a specific way, the output should change in a predictable way. sort(shuffle(xs)) should equal sort(xs).

Invariants under valid transformation β€” a user's total balance after any sequence of valid deposits and withdrawals should never go negative if you've implemented the guard correctly.

You don't need to cover every possible property. Even one or two per module catches edge cases that would take months to surface in production.

When NOT to Reach for It

Property-based testing isn't a replacement for example-based tests β€” it's a complement. Use example-based tests to document expected behaviour and serve as readable specifications. Use property-based tests to stress the correctness guarantees across unknown inputs.

Also: if your function has side effects that are expensive or irreversible (sending emails, charging cards), wrap them in mocks before property-testing them. fast-check will happily call your function 100 times per run.

The 15-Minute Rule

Here's my rule of thumb: any function that accepts user-supplied strings, numbers, or dates should have at least one property test. That's it. You don't need to property-test your entire codebase. Start with your validation layer and your serialisation utilities.

Fifteen minutes of writing properties will find more edge cases than an afternoon of hand-crafting examples β€” and you'll sleep better knowing the computer has been adversarially poking at your code so your users don't have to.

Your test suite shouldn't just verify the inputs you imagined. It should survive the inputs you didn't.


Using property-based testing in production? I'd love to hear what properties you've found most valuable β€” find me on X at @kpanuragh.

Thanks for reading!

Back to all posts