Property-Based Testing skill for Codex
State the rule, let the library hunt the counterexample. Codex turns contracts into properties: round-trips for codecs, invariants for calculations, oracles for rewrites and model-based state machines for stateful components. It builds generators that match the domain, proves each property can fail, replays counterexamples by seed and pins every one it finds as a permanent example test.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- property-based-testing Installs to ~/.agents/skills/property-based-testing/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install property-based-testing The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project property-based-testing Installs to .agents/skills/property-based-testing/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/property-based-testing.zipunzip -o property-based-testing.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) property-based-testing PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $property-based-testing, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Writes property-based tests with fast-check (TypeScript/JavaScript) and Hypothesis (Python) - finding properties such as round-trips, invariants, oracles and metamorphic relations, building generators and composite strategies, stateful model-based tests, shrinking to minimal counterexamples, and replaying failures by seed. Use when testing parsers, serializers, encoders, sorting or pricing logic, data structures and state machines, or when example-based tests keep missing edge cases.
Use it when
- Testing parsers, serializers or encoders
- Business rules have invariants that examples only sample
- Replacing an implementation and needing to match the old one
- Stateful components where call order matters
Use something else when
- You need long coverage-guided campaigns against untrusted input: use fuzz-testing
- Plain example cases already cover a small, closed input space: use unit-testing-patterns
How to check the result
Each property finds and shrinks a counterexample when the implementation is deliberately broken, and every counterexample found is pinned as an explicit example test.
The mistake it prevents
Writing a property that recomputes the expected value with the same algorithm as the code under test, so it can never disagree.
A property test states a rule that must hold for every valid input, lets the library search for a counterexample, and when one is found reports the smallest input that breaks the rule, reproducible by seed. Done well, each property encodes a real requirement and generators produce the inputs production actually sees.
Inspect first
- Read
AGENTS.mdand check whetherfast-check(and@fast-check/vitestor@fast-check/jest) orhypothesisare already dependencies. Adding them needs network access to install. - Find the unit’s contract: types, validation rules, documented invariants. Properties come from the contract, not from the implementation.
- Look for existing example-based tests; keep them. Properties complement examples.
Finding properties
| Pattern | Property | Typical target |
|---|---|---|
| Round-trip | decode(encode(x)) == x | serializers, parsers + printers, compression, URL encoding |
| Invariant | output always satisfies a rule | sorted output is ordered and a permutation of input; totals never negative |
| Oracle / differential | fast(x) == reference(x) | optimized vs naive implementation, new vs legacy code path |
| Idempotence | f(f(x)) == f(x) | normalization, formatting, dedupe, upserts |
| Metamorphic | changing input in a known way changes output predictably | adding an item never lowers the cart total; filtering more never returns more |
| Commutativity / associativity | order of operations does not matter | merging CRDTs, set operations, money arithmetic |
| Never crashes | arbitrary input yields a value or a typed error | parsers, request validators |
Avoid properties that re-implement the function under test. If the only way to state the expected output is the same algorithm, use an oracle that is simpler (brute force) or a different pattern.
fast-check
import fc from "fast-check";
import { describe, it, expect } from "vitest";
import { encodeQuery, decodeQuery } from "./query";
describe("query string codec", () => {
it("round-trips any flat string map", () => {
fc.assert(
fc.property(fc.dictionary(fc.string(), fc.string()), (params) => {
expect(decodeQuery(encodeQuery(params))).toEqual(params);
}),
);
});
it("never makes the total smaller when an item is added", () => {
const item = fc.record({
priceCents: fc.integer({ min: 0, max: 1_000_000 }),
qty: fc.integer({ min: 1, max: 99 }),
});
fc.assert(
fc.property(fc.array(item, { maxLength: 30 }), item, (cart, extra) => {
expect(total([...cart, extra])).toBeGreaterThanOrEqual(total(cart));
}),
{ numRuns: 500 },
);
});
});
- Async code:
fc.asyncPropertyandawait fc.assert(...). @fast-check/vitest(or@fast-check/jest) offerstest.prop([fc.integer()])("...", (n) => ...)for tighter integration.- Useful arbitraries:
fc.integer,fc.double({ noNaN: true }),fc.string,fc.uniqueArray,fc.record,fc.oneof,fc.constantFrom,fc.option,fc.date,fc.emailAddress,fc.uuid,fc.letrecfor recursive structures. - Restrict with
.filter()sparingly; heavy filtering wastes runs. Build valid values directly with.map()orfc.recordinstead. - On failure fast-check prints
seedandpath; replay exactly withfc.assert(prop, { seed: <seed>, path: "<path>", endOnFailure: true }). Then copy the shrunk counterexample into a plain example test so it runs forever.
Hypothesis
from hypothesis import given, example, settings, strategies as st
money = st.integers(min_value=0, max_value=10_000_000)
line = st.builds(LineItem, price_cents=money, qty=st.integers(min_value=1, max_value=99))
@given(st.lists(line, max_size=30), line)
def test_adding_an_item_never_lowers_total(cart, extra):
assert total(cart + [extra]) >= total(cart)
@given(st.dictionaries(st.text(), st.text()))
@example({"": ""}) # pinned edge case, always runs
def test_query_round_trip(params):
assert decode_query(encode_query(params)) == params
Composite strategies for dependent values:
from datetime import date, timedelta
@st.composite
def date_ranges(draw):
start = draw(st.dates(min_value=date(2000, 1, 1), max_value=date(2100, 1, 1)))
days = draw(st.integers(min_value=0, max_value=365))
return start, start + timedelta(days=days)
@given(date_ranges())
def test_nights_is_never_negative(rng):
assert nights(*rng) >= 0
st.from_type(MyDataclass)andst.builds(...)infer from type hints.assume(cond)discards an input; if many inputs are discarded, Hypothesis raises a health check error. Fix the strategy instead.- Hypothesis stores failing examples in
.hypothesis/and replays them first on the next run. Add.hypothesis/to.gitignore; pin important ones with@example. - Profiles for speed vs depth:
# conftest.py
import os
from hypothesis import settings
settings.register_profile("ci", max_examples=500, deadline=None)
settings.register_profile("dev", max_examples=50)
settings.load_profile(os.getenv("HYPOTHESIS_PROFILE", "dev"))
deadline=None avoids false failures on slow CI machines; keep the default deadline
locally to catch accidental slowness.
Stateful (model-based) testing
Compare the real system against a simple model across random command sequences.
from hypothesis.stateful import RuleBasedStateMachine, rule, invariant
import hypothesis.strategies as st
class CartMachine(RuleBasedStateMachine):
def __init__(self):
super().__init__()
self.cart = Cart()
self.model: dict[str, int] = {}
@rule(sku=st.sampled_from(["A", "B", "C"]), qty=st.integers(1, 5))
def add(self, sku, qty):
self.cart.add(sku, qty)
self.model[sku] = self.model.get(sku, 0) + qty
@rule(sku=st.sampled_from(["A", "B", "C"]))
def remove(self, sku):
self.cart.remove(sku)
self.model.pop(sku, None)
@invariant()
def matches_model(self):
assert self.cart.quantities() == self.model
TestCart = CartMachine.TestCase
fast-check has the same idea with fc.commands([...]) and fc.modelRun(setup, cmds).
Use stateful tests for caches, queues, undo stacks, connection pools and anything with
an API where call order matters.
Generators that match reality
- Bound sizes and ranges to the domain (quantities 1-99, prices in cents up to a cap). Unbounded integers find overflow bugs that cannot happen, which wastes time.
- Do include the nasty edges the domain allows: empty strings, unicode, zero, maximum values, duplicate keys, leap days, DST transitions.
- Floats: decide whether NaN and infinities are valid inputs; exclude them explicitly if not, and compare with tolerances.
- Share generators in one module (
tests/strategies.py,test/arbitraries.ts) so domain constraints stay consistent across properties.
Gotchas
- A property that passes on the first run may be vacuous. Break the implementation on purpose and confirm the property finds it and shrinks to a small input.
- Shrinking needs generators built from library combinators; values produced through
external randomness (
Math.random()inside the test) cannot shrink or replay. - Do not catch broad exceptions inside the property; a swallowed error passes.
- Tests that hit a database or network per example are slow; lower
numRuns/max_examplesor test the pure core with properties and the edges with examples. - Properties do not replace coverage-guided fuzzing for parsers of untrusted input; for
that, hand off to
$fuzz-testing.
Verification
- Each property fails against a deliberately broken implementation and reports a shrunk counterexample.
- Every counterexample found during the work is pinned as an example test.
- Runtime per property stays under a few seconds in the default profile.
- CI runs with the deeper profile (more examples) at least nightly.
What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Property-Based Testing"
short_description: "State the rule, let the library hunt the counterexample"
default_prompt: "$property-based-testing Add property-based tests for our CSV parser and serializer round-trip" About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.