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.
- name
property-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
- You want to know how many bugs your existing tests would catch: use mutation-testing
- You need realistic seeded records for tests: use test-data-factories
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.
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
| Pattern | Statement | Good for |
|---|---|---|
| Round-trip | decode(encode(x)) == x | Serializers, parsers, codecs, URL builders |
| Oracle | fast(x) == slowButObvious(x) | Optimized algorithms, rewrites, caches |
| Invariant | Output always satisfies P (sorted, within bounds, sums preserved) | Transformations, pricing, allocation |
| Idempotence | f(f(x)) == f(x) | Normalizers, formatters, upserts, migrations |
| Metamorphic | Changing input in a known way changes output predictably | Search ranking, ML, geometry, filtering |
| Commutativity / order independence | Result independent of input order | Aggregations, merges, CRDTs |
| No crash | Any input yields a result or a typed error, never an exception | Parsers 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) providetest.prop([fc.integer()])('name', (n) => ...). - Derive generators with
.map(transform) and.chain(dependent values: generate a length, then an index within it). Usefc.constantFrom,fc.oneof,fc.option,fc.uniqueArray,fc.date({ noInvalidDate: true }). - Failure output includes
seedandpath; replay exactly withfc.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)andst.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_failuredecorator for exact replay. pytest --hypothesis-show-statisticsreports generation and discard rates.
4. Triage a counterexample
For each failure, decide which is wrong:
- The code: a real bug. Pin the counterexample as an example test, then fix.
- 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.
- 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
| Context | Runs per property |
|---|---|
| Local / PR | 100 (default) |
| Nightly or CI profile | 1,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()andst.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
- 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.
- Check discard rate is low (
--hypothesis-show-statistics, or fast-checkverbose). - 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
# 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.