loke.dev
Cover image for Stop Writing 'Happy Path' Tests (Let Property-Based Testing Break Your Code Instead)

Stop Writing 'Happy Path' Tests (Let Property-Based Testing Break Your Code Instead)

Move beyond example-based testing and discover how to generate thousands of scenarios to find the edge cases you didn't know existed.

· 4 min read

We usually write tests for the things we’ve already thought of. It's a comforting ritual: you write a function, you think of three or four likely scenarios, you assert that 2 + 2 indeed equals 4, and you watch the little green checkmark appear. It feels like progress.

The problem is that your brain is biased. The same brain that wrote the code (and the bugs) is the one writing the tests. You aren't testing for the "unknown unknowns"—the weird stuff like null bytes, negative integers in a "positive only" field, or that one user who decides to put a 5MB string into a first-name field.

Example-based testing is like checking a few stones on a bridge to see if they're solid. Property-based testing (PBT) is like driving a thousand tanks over it at once.

The Flaw in Examples

Most of us write tests like this:

test('it should calculate the total price', () => {
  const items = [
    { name: 'Coffee', price: 5 },
    { name: 'Donut', price: 2 },
  ]
  const total = calculateTotal(items, 0.1) // 10% tax
  expect(total).toBe(7.7)
})

This test is fine. It proves the "Happy Path" works. But what if price is -1? What if tax is 1.5? What if the array is empty? You could write ten more tests manually, but you’ll still probably miss the one edge case that crashes your server at 3:00 AM on a Sunday.

Enter: Property-Based Testing

Instead of providing specific inputs, property-based testing asks you to describe the properties of your output. You define the _shape_ of the input, and a library generates hundreds or thousands of random variations for you.

If you're in the TypeScript ecosystem, fast-check is the gold standard for this.

Let’s look at a "vulnerable" function that calculates a discount:

// The implementation we *think* is fine
function applyDiscount(price: number, discountPercent: number): number {
  return price - price * (discountPercent / 100)
}

Instead of testing applyDiscount(100, 20), we define the rules:

  1. The discounted price should never be greater than the original price (assuming no negative discounts).
  2. The price should never be negative.
  3. The function should not throw, no matter what numbers we throw at it.

Here is how we write that with fast-check:

import fc from 'fast-check'

test('applyDiscount properties', () => {
  fc.assert(
    fc.property(
      fc.double({ min: 0, max: 10000 }), // price
      fc.double({ min: 0, max: 100 }), // discount
      (price, discount) => {
        const result = applyDiscount(price, discount)

        // Property 1: Result is never more than original
        // Property 2: Result is never negative
        return result <= price && result >= 0
      }
    )
  )
})

When the Magic Happens: Shrinking

The first time you run a property-based test on existing code, it will likely fail. And it won't just say "it failed"; it will give you the minimal failing case. This is called _shrinking_.

If fast-check finds a failure with a massive, complex input, it doesn't just hand you that garbage. It tries to simplify the input repeatedly until it finds the smallest possible value that still breaks your code.

For example, if it finds that a 10,000-character string with emojis breaks your parser, it will "shrink" it down and might tell you: "It breaks when the input is exactly '\0' (a null byte)." That is a massive productivity boost. You don't have to hunt for the needle in the haystack; the library hands you the needle.

Thinking in Properties

The hardest part isn't the syntax—it's shifting your mindset. You have to stop thinking about _values_ and start thinking about _invariants_.

Here are a few patterns I use to find properties:

1. The Oracle (The "Cheat" Method)

Compare your optimized, complex function against a "dumb" but obviously correct version.

  • _Property:_ mySuperFastSort(list) == list.sort().

2. Inverse Operations

If you perform an action and then its opposite, you should end up where you started.

  • _Property:_ decrypt(encrypt(text)) == text.
  • _Property:_ JSON.parse(JSON.stringify(obj)) == obj.

3. Idempotence

Running the function multiple times shouldn't change the result after the first time.

  • _Property:_ convertToSlug(convertToSlug(text)) == convertToSlug(text).

4. Invariants

Things that should _always_ be true.

  • _Property:_ A sorted list always has the same length as the original list.
  • _Property:_ The sum of a transaction's parts must always equal the total.

Where to Use This (and Where Not To)

I'll be honest: don't use this for your UI components. Trying to property-test if a button is blue is a waste of time.

Use PBT for:

  • Data transformations: JSON parsers, CSV generators, custom formatters.
  • Math/Logic heavy code: Pricing engines, scheduling algorithms, risk calculators.
  • Security-sensitive inputs: Validating user-provided strings or files.

The goal isn't to replace every unit test you have. The goal is to let the computer do the boring work of trying to break your logic. We are remarkably bad at imagining the ways our code will fail. We might as well outsource that lack of imagination to a generator that can try 50,000 combinations while we grab a coffee.