Skip to content

Property-based testing for agent-written code

Property-based testing checks agent-written code against invariants taken from spec.md, such as “the instalments sum to the total”, on 100 or more generated inputs per run. Because the library picks the inputs, an agent cannot overfit the tests by choosing examples. fast-check, Hypothesis and proptest shrink each failure to a small input that becomes a permanent regression test.

An agent implements “split an invoice total into instalments” and ships four example tests. splitCents(1000, 3) returns [333, 333, 334], the tests expect exactly that, and CI is green. The spec said earlier instalments are never smaller than later ones, so that example already breaks the spec’s ordering rule, yet the test expects it, because the agent read the expected value off its own code. Worse, splitCents(2, 4) returns [1, 1, 1, -1]: a negative payment. This page is for the developer who approves agent pull requests on evidence and wants a test suite the agent cannot quietly fit to its own code.

What you get from property tests on agent code

Section titled “What you get from property tests on agent code”
  • A method for turning spec requirements into invariants, with a table of the eight property shapes that cover most business logic.
  • Working property tests in three stacks (fast-check with Vitest, Hypothesis with pytest, proptest with Cargo), with the real failure output each library prints.
  • A regression-pinning routine that turns every shrunk counterexample into a test that runs on every commit, forever.
  • Four copy-paste prompts: extract invariants from a spec, write property tests in a test-only task, triage a counterexample, and audit existing properties for tests that assert nothing.
  • CI settings for a fast per-pull-request run and a deep nightly sweep, including the Hypothesis CI profile trap.

Why do example tests fail as an oracle for agent code?

Section titled “Why do example tests fail as an oracle for agent code?”

An example test checks one input the author chose. When the agent writes both the implementation and the examples, it chooses inputs its implementation already handles, and it reads the expected values off its own code. The suite then describes the code rather than the requirement. This is the “grading its own homework” problem that oracle strength measures after the fact.

A property removes the choice. It states a rule that must hold for every valid input, and the library generates the inputs, including the awkward ones: zero, one, the maximum, totals smaller than the number of parts. When a rule fails, the library shrinks the input, repeatedly simplifying it while it still fails, and reports a small counterexample you can read.

Properties do not replace examples. Keep a few examples as documentation of typical behaviour; let properties carry the rules. Kiro builds the same idea into its spec workflow, using property-based tests to “check that the code matches the spec” (Kiro README on GitHub, read 2026-09-26); see Kiro compared.

Each property is a sentence in the spec rewritten as “for all valid inputs, …”. Start from requirements written as observable outcomes, in the format from spec-driven development:

### BILL-4: Split an invoice total into instalments
- The instalments sum exactly to the total, in minor units (cents).
- No two instalments differ by more than 1 cent.
- Earlier instalments are never smaller than later ones.
- The total is 0 to 10,000,000 cents and the count is 1 to 24;
anything else is rejected with a RangeError.

Four bullets give four properties. Most requirements fall into one of these shapes:

ShapeSpec sentence looks likePropertyExample
Conservation“adds up to”, “nothing is lost”An aggregate of the output equals one of the inputInstalments sum to the total; ledger debits equal credits
Bounds“at most”, “never negative”, “within”Every output value lies in a rangeSpread of at most 1 cent; discount never exceeds the price
Ordering“sorted by”, “earlier … never smaller”Adjacent outputs respect an orderInstalments non-increasing; search results sorted by score
Round trip“can be exported and re-imported”decode(encode(x)) equals xCSV export and import; URL building and parsing
Idempotence“normalised”, “applying twice changes nothing”f(f(x)) equals f(x)Address normalisation; migration re-run
Reference model“behaves like”, “same result as the old system”Output equals a simple, slow, obviously correct versionNew pricing engine against the legacy function
Metamorphic“adding a filter never adds results”A related input changes the output in a predictable wayNarrower date range returns a subset
Rejection“anything else is rejected”Invalid inputs raise the named errorTotals out of range throw RangeError

A requirement that fits no shape is often vague. “Instalments should be fair” has no property; “no two instalments differ by more than 1 cent” does. When extraction stalls, fix the spec, not the test.

How do you run property-based testing on agent-written code?

Section titled “How do you run property-based testing on agent-written code?”

The order matters: invariants are approved and property tests exist before the implementation task starts, and the implementing agent cannot edit them. Otherwise the agent fits the properties to the code as easily as it fits examples.

  1. Extract the invariants. Give an agent the requirement and ask for invariants only, no code (first prompt below). You approve the list. This is the one place you read carefully, and it is a few lines of English, not a diff.

  2. Write the property tests in a test-only task. A separate session writes one property per approved invariant, tagged with the requirement ID ([BILL-4]) so the traceability check finds it. Generators follow the spec’s input ranges exactly.

  3. Record a negative control for each property. Break the behaviour on purpose in a scratch branch and confirm the property goes red. A property that stays green against a wrong implementation asserts nothing. The routine is on oracle strength.

  4. Lock the property files. Add them to the agent’s deny rules and to CODEOWNERS, as described in protecting the oracle. From now on they are pre-existing checks.

  5. Run the implementation loop against the properties. The implementing agent iterates until the suite is green. It may add example tests; it may not touch the properties.

  6. Triage every shrunk counterexample. Each failure is one of three things: a wrong property, an ambiguous spec, or a real bug. Only the third is fixed in code; the first two go back to steps 1 and 2 with a human decision.

  7. Pin the counterexample, then fix. Add the exact failing input as a permanent regression case in the same pull request as the fix, so the evidence shows it red before and green after.

Write the properties in fast-check, Hypothesis or proptest

Section titled “Write the properties in fast-check, Hypothesis or proptest”

The three libraries share the model: generators (fast-check calls them arbitraries, Hypothesis strategies, proptest strategies), a property body, a run count, and shrinking. Versions below were checked on 2026-09-26: fast-check 4.10.2 with @fast-check/vitest 0.5.0 and Vitest 5.0.2, Hypothesis 6.168.2 with pytest 9.1.1, and proptest 1.11.0. Every failure shown was produced by running these tests against the agent’s first implementation, which rounds total / parts and gives the remainder to the last instalment.

Install with npm i -D fast-check @fast-check/vitest. test.prop runs the body 100 times by default (numRuns).

tests/billing/split.property.test.ts
import { describe, expect } from 'vitest';
import { fc, test } from '@fast-check/vitest';
import { splitCents } from '../../src/billing/split';
const total = fc.integer({ min: 0, max: 10_000_000 });
const parts = fc.integer({ min: 1, max: 24 });
describe('BILL-4 splitCents', () => {
test.prop([total, parts])('[BILL-4] instalments sum exactly to the total', (t, n) => {
expect(splitCents(t, n).reduce((a, b) => a + b, 0)).toBe(t);
});
test.prop([total, parts])('[BILL-4] no two instalments differ by more than 1 cent', (t, n) => {
const out = splitCents(t, n);
expect(Math.max(...out) - Math.min(...out)).toBeLessThanOrEqual(1);
});
test.prop([total, parts])('[BILL-4] earlier instalments are never smaller', (t, n) => {
const out = splitCents(t, n);
for (let i = 1; i < out.length; i++) expect(out[i - 1]).toBeGreaterThanOrEqual(out[i]);
});
test.prop([fc.oneof(fc.integer({ max: -1 }), fc.integer({ min: 10_000_001 })), parts])(
'[BILL-4] rejects totals out of range',
(t, n) => {
expect(() => splitCents(t, n)).toThrow(RangeError);
},
);
});

The run against the first implementation:

× [BILL-4] no two instalments differ by more than 1 cent (with seed=191597017)
Error: Property failed after 1 tests
{ seed: 191597017, path: "0:1:0:0:0:2:1:1", endOnFailure: true }
Counterexample: [2,4]
Shrunk 7 time(s)
Caused by: AssertionError: expected 2 to be less than or equal to 1

To replay exactly that failure, pass the printed object as the second argument: test.prop([total, parts], { seed: 191597017, path: '0:1:0:0:0:2:1:1', endOnFailure: true }).

All three libraries caught a bug that four passing example tests hid. In the TypeScript run, a second property (ordering) also failed, with a counterexample of [791421, 14] after 24 shrinks. One root cause often trips several properties; triage the smallest counterexample first.

Turn every shrunk failure into a permanent regression test

Section titled “Turn every shrunk failure into a permanent regression test”

A random run finds a bug once. Unless you pin the input, the next run may not generate it again, and a later agent change can reintroduce the bug unnoticed. Pin every real-bug counterexample in the same pull request as the fix, and write the date and the observed wrong output in a comment.

LibraryPin the input withWhere it livesTrap
fast-checkexamples: [[2, 4]] in the test.prop options; examples run before the generated inputsThe property test fileSeed and path replay a run only while the arbitraries are unchanged; examples survive any refactor
Hypothesis@example(total=2, n=4) above @givenThe property test fileThe .hypothesis database is local by default, and in CI Hypothesis disables it (see below), so only @example travels reliably
proptestCommit proptest-regressions/*.txt; proptest replays those cases firstIn proptest-regressions/ at the crate root, mirroring the source path (for example proptest-regressions/billing.txt)The file stores seeds, as its own header says, so a changed strategy can replay a different input. Add a plain #[test] with the literal values as well

A pinned case must assert the rule it broke: [791421, 14] failed the ordering property, so a pin that checks only sign and spread would pass on the buggy code. The fast-check and Hypothesis forms of the pinned case:

// Shrunk counterexamples from failed runs, 2026-09-26. Never delete.
// [2, 4] returned [1, 1, 1, -1]; [791421, 14] returned a larger last instalment.
test.prop([total, parts], { examples: [[2, 4], [791421, 14]] })(
'[BILL-4] pinned counterexamples stay fixed',
(t, n) => {
const out = splitCents(t, n);
expect(out.every((c) => c >= 0)).toBe(true);
expect(Math.max(...out) - Math.min(...out)).toBeLessThanOrEqual(1);
// [791421, 14] broke the ordering rule, so the pin must assert ordering too.
for (let i = 1; i < out.length; i++) expect(out[i - 1]).toBeGreaterThanOrEqual(out[i]);
},
);
from hypothesis import example, given
@given(totals, parts)
@example(total=2, n=4) # 2026-09-26: returned [0, 0, 0, 2]
def test_bill_4_no_negative_or_lopsided_instalments(total, n):
out = split_cents(total, n)
assert min(out) >= 0 and max(out) - min(out) <= 1

Treat pinned cases as oracle files: they sit under the same deny rules and CODEOWNERS entry as the properties, and a pull request that removes one needs a human reason.

The defaults (100 for fast-check and Hypothesis, 256 for proptest) keep a pull-request run to seconds, and that is the right gate for every change. Add a nightly sweep with far more runs and a fresh seed each night, so the search keeps covering new inputs without slowing pull requests down.

Hypothesis needs one extra step. When it detects a CI environment (CI, GITHUB_ACTIONS and similar variables), it loads its built-in ci profile: derandomize=True, database=None, deadline=None and print_blob=True (checked in Hypothesis 6.168.2 source). Derandomized runs generate the same inputs every time, so a nightly job that only raises the example count searches the same corner every night. Register a nightly profile that turns randomisation back on:

conftest.py
from hypothesis import settings
settings.register_profile(
"nightly",
settings.get_profile("ci"),
max_examples=5_000,
derandomize=False,
)

For fast-check, set the count in a Vitest setup file (listed in test.setupFiles in vitest.config.ts) with fc.configureGlobal({ numRuns: Number(process.env.FC_NUM_RUNS ?? 100) }); FC_NUM_RUNS is a name this setup file defines, not a fast-check variable. proptest reads PROPTEST_CASES directly.

.github/workflows/property-sweep.yml
name: property-sweep
on:
schedule:
- cron: '17 3 * * *'
workflow_dispatch:
permissions:
contents: read
jobs:
sweep:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
with:
node-version-file: .node-version
- run: npm ci
- name: Deep fast-check run
run: FC_NUM_RUNS=10000 npx vitest run property
# Python: pytest --hypothesis-profile=nightly tests/
# Rust: PROPTEST_CASES=20000 cargo test

A red nightly run prints the seed (fast-check), the @reproduce_failure blob (Hypothesis, because the ci profile sets print_blob=True) or a new regressions line (proptest). The sweep reports; a developer or a test-only agent task pins the case and opens the fix.

Copy-paste prompts for property-based testing

Section titled “Copy-paste prompts for property-based testing”

How do you run this in Claude Code, Codex and Cursor?

Section titled “How do you run this in Claude Code, Codex and Cursor?”

The libraries, tests and prompts are the same in all three tools. What differs is how you install the property-testing skill, how you keep the implementing agent out of the property files, and how you run the extraction headless. Commands were checked against Claude Code 2.1.283, Codex CLI 0.157.1 and the skills CLI 1.7.0 on 2026-09-26.

Trail of Bits publishes a property-based-testing plugin (1.2.2) whose skill writes and reviews property tests for Hypothesis, fast-check, proptest and more, and triages a shrunk counterexample into “a wrong property, an ambiguous spec, or a real bug” (plugin README, read 2026-09-26). That is the same triage as step 6 above. It is one skill, so its context cost is its description until the agent invokes it.

Install the skill.

Terminal window
claude plugin marketplace add trailofbits/skills
claude plugin install property-based-testing@trailofbits

Extract invariants headless, with read-only tools, and save the result for your approval:

Terminal window
# Terminal or CI, from the repository root
claude -p "$(cat prompts/extract-invariants.md)" \
--allowedTools "Read,Grep,Glob" \
--max-budget-usd 2 --output-format json > bill-4-invariants.json

Lock the properties during implementation. Add deny rules to .claude/settings.json; they apply in every permission mode:

{
"permissions": {
"deny": [
"Edit(**/*.property.test.ts)",
"Edit(**/test_*_properties.py)",
"Edit(**/proptest-regressions/**)"
]
}
}

Deny rules do not stop a script that writes files itself; add the sandbox denyWrite paths from protecting the oracle for that.

How do you know the properties are strong enough?

Section titled “How do you know the properties are strong enough?”

Nobody reads every generated input, so the evidence has to be mechanical:

  • Every invariant maps to a property with a requirement ID, and the traceability check fails the build when a requirement has no test.
  • Every property has a negative control: a deliberate violation that turned it red, recorded in the pull request.
  • Mutation score on the module clears the team’s threshold. A property suite with many surviving mutants is asserting less than it appears to; see oracle strength.
  • Pinned counterexamples are present and untouched, verified by the provenance check on oracle files.
  • The nightly sweep is green, or its failures are triaged within a working day.

The developer who owns the change approves the invariant list and the triage of each counterexample. The tech lead owns the oracle paths and the nightly sweep. Record all five items in the evidence bundle so a reviewer reads evidence instead of the diff.

What breaks when you use property-based tests with agents?

Section titled “What breaks when you use property-based tests with agents?”

The property reimplements the code. The agent writes expect(splitCents(t, n)).toEqual(mySplit(t, n)), where mySplit is the same algorithm. Both share the bug and the test always passes. Recovery: assert rules, not outputs. A reference model is legitimate only when it is simpler and independently correct, such as a legacy function or a brute-force version.

The generator is narrower than the spec. fc.integer({ min: 1, max: 100 }) for a total that may be zero or ten million never tests the edges. Recovery: copy the ranges from the spec into the generator, and include the endpoints in examples or @example.

Filters throw away most inputs. .filter(t => t % n === 0) keeps only easy totals, and the library gives up or tests almost nothing. Recovery: build valid inputs directly (generate n and a quotient, multiply) instead of filtering.

The agent weakens a property to go green. It loosens <= 1 to <= 2, narrows a range, or deletes a pinned example. Recovery: the property files are oracle files under deny rules and CODEOWNERS; a same-change edit to them fails the provenance check.

Hypothesis fails on deadlines, not logic. The default 200 ms deadline turns a slow first call into DeadlineExceeded locally, while CI passes because the ci profile sets deadline=None. Recovery: fix genuine slowness, or set an explicit deadline for that test with @settings.

The nightly sweep keeps finding nothing. In Hypothesis this is usually the derandomized ci profile repeating the same inputs. Recovery: load a profile with derandomize=False, as shown above.

A replayed failure does not reproduce. A fast-check seed and path, or a proptest seed, stop matching once someone changes the generator. Recovery: pin the literal input values, not only the seed.

Where to go next with property-based testing

Section titled “Where to go next with property-based testing”

Frequently asked questions

Why use property-based tests on agent-written code?

An agent that writes both the code and the example tests picks examples its code already passes. A property states a rule from the spec for every valid input, and the library generates the inputs, so the agent cannot choose the cases it is judged on.

Where do the properties come from?

From the requirements in spec.md: sums that must balance, bounds, ordering, round trips, idempotence and inputs that must be rejected. A human approves the list of invariants before any property test or implementation is written.

What do you do with a shrunk counterexample?

Triage it as a wrong property, an ambiguous spec or a real bug. For a real bug, pin the exact input as a permanent regression case (fast-check examples, Hypothesis @example, or a committed proptest-regressions file plus a plain unit test) before you fix the code.