← Back to Index

Lesson 11

Property-Based Testing

The Problem with Traditional Unit Tests

A traditional unit test is example-based: you pick specific inputs, define expected outputs, and verify they match.

// Example-based test
it('should add item to cart', () => {
  const cart = new Cart();
  cart.add(item1);
  expect(cart.items.length).toBe(1);
});

The weakness: you're limited to the scenarios you think of. You test the happy path, maybe a few edge cases, and call it done. But the bugs that reach production are almost always in cases you didn't think to test.

This is even worse when AI writes both the code and the tests - the same blind spots exist in both.

What Property-Based Testing Is

Note: PBT is an independent testing technique (originating in Haskell's QuickCheck, 1999) - not something Kiro invented. You can use fast-check directly in any project without Kiro. It's covered here because Kiro has built PBT into its spec workflow as the primary verification mechanism for generated code. Understanding PBT helps you understand what Kiro is doing during the "verify" step, and gives you a technique you can adopt independently of Kiro.

Instead of testing specific examples, you define properties - universal statements about how your system should behave for any valid input.

What is a "property"?

A property is a rule of the form: "for any valid input meeting certain preconditions, some expected behaviour must hold." Examples:

These are fundamentally different from "when I pass 'test@example.com', it returns true." Properties describe the general rule, not a specific instance.

How fast-check executes properties

import * as fc from 'fast-check';

// 1. Define an ARBITRARY - a generator for random valid inputs
const arbitraryEmail = fc.emailAddress();
const arbitraryUser = fc.record({
  id: fc.uuid(),
  name: fc.string({ minLength: 1, maxLength: 100 }),
  email: arbitraryEmail,
});

// 2. Define the PROPERTY - what must always be true
fc.assert(
  fc.property(arbitraryUser, (user) => {
    // This runs HUNDREDS of times with different random users
    const created = createUser(user);
    const fetched = getUser(created.id);

    // The property: creating then fetching should return the same data
    expect(fetched.name).toBe(user.name);
    expect(fetched.email).toBe(user.email);
  })
);

// 3. If any generated user causes a failure, fast-check SHRINKS:
//    "Failed with user { name: '🎭', email: 'a+b@c.com' }"
//    → shrinks to minimal failing case
//    "Minimal failure: user { name: '\\', email: 'a@b.c' }"

The framework does three things:

  1. Generates - creates hundreds of random valid inputs based on your arbitrary definitions
  2. Checks - runs your property assertion against each one
  3. Shrinks - when a failure is found, reduces the input to the smallest case that still fails (making the bug obvious)

How Kiro Uses This - The Full Picture

PBT isn't just a testing pattern you could use yourself - it's built into Kiro's spec workflow as the primary mechanism for verifying that generated code matches your intent. Here's why this matters:

The AI-specific problem

When AI generates code, who verifies it's correct? Traditionally:

Kiro's solution: separate the concerns. The spec (your requirements) is the source of truth. Code and tests are derived independently from it:

The key insight: Kiro derives property tests from the spec, not from the code. The spec produces both the code and the tests as two independent outputs. When they disagree, the code is wrong - because the spec represents your intent. This is fundamentally different from "write tests for this code", which can only confirm what the code does, not whether it's correct.

If tests were derived from the code, they'd just validate internal consistency - the code does what the code does. Circular. By deriving tests from the spec instead, you get genuine verification: does the code do what you intended?

The concrete flow in a Kiro spec session

When you run a spec, this happens behind the scenes:

YOUR SPEC (requirements.md) "WHEN a user submits an order with items totalling over £50, THE System SHALL apply a 10% discount" Kiro extracts testable properties PROPERTIES For ANY set of items where total > 50: discount = total * 0.1 For ANY set of items where total ≤ 50: discount = 0 Discount is NEVER negative Discount NEVER exceeds the order total Kiro generates fast-check tests PROPERTY-BASED TESTS fc.property(arbitraryOrderItems(), (items) => { const total = sum(items.map(i => i.price)); const discount = calculateDiscount(items); if (total > 50) expect(discount).toBeCloseTo(total * 0.1); else expect(discount).toBe(0); expect(discount).toBeGreaterThanOrEqual(0); }); Tests run against generated code VERIFICATION 247 random inputs passed FAILED: items = [{price: 25.01}, {price: 25.00}] Expected discount: 5.001, Got: 5.00 (rounding bug) Kiro fixes the code and re-runs

The spec-to-verification pipeline: requirements are translated into properties, then into fast-check tests, then run against generated code. Failures feed back into code fixes automatically.

Notice: the property test found a rounding bug with a specific combination (£25.01 + £25.00 = £50.01 - just over the threshold). A hand-written test would likely have used round numbers and missed this.

Where you see this in the IDE

In a spec session:

What makes this Kiro-specific (not just "use fast-check yourself")

If you used fast-check yourselfWhat Kiro does differently
You decide what properties to testKiro derives properties from your natural-language spec
You write the arbitraries (random generators)Kiro generates appropriate arbitraries for your domain types
You run tests manuallyKiro runs them automatically as part of code generation
Failures require you to fix the codeKiro uses failures as feedback to fix its own generated code
Properties might miss requirementsProperties are traced back to specific requirements - nothing is missed

The key insight: PBT in Kiro isn't a tool you use - it's part of the code generation feedback loop. Kiro generates code, then immediately tries to break it with property tests, then fixes what breaks. You get more correct code on the first pass.

When does Kiro NOT use PBT?

Practical tip: Write your spec requirements in EARS format ("WHEN X, THE System SHALL Y") and PBT quality improves dramatically. The more precise your requirements, the better the properties Kiro derives. Vague requirements → vague properties → weak tests.
Ad

How to make PBT work harder for you

  1. Use spec mode for logic-heavy features - PBT only triggers in spec mode, not vibe
  2. Write precise requirements - "SHALL apply 10% discount" is testable; "should handle discounts" isn't
  3. Include boundary conditions in requirements - "WHEN total is exactly £50" forces Kiro to test the boundary
  4. Keep the generated property tests - they're valuable regression protection even after the initial generation
  5. Ask explicitly - "Verify this implementation against the spec using property-based tests" if Kiro doesn't do it automatically

Concrete Example

Your spec requirement:

WHEN a user adds a listing to favourites,
THE System SHALL display it in their favourites list.

Traditional test (example-based):

it('user A adds listing #5 to favourites', () => {
  addToFavourites(userA, listing5);
  expect(getFavourites(userA)).toContain(listing5);
});

Property-based test (what Kiro generates):

it('any user adding any active listing to favourites should see it in their list', () => {
  fc.assert(
    fc.property(
      arbitraryUser(),
      arbitraryActiveListing(),
      (user, listing) => {
        addToFavourites(user, listing);
        expect(getFavourites(user)).toContain(listing);
      }
    )
  );
});

The property test checks this with: users with special characters in names, listings with extreme prices, users with empty favourites lists, users with 1000 existing favourites, and hundreds more combinations - catching edge cases you'd never think to write manually.

What This Catches That Unit Tests Miss

Why PBT catches what reviews miss: A seemingly correct implementation of API key storage could pass all hand-written tests but fail a property-based test - because PBT generates keys with special characters, extreme lengths, and unicode that no one thought to test manually. Code review checks logic, not exhaustive input combinations.

How to Trigger PBT in Kiro

Property-based testing is part of the spec workflow. When you use spec mode:

  1. Create a spec with clear requirements (EARS-format helps)
  2. Kiro generates the implementation tasks
  3. When tasks execute, Kiro generates property-based tests alongside the code
  4. Look for a "Verify" or "Correctness" step - that's where PBT runs

You can also ask explicitly: "Generate property-based tests for this service" or "Verify this code against the spec using PBT."

The Library: fast-check

Kiro uses fast-check for TypeScript/JavaScript PBT. It provides:

Where AI stops and fast-check starts

fast-check has no AI in it. It's purely algorithmic - deterministic pseudo-random generation based on type constraints. It predates the AI era (2017). The division of labour:

AI (Kiro)fast-check (no AI)
Does whatReads your spec, decides what properties to test, writes the fast-check codeExecutes the tests - generates random inputs, checks assertions, shrinks failures
Kind of "smart"Understanding intent, reasoning about requirementsExhaustive exploration of input space, minimal counterexample finding
Deterministic?No - LLM output variesYes - same seed produces same inputs

The AI's job is writing the property tests. fast-check's job is running them exhaustively. Two different kinds of "smart" working together.

fast-check integrates with Jest, Vitest, and other test runners you're already using. It's not a separate framework - it plugs into your existing test setup.

PBT vs Unit Tests: Complementary, Not Replacement

Unit tests (example-based)Property-based tests
What you defineSpecific input → expected outputA rule that must hold for ALL valid inputs
CoverageOnly the cases you thought ofHundreds of randomly generated cases
FindsRegressions in known scenariosEdge cases you never considered
ReadabilityClear - one input, one outputMore abstract - describes a general rule
SpeedFast (few cases)Slower (hundreds of cases)
Best forCritical paths, specific bug fixesInvariants, contracts, spec compliance

You keep both. Unit tests for clarity and regression coverage. Property tests for finding bugs you didn't think to look for.

Keep Your Specs - They're Not Disposable

Since specs are the source from which both code and tests are derived, they're living documentation - not scaffolding you discard once the feature ships. Reasons to commit them to git alongside the code:

Kiro stores specs in .kiro/specs/ and they're designed to be committed. They're the "why" that outlives any particular implementation.

When Kiro Does This Automatically

In spec mode, Kiro will often generate PBT without you asking - it's part of the verification step. You'll see it when:

For purely UI/template work, PBT is less relevant - it shines with logic, not layout.

Your Tangible Win

You now understand what property-based testing is, how it differs from example-based testing, why it catches bugs that unit tests miss, and how Kiro uses it as part of the spec-to-code verification loop. You know it uses fast-check, integrates with your existing test runner, and triggers automatically in spec mode for logic-heavy requirements.

Quiz

A property-based test differs from a unit test in that:

It tests more slowly but with better coverage
It defines a rule that must hold for any valid input, then generates hundreds of random inputs to verify
It replaces unit tests entirely

Kiro's PBT flow in spec mode starts from:

The generated code - Kiro infers properties from implementations
The spec requirements - Kiro translates them into testable properties
Existing unit tests - Kiro converts them to property tests

"Shrinking" in fast-check means:

Reducing the number of tests to run faster
Compressing test output for readability
When a test fails, automatically reducing the input to the smallest case that still fails