TL;DR: Property-based testing means describing rules your code should always follow, then letting a tool generate hundreds of random inputs to try to break those rules. Instead of testing "does add(2, 3) return 5?", you test "does adding two positive numbers always return something bigger than either input?" Tools like fast-check (JavaScript/TypeScript), Hypothesis (Python), and Bombadil (web UIs) do the heavy lifting. AI is surprisingly good at helping you identify the right properties to test — making this one of the testing strategies where AI + vibe coder is a strong combo.

The Problem With How Most People Test

Let's start with how most people — AI-assisted or not — write tests. You think of a few examples, write them out, run them, and if they pass you ship.

Something like this:

// A function that formats a price
function formatPrice(cents: number): string {
  return `$${(cents / 100).toFixed(2)}`;
}

// Your tests
test('formats 100 cents as $1.00', () => {
  expect(formatPrice(100)).toBe('$1.00');
});

test('formats 1500 cents as $15.00', () => {
  expect(formatPrice(1500)).toBe('$15.00');
});

test('formats 0 cents as $0.00', () => {
  expect(formatPrice(0)).toBe('$0.00');
});

These tests pass. You ship. And three weeks later a customer complains that a price is showing up as $-0.00. Or $NaN. Or that someone entered -500 and got a negative price displayed. Or that a floating-point precision issue turned $9.99 into $9.990000000001.

You didn't test for those cases because you didn't think of them. And you didn't think of them because you were testing specific examples, not testing the behavior of the function as a whole.

That's the gap property-based testing fills.

Instead of asking "does this specific input produce this specific output?", you ask "does this function always behave according to these rules?" And then you let a machine spend five seconds inventing every weird edge case it can think of.

What Property-Based Testing Actually Is

Here's the core idea in plain English: you write a rule, the tool tries to disprove it.

A "property" is just a rule that should be true no matter what input you throw at the function. Some examples of properties for our price formatter:

  • Always starts with a dollar sign. No matter what number you pass in, the output should start with $.
  • Never contains more than two decimal places. $1.001 is never valid.
  • The number part is always a valid decimal. $NaN and $Infinity are never valid outputs.
  • For any positive input, the output is a positive amount. You can't format 500 cents as a negative price.

With property-based testing, you write those rules in code. Then the library generates hundreds — sometimes thousands — of random inputs and checks whether your rule holds for every single one. If it finds an input that breaks your rule, it shows you exactly what that input was and fails the test.

That failing input is called a counterexample. The tool even tries to "shrink" it — find the smallest possible input that still breaks the rule — so you're not debugging a 500-character string when a 3-character string demonstrates the same bug.

Think of it like hiring a QA tester whose entire job is inventing obnoxious edge cases. Except they work in milliseconds and never get tired.

fast-check: The Tool You'll Actually Use

fast-check is the standard property-based testing library for JavaScript and TypeScript. It works alongside Vitest, Jest, or any other test runner you're already using — you don't have to replace anything.

Install it:

npm install --save-dev fast-check

Here's what property-based tests for our price formatter actually look like with fast-check:

import { describe, test, expect } from 'vitest';
import * as fc from 'fast-check';
import { formatPrice } from './formatPrice';

describe('formatPrice', () => {
  test('always starts with a dollar sign', () => {
    fc.assert(
      fc.property(fc.integer(), (cents) => {
        expect(formatPrice(cents)).toMatch(/^\$/);
      })
    );
  });

  test('never contains more than two decimal places', () => {
    fc.assert(
      fc.property(fc.integer({ min: 0 }), (cents) => {
        const formatted = formatPrice(cents);
        const decimalPart = formatted.split('.')[1];
        expect(decimalPart?.length ?? 0).toBeLessThanOrEqual(2);
      })
    );
  });

  test('never produces NaN or Infinity in the output', () => {
    fc.assert(
      fc.property(fc.integer(), (cents) => {
        const formatted = formatPrice(cents);
        expect(formatted).not.toContain('NaN');
        expect(formatted).not.toContain('Infinity');
      })
    );
  });

  test('for any positive input, the formatted amount is positive', () => {
    fc.assert(
      fc.property(fc.integer({ min: 1 }), (cents) => {
        const amount = parseFloat(formatPrice(cents).replace('$', ''));
        expect(amount).toBeGreaterThan(0);
      })
    );
  });
});

Fast-check runs each of those rules against hundreds of randomly generated integers. If your formatPrice function has a hidden bug — say, it breaks when cents is Number.MAX_SAFE_INTEGER, or when it receives -0 — fast-check will find it and show you exactly which input caused the failure.

The "Arbitraries" System — Controlling What Gets Generated

The thing that makes fast-check flexible is its system of arbitraries — these are the generators that control what kind of random data gets created. You pick the arbitrary that matches what your function actually accepts.

Some common ones:

fc.integer()          // any integer
fc.integer({ min: 0, max: 100 }) // integer in a range
fc.float()            // any floating point number
fc.string()           // any string
fc.emailAddress()     // random but valid-looking email addresses
fc.date()             // random Date objects
fc.array(fc.string()) // arrays of random strings
fc.record({           // objects with specific shapes
  name: fc.string(),
  age: fc.integer({ min: 0, max: 120 })
})
fc.oneof(             // one of multiple arbitraries
  fc.integer(),
  fc.string()
)

You're not just pointing a random number cannon at your function. You're generating random data that matches the shape your function expects — which means the tests are useful, not just noise.

Where AI Fits In — And Why This Pairing Works

Here's the thing about property-based testing that trips people up: knowing what properties to test is harder than writing the test code itself. Coming up with the rules requires you to think carefully about what your function is supposed to guarantee.

This is exactly the kind of thinking AI is good at. You give it a function, ask it to think about what should always be true, and it generates a solid first list of properties. You don't have to invent them from scratch — you're reviewing and approving them instead.

Prompt to generate property ideas:

"Here's a TypeScript function: [paste your function]. I want to write property-based tests for it using fast-check. What are the properties (rules that should always be true) I should test? Give me 5-8 specific properties, written as plain English statements like 'for any valid input, the output should always...' Don't write the test code yet — just the properties."

The AI will come back with a list like:

  • "The output length should never exceed the input length plus 10 characters"
  • "Calling the function twice with the same input should always return the same output (idempotent)"
  • "The output should always be parseable as valid JSON"
  • "For any input, encoding then decoding should return the original value"

Then you use a second prompt to turn those into actual test code:

Prompt to generate the test code:

"Now write fast-check property tests for these properties: [paste the list]. Use Vitest as the test runner. Use fc.property() and fc.assert(). For each property, pick the most appropriate fast-check arbitrary for the input type. Add a brief comment above each test explaining what it's checking."

The AI-generated tests won't always be perfect — sometimes it picks the wrong arbitrary, or the property isn't quite right once you look at it closely. But you're reviewing and editing a draft instead of writing from a blank page. For most functions, you end up with working property tests in under ten minutes.

Real-World Example: Testing a URL Slug Generator

Here's a more practical example. Say AI wrote you this function that converts article titles into URL slugs:

function slugify(title: string): string {
  return title
    .toLowerCase()
    .trim()
    .replace(/[^\w\s-]/g, '')
    .replace(/\s+/g, '-')
    .replace(/-+/g, '-');
}

You could write unit tests for specific titles: "Hello World" → "hello-world", "This & That" → "this-that". But those tests only verify the cases you imagined. Property-based tests let you verify the contract of the function for any title anyone might ever pass in.

Here's what that looks like:

import * as fc from 'fast-check';
import { slugify } from './slugify';

describe('slugify', () => {
  // Property 1: Output is always lowercase
  test('output is always lowercase', () => {
    fc.assert(
      fc.property(fc.string(), (title) => {
        expect(slugify(title)).toBe(slugify(title).toLowerCase());
      })
    );
  });

  // Property 2: Output never has consecutive dashes
  test('output never has consecutive dashes', () => {
    fc.assert(
      fc.property(fc.string(), (title) => {
        expect(slugify(title)).not.toMatch(/--/);
      })
    );
  });

  // Property 3: Output never starts or ends with a dash
  test('output never starts or ends with a dash', () => {
    fc.assert(
      fc.property(fc.string(), (title) => {
        const slug = slugify(title);
        if (slug.length > 0) {
          expect(slug[0]).not.toBe('-');
          expect(slug[slug.length - 1]).not.toBe('-');
        }
      })
    );
  });

  // Property 4: Running slugify twice gives the same result as running it once
  test('slugify is idempotent — running it twice changes nothing', () => {
    fc.assert(
      fc.property(fc.string(), (title) => {
        expect(slugify(slugify(title))).toBe(slugify(title));
      })
    );
  });

  // Property 5: Output contains only valid URL characters
  test('output contains only lowercase letters, numbers, and hyphens', () => {
    fc.assert(
      fc.property(fc.string(), (title) => {
        expect(slugify(title)).toMatch(/^[a-z0-9-]*$/);
      })
    );
  });
});

Run this and fast-check immediately finds that property 3 fails. The slug can start with a dash if the title starts with special characters that get stripped, leaving a leading hyphen. Your specific unit tests never caught that because "Hello World" and the other titles you picked don't trigger it. fast-check found it on the first run because it tried inputs like "-hello", "!!!", and " - world".

That's the point. You write the rules. The machine breaks them.

Bombadil: Property-Based Testing for Web UIs

fast-check works great for functions that take data and return data. But what about UIs? What about bugs that only show up after a specific sequence of user interactions — open the modal, fill in the form, close it without submitting, open it again, and now the Submit button is broken?

That's the problem Bombadil, built by Antithesis, is designed to solve. It's been getting traction recently because it applies the same "generate random inputs to find failures" idea to the entire surface of a web interface.

Instead of generating random numbers, Bombadil generates random sequences of user actions:

  • Click this button
  • Type this text into that input
  • Scroll to this position
  • Navigate to this route
  • Wait for this network request to resolve
  • Click that other button

You define what "correctly working" means — a property like "the cart total should always equal the sum of item prices" or "a logged-out user should never be able to see account data." Bombadil then runs thousands of random interaction sequences to find one that violates your rule.

The bugs it finds are the hardest kind to find manually — race conditions, state management issues that only surface after many interactions, UI components that break only in specific sequences. These are the bugs that slip past hand-written tests and only appear in production when real users do unexpected things.

Bombadil vs. fast-check in one sentence: fast-check tests your functions with random data. Bombadil tests your UI with random interactions. They solve different problems and work well together.

Hypothesis: If You're Working in Python

If any part of your stack is Python — backend APIs, data processing scripts, ML pipelines — Hypothesis is the property-based testing library to know. It's older than fast-check and widely considered the gold standard implementation of the technique.

The concept is the same. Here's what it looks like:

from hypothesis import given, strategies as st
from myapp import slugify

@given(st.text())
def test_slugify_is_always_lowercase(title):
    assert slugify(title) == slugify(title).lower()

@given(st.text())
def test_slugify_has_no_consecutive_dashes(title):
    assert '--' not in slugify(title)

@given(st.text())
def test_slugify_is_idempotent(title):
    assert slugify(slugify(title)) == slugify(title)

# Hypothesis also ships with strategies for common types
@given(st.emails())
def test_email_validator_accepts_all_valid_emails(email):
    # Your validator should accept any properly formatted email
    assert is_valid_email(email) is True

@given(st.lists(st.integers(), min_size=1))
def test_sort_output_length_matches_input(items):
    sorted_items = my_sort(items)
    assert len(sorted_items) == len(items)

Hypothesis has a particularly useful feature called the database — it remembers every counterexample it's ever found and re-runs those first on future test runs. So if a weird input broke your code once and you fixed it, Hypothesis will keep checking that exact input every time. It never forgets a bug.

When to Use Property-Based Testing (And When Not To)

Property-based testing is not a replacement for regular tests. It's a second layer that catches a different category of bugs. Here's when each approach wins:

Use regular example-based tests for:

  • Specific business logic with known correct outputs: "when the user is on a Pro plan and buys 3 items, the 3rd item should be 20% off"
  • Testing integrations with external services where you're mocking specific responses
  • Regression tests for bugs you've already fixed — you want to test exactly the input that caused the bug
  • UI behavior tied to specific user stories

Use property-based tests for:

  • Data transformation functions — anything that takes input data and reshapes it
  • Validation and parsing — form validators, URL parsers, date formatters
  • Encoding/decoding — serialization, compression, encoding schemes
  • Sort and search algorithms — output should be sorted, all items should be present
  • Mathematical functions — should be consistent, idempotent, or reversible
  • Anything that processes user input — users will enter things you never imagined

The pattern that works well for AI-assisted development: let AI write the function, then use property-based testing to verify AI didn't introduce subtle edge-case bugs. AI is great at generating plausible-looking logic. Property-based testing is great at exposing the cases where plausible-looking logic breaks down.

Prompt for testing AI-generated code:

"You just wrote this function: [paste function]. Before I ship it, I want to write property-based tests using fast-check to check for edge cases. What are the riskiest edge cases for this kind of function? What properties might break? Give me 5 properties that are most likely to find real bugs, not obvious ones."

The Four Property Patterns That Cover Most Cases

If you're stuck on what properties to test, these four patterns apply to a huge percentage of functions. Run through this list whenever you're writing a new property test:

1. Round-Trip Properties

If you encode something, you should be able to decode it. If you serialize something, you should be able to deserialize it. If you compress, you should be able to decompress.

// Encode → decode should return the original
test('base64 round-trip always recovers the original string', () => {
  fc.assert(
    fc.property(fc.string(), (original) => {
      const encoded = btoa(original);
      const decoded = atob(encoded);
      expect(decoded).toBe(original);
    })
  );
});

2. Idempotency Properties

Running a function twice should give the same result as running it once. This applies to formatters, sanitizers, and normalizers.

// Trimming whitespace twice is the same as doing it once
test('trim is idempotent', () => {
  fc.assert(
    fc.property(fc.string(), (s) => {
      expect(s.trim().trim()).toBe(s.trim());
    })
  );
});

3. Invariant Properties

Something about the output should always hold, regardless of input — length, type, format, range.

// Sorting never changes the length of the array
test('sort preserves array length', () => {
  fc.assert(
    fc.property(fc.array(fc.integer()), (arr) => {
      const sorted = [...arr].sort((a, b) => a - b);
      expect(sorted.length).toBe(arr.length);
    })
  );
});

// Every element in the input is still in the output
test('sort preserves all elements', () => {
  fc.assert(
    fc.property(fc.array(fc.integer()), (arr) => {
      const sorted = [...arr].sort((a, b) => a - b);
      expect(sorted).toEqual(expect.arrayContaining(arr));
    })
  );
});

4. Comparison Properties

When two different approaches to the same problem should agree, you can use one as a reference implementation to verify the other. This is especially powerful for verifying AI-generated code against a simpler (but slower) version.

// Your optimized search should find the same results as a naive scan
test('optimized search finds same results as linear scan', () => {
  fc.assert(
    fc.property(
      fc.array(fc.integer()),
      fc.integer(),
      (haystack, needle) => {
        const fastResult = binarySearch(haystack.sort((a, b) => a - b), needle);
        const naiveResult = haystack.sort((a, b) => a - b).indexOf(needle);
        expect(fastResult).toBe(naiveResult);
      }
    )
  );
});

Getting Started Today: A 15-Minute Setup

Here's the fastest path from zero to running property-based tests. This assumes you already have a project with Vitest or Jest. If you're starting from scratch, check out our guides on Vitest and Jest.

Step 1: Install fast-check.

npm install --save-dev fast-check

Step 2: Pick one function in your codebase that processes user input or transforms data. Something like a formatter, validator, or parser. Start small.

Step 3: Use this AI prompt to generate your first property list:

Starter prompt:

"Here's a TypeScript function I want to property-test: [paste function]. Using fast-check and Vitest, write 4-5 property tests that check rules this function should always follow. Use fc.assert(fc.property(...)) syntax. Focus on: (1) output format invariants, (2) idempotency if applicable, (3) any properties related to the numeric or string structure of the output. Add a comment above each test saying what it's checking in plain English."

Step 4: Run the tests. If any fail, fast-check will show you the counterexample — the exact input that broke your rule. Fix the function, rerun, and repeat.

Step 5: Add property tests to your existing test files alongside your regular tests. They don't need to be in separate files — a mix of example-based and property-based tests in the same file is completely normal.

That's the whole workflow. You're not replacing anything you're doing now. You're adding a second layer that catches a different class of bugs — the ones that only show up when a user does something you didn't think to test for.

How It Fits With Your Other Testing Tools

Property-based testing lives alongside your existing test stack, not instead of it. Here's how the pieces fit:

  • Vitest or Jest — These are your test runners. fast-check plugs directly into either one. You run vitest or jest like normal and your property tests run alongside your regular tests.
  • Playwright — Playwright tests user interactions in a real browser using scripts you write. Bombadil takes a similar approach but with generated interaction sequences instead of hand-written scripts. They complement each other: Playwright for specific user flows you care about, Bombadil for finding the unexpected ones.
  • fast-check — Pure function testing with generated data. This is where you start.

A realistic testing setup for an AI-assisted project might look like: Vitest for unit and integration tests (including fast-check properties for data-handling code), Playwright for key user flows (checkout, login, onboarding), and Bombadil if you have complex UI state that needs deeper exploration.

Frequently Asked Questions

What is the difference between property-based testing and unit testing?

Unit tests check specific, hand-picked inputs: you write "when I pass 5 and 3, I expect 8." Property-based tests check rules: "for any two positive numbers, adding them should give a result bigger than either one." The property-based tool then invents hundreds of random inputs to try to disprove your rule. You write fewer tests but cover far more ground, especially edge cases you wouldn't have thought to write by hand.

Is property-based testing only for experienced developers?

No — and AI makes it more accessible than ever. The hard part used to be figuring out what rules to write. Now you can describe your function to an AI and ask it to suggest properties to test. You still need to understand what your code is supposed to do, but you don't need a computer science background to get real value out of it. If you can describe what your function is supposed to do, you can write property tests.

What is fast-check and how do I install it?

fast-check is the most popular property-based testing library for JavaScript and TypeScript. Install it with: npm install --save-dev fast-check. It works alongside your existing test framework — Vitest, Jest, or any other — so you don't have to replace anything you're already using. It gives you a set of generators (called "arbitraries") for creating random data, and a way to assert that your function obeys a rule for every generated input.

What is Bombadil and how is it different from fast-check?

Bombadil, built by Antithesis, is a property-based testing tool specifically designed for web UIs and distributed systems. While fast-check generates random data for function inputs, Bombadil generates random sequences of user interactions and system events. It's designed to find the kinds of bugs that only show up after many UI interactions in a specific order — things like a modal that breaks only after you open it, fill it in, close it, open it again, and then submit. Different problem, same core idea.

When should I use property-based testing versus regular tests?

Use regular tests for business logic that has specific, known correct answers — "when a user logs in with the right password, they should see their dashboard." Use property-based testing for functions where the output should obey rules regardless of input — sorting functions, data transformations, validation logic, parsers, anything that processes user input. The two approaches complement each other. A solid test suite uses both: regular tests for business behavior, property tests for data handling correctness.

What to Learn Next

  • What Is Vitest? — The fastest way to run tests in modern JavaScript projects. fast-check integrates directly with it.
  • What Is Jest? — If you're on an older project or a React setup that came with Jest, fast-check works here too.
  • What Is Playwright? — For testing what your UI actually does in a real browser. Pairs well with Bombadil for full UI test coverage.