Testing No. 138

Property-Based Testing skill for Claude Code

Let the generator find the edge case you missed. Claude writes properties that state true facts about your domain, such as round-trips, invariants, and comparisons to a simple oracle, instead of restating the code. Generators build valid inputs directly rather than filtering, every shrunk counterexample becomes a pinned example test, and stateful components get model-based command sequences.

  • nameproperty-based-testing
  • size213 lines · ≈2,150 tokens
  • files2
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Writes property-based tests with fast-check (TypeScript/JavaScript) and Hypothesis (Python) that state invariants over generated inputs, build domain-shaped generators, read shrunk counterexamples, and replay failures by seed. Covers choosing properties (round-trip, oracle, invariant, idempotence, metamorphic), avoiding filter-heavy generators, and stateful/model-based testing. Use when example tests keep missing edge cases, when testing parsers, serializers, algorithms, or state machines, or when asked to add fast-check or Hypothesis tests.

Try asking

  • Add fast-check property tests for our CSV parser and serializer
  • Write Hypothesis tests for the payment splitting function
  • Model-test our LRU cache against a simple dictionary

Use it when

  • Example tests keep missing edge cases
  • Testing parsers, serializers, money math, or algorithms
  • A component has operations and state worth model-checking
  • You want generated inputs with automatic shrinking

Skip it when

How to check the result

A deliberately injected off-by-one makes a property fail with a small shrunk counterexample, discard rates stay low, and every past counterexample exists as a pinned example.

The common mistake it prevents

Writing a property that recomputes the expected result with the same logic as the implementation, so it passes no matter what the code does.

~/.claude/skills/property-based-testing/SKILL.md

--- name: property-based-testing description: Writes property-based tests with fast-check (TypeScript/JavaScript) and Hypothesis (Python) that state invariants over generated inputs,… license: MIT ---

A property test states something that must hold for every valid input and lets the library hunt for a counterexample. Done well, properties are true statements about the domain (not a re-implementation of the code), generators produce valid inputs without heavy filtering, and every failure is replayable from its seed.

Ground rules

  • Keep example-based tests for known cases and documentation. Add properties to find the cases nobody thought of.
  • A property that re-implements the function under test proves nothing. Prefer relationships: round-trips, invariants, comparisons to a simple oracle.
  • Build generators that produce valid inputs by construction. If more than ~10% of inputs are discarded by filter/assume/fc.pre, rewrite the generator.
  • When a property fails, read the shrunk counterexample first, then add it as a permanent example test before fixing.
  • Fix seeds only for replay. Normal runs use random seeds so coverage grows over time.

Workflow

- [ ] 1. Pick the function/module and write down candidate properties
- [ ] 2. Build generators for the input domain
- [ ] 3. Write properties; start with numRuns default (100)
- [ ] 4. Run; triage each counterexample (bug in code, property, or generator)
- [ ] 5. Pin counterexamples as example tests
- [ ] 6. Tune runs for CI and add stateful tests for stateful components

1. Choosing properties

PatternStatementGood for
Round-tripdecode(encode(x)) == xSerializers, parsers, codecs, URL builders
Oraclefast(x) == slowButObvious(x)Optimized algorithms, rewrites, caches
InvariantOutput always satisfies P (sorted, within bounds, sums preserved)Transformations, pricing, allocation
Idempotencef(f(x)) == f(x)Normalizers, formatters, upserts, migrations
MetamorphicChanging input in a known way changes output predictablySearch ranking, ML, geometry, filtering
Commutativity / order independenceResult independent of input orderAggregations, merges, CRDTs
No crashAny input yields a result or a typed error, never an exceptionParsers and public APIs fed untrusted data

More patterns with worked examples are in references/property-patterns.md; read it when the obvious properties above do not fit.

2–3. fast-check

import fc from 'fast-check';
import { describe, it, expect } from 'vitest';

const money = fc.integer({ min: 0, max: 10_000_000 });          // cents
const lineItem = fc.record({ sku: fc.stringMatching(/^[A-Z]{3}-\d{3}$/), qty: fc.integer({ min: 1, max: 99 }), price: money });
const cart = fc.array(lineItem, { minLength: 0, maxLength: 20 });

describe('splitPayment', () => {
  it('parts always sum to the total and differ by at most one cent', () => {
    fc.assert(
      fc.property(money, fc.integer({ min: 1, max: 12 }), (total, parts) => {
        const shares = splitPayment(total, parts);
        expect(shares.reduce((a, b) => a + b, 0)).toBe(total);
        expect(Math.max(...shares) - Math.min(...shares)).toBeLessThanOrEqual(1);
      }),
    );
  });

  it('serializes carts losslessly', () => {
    fc.assert(fc.property(cart, (c) => { expect(parseCart(serializeCart(c))).toEqual(c); }));
  });
});
  • Integration helper: @fast-check/vitest (and @fast-check/jest) provide test.prop([fc.integer()])('name', (n) => ...).
  • Derive generators with .map (transform) and .chain (dependent values: generate a length, then an index within it). Use fc.constantFrom, fc.oneof, fc.option, fc.uniqueArray, fc.date({ noInvalidDate: true }).
  • Failure output includes seed and path; replay exactly with fc.assert(prop, { seed: <seed>, path: '<path>', endOnFailure: true }).

2–3. Hypothesis

from hypothesis import given, example, settings, strategies as st

money = st.integers(min_value=0, max_value=10_000_000)

@given(total=money, parts=st.integers(min_value=1, max_value=12))
def test_split_payment_sums_to_total(total, parts):
    shares = split_payment(total, parts)
    assert sum(shares) == total
    assert max(shares) - min(shares) <= 1

@st.composite
def date_ranges(draw):
    start = draw(st.dates())
    length = draw(st.integers(min_value=0, max_value=400))
    return start, start + timedelta(days=length)

@given(date_ranges())
@example((date(2024, 2, 28), date(2024, 3, 1)))   # pinned leap-year counterexample
def test_nights_never_negative(rng):
    assert nights(*rng) >= 0
  • st.builds(Model, field=strategy) and st.from_type(SomeDataclass) generate typed objects; override only the fields that need constraints.
  • Settings profiles for CI vs. local:
# conftest.py
import os
from hypothesis import settings
settings.register_profile("ci", max_examples=1000, deadline=None)
settings.register_profile("dev", max_examples=100)
settings.load_profile(os.getenv("HYPOTHESIS_PROFILE", "dev"))
  • Hypothesis stores failing examples in .hypothesis/ and replays them first on the next run. Commit nothing from it; add @example(...) for permanent pins. A failure prints a @reproduce_failure decorator for exact replay.
  • pytest --hypothesis-show-statistics reports generation and discard rates.

4. Triage a counterexample

For each failure, decide which is wrong:

  1. The code: a real bug. Pin the counterexample as an example test, then fix.
  2. The property: it states something the domain does not promise (e.g. round-trip of floats through a lossy format). Narrow the property or document the exception.
  3. The generator: it produced inputs outside the valid domain (NaN where the API forbids it). Constrain the generator, not the code.

Shrinking gives the minimal failing input; do not “fix” the specific value, fix the class of inputs it represents.

6. Stateful (model-based) testing

For components with operations and state (caches, queues, carts, connection pools), generate sequences of commands and compare the real system against a simple model.

from hypothesis.stateful import RuleBasedStateMachine, rule, invariant
import hypothesis.strategies as st

class LRUCacheMachine(RuleBasedStateMachine):
    def __init__(self):
        super().__init__()
        self.real = LRUCache(capacity=3)
        self.model = {}            # plus an order list for recency
        self.order = []

    @rule(k=st.integers(0, 5), v=st.integers())
    def put(self, k, v):
        self.real.put(k, v)
        self.model[k] = v
        self.order = [x for x in self.order if x != k] + [k]
        if len(self.order) > 3:
            evicted = self.order.pop(0)
            del self.model[evicted]

    @rule(k=st.integers(0, 5))
    def get(self, k):
        assert self.real.get(k) == self.model.get(k)
        if k in self.model:
            self.order = [x for x in self.order if x != k] + [k]

    @invariant()
    def size_bounded(self):
        assert len(self.real) <= 3

TestLRUCache = LRUCacheMachine.TestCase

fast-check equivalent: define command classes with check(model), run(model, real), and toString(), then fc.assert(fc.property(fc.commands([...]), (cmds) => fc.modelRun(setup, cmds))).

Run budgets

ContextRuns per property
Local / PR100 (default)
Nightly or CI profile1,000–10,000
Expensive property (I/O, >10 ms per run)20–50 and move to a nightly job

Disable Hypothesis deadlines (deadline=None) for tests with variable runtime instead of letting them flake.

Gotchas

  • fc.float() and st.floats() include NaN, infinities, and -0.0 by default. Exclude them explicitly if the domain does, or test that they are rejected.
  • Generated strings include Unicode edge cases (combining marks, surrogates in JS); great for parsers, noisy for unrelated fields. Use constrained generators there.
  • Tests that touch a shared database across runs must reset state per example, or shrinking produces nonsense.
  • Properties with randomness inside the code under test are not reproducible; inject the RNG.
  • Large generated collections slow everything; bound maxLength/max_size.

Verification

  1. Introduce a known bug (off-by-one in the split, drop the last item in serialization) and confirm a property fails with a small shrunk counterexample.
  2. Check discard rate is low (--hypothesis-show-statistics, or fast-check verbose).
  3. Each historical counterexample exists as a pinned example test.

Bundled files

Loaded only when a step in the skill needs them. Review them here before installing.

references/property-patterns.md 73 lines · 3.5 KB
# Property patterns with worked examples

Read this when the obvious properties (round-trip, invariant, oracle) do not fit the
code under test.

## Round-trip variants

- **Encode/decode**: `parse(format(x)) == x`. If formatting is lossy (dates to day
  precision), compare after normalizing: `normalize(parse(format(x))) == normalize(x)`.
- **Inverse operations**: `remove(add(set, x), x) == set` when `x` was not in `set`.
- **Migrations**: `down(up(schema_v1_data)) == schema_v1_data` for reversible migrations.

## Oracles

- Compare an optimized implementation to a naive one: binary search vs. linear scan,
  interval tree vs. brute-force overlap check, cached service vs. uncached.
- Compare against a trusted library: your date diff vs. a well-tested date library.
- Compare old vs. new implementation during a rewrite (differential testing). Keep the
  old one in the test tree until the rewrite ships.

## Invariants by domain

| Domain | Invariants |
|---|---|
| Money and allocation | Parts sum to total; no negative shares; rounding difference ≤ 1 minor unit |
| Sorting / ranking | Output is a permutation of input; ordered by key; stable for equal keys |
| Pagination | Concatenating all pages equals the full result; no duplicates; page size ≤ limit |
| Scheduling / time ranges | End ≥ start; no overlaps after merge; total duration preserved |
| Permissions | Adding a role never removes access; removing all roles denies everything |
| Text normalization | Output is idempotent; length bounds; no forbidden characters remain |
| Inventory | Stock never negative; reserved + available == on hand |

## Metamorphic relations

Useful when the correct output is unknown but relationships between outputs are known.

- Search: adding a filter never increases result count.
- Search: duplicating a matching document never lowers its rank relative to others.
- Geometry: translating all points does not change distances.
- Pricing: adding an item never lowers the subtotal (unless a documented promotion).
- Image/ML: small perturbations below a threshold do not flip a classification.

## Model-based testing

Write the simplest possible model (dict, list, counter) and compare after every
generated operation. Good targets:

- Caches (LRU/TTL) vs. a dict plus recency list.
- Queues with priorities vs. a sorted list.
- A REST resource's CRUD behavior vs. an in-memory map (run against a test server).
- A rate limiter vs. a list of timestamps.

Include operations that should fail (get missing key, pop empty queue) so error
handling is tested too.

## Generators: making valid inputs by construction

| Need | Instead of filtering | Construct |
|---|---|---|
| Sorted list | `filter(isSorted)` | Generate a list, then `.map(sort)` |
| Start < end | `filter(s < e)` | Generate start and a positive delta |
| Index within array | `filter(i < len)` | `.chain(arr => fc.tuple(fc.constant(arr), fc.nat(arr.length - 1)))` with non-empty arrays |
| Valid email | regex filter | Compose local part + `@` + domain from constrained pieces, or `st.emails()` |
| Unique IDs | filter duplicates | `fc.uniqueArray` / `st.lists(..., unique=True)` |
| Balanced tree / JSON | filter | Recursive generators (`fc.letrec`, `st.recursive`) |

## Smells

- Property duplicates the implementation's formula.
- Assertions only check "does not throw".
- Generator produces mostly empty or trivial values (check statistics).
- Property passes with numRuns 10,000 the first day and nobody ever saw it fail with a
  deliberately injected bug. Inject one to prove it bites.

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc