Property-based testing
StableSince v0.1.0Requires fast-check
test.prop runs a property test. You describe the inputs with
fast-check arbitraries, state a property that must
hold for them, and the runner generates many samples. If a sample fails,
fast-check shrinks it to a minimal counterexample and reports the seed to
replay it. Fixtures work exactly as they do in ordinary tests.
Installation
test.prop ships in @archont561/bun-test-utils; fast-check is an optional
peer (>=4.0.0), so install it in projects that use property tests:
Runnable — complete sample — the docs check runs it against the built package
bun add -d @archont561/bun-test-utils fast-checkProjects without property tests do not need fast-check at runtime, and
test.prop fails with MISSING_OPTIONAL_DEPENDENCY naming the install command
when the peer is absent.
Write a property test
Partial — one step of a longer file or workflow — surrounding lines are omitted
import { expect, test } from "@archont561/bun-test-utils";
test.prop( "addition is commutative", (fc) => ({ a: fc.integer(), b: fc.integer() }), async (_fixtures, { a, b }) => { expect(a + b).toBe(b + a); }, { numRuns: 100 },);- The arbitrary factory receives the
fast-checkAPI and returns a record of arbitraries, one per generated value. - The test body receives the fixtures first, then the generated values.
- Other options such as
numRunsandseedpass straight through tofc.assert.
Use fixtures in a property
Destructure fixtures from the first parameter, exactly as in an ordinary test; the engine detects them:
Partial — one step of a longer file or workflow — surrounding lines are omitted
test.prop( "orders survive a round trip through the store", (fc) => ({ orders: fc.array(fc.record({ id: fc.uuid(), total: fc.nat() })) }), async ({ tmpdir }, { orders }) => { tmpdir.write("orders.json", JSON.stringify(orders)); const saved = JSON.parse(tmpdir.read("orders.json")); expect(saved).toEqual(orders); }, { numRuns: 50 },);tmpdir is test-scoped, so each sample gets its own directory, and that
directory is removed before the next sample starts.
The fixture lifecycle per sample
A property test is one bun test case whose body runs many times. Scope
decides what is shared between those runs:
| Scope | Across samples and shrink steps |
|---|---|
session and file | Shared. Built once and reused for every sample. |
test (default) | Rebuilt for each sample. A fresh instance, torn down before the next sample starts. |
Each sample — and each candidate fast-check tries while shrinking a failure — runs through the engine’s iteration protocol: build the test-scoped fixtures, run the body, tear them down in LIFO order, even when the body throws. State left over from one sample cannot leak into the next.
Put expensive, read-only resources in session or file scope. Put anything a
sample writes to in test scope. See
Scopes and teardown.
Share arbitrary definitions
An arbitrary definition is an ordinary record or factory. Export it from a shared module and import it wherever a property needs it:
Partial — one step of a longer file or workflow — surrounding lines are omitted
import { defineArbitraries } from "@archont561/bun-test-utils/pbt";
export const userArbitraries = defineArbitraries((fc) => ({ name: fc.string(), age: fc.nat(),}));
export const adminArbitraries = defineArbitraries((fc) => ({ ...userArbitraries(fc), permissions: fc.array(fc.constantFrom("read", "write")),}));defineArbitraries is an identity function. It types the fc parameter
contextually, so you do not need to import the fast-check type into every
definition module, and generated values are inferred at each call site:
Partial — one step of a longer file or workflow — surrounding lines are omitted
import { expect, test } from "@archont561/bun-test-utils";import { adminArbitraries } from "./arbitraries";
test.prop( "admins have a valid name and permissions", adminArbitraries, async (_fixtures, { name, age, permissions }) => { const displayName: string = name; const years: number = age; const grants: ("read" | "write")[] = permissions;
expect(displayName.length).toBeGreaterThanOrEqual(0); expect(years).toBeGreaterThanOrEqual(0); expect(grants.every((grant) => grant === "read" || grant === "write")).toBe(true); },);test.scenario.prop accepts the same kind of arbitrary record, so generated
values are available in the first scenario step. See
BDD scenarios.
Choose fixtures explicitly
Auto-detection reads the destructured first parameter. If a property needs a
fixture only for its side effect, such as a server that must be listening, list
it in options.fixtures instead:
Partial — one step of a longer file or workflow — surrounding lines are omitted
test.prop( "the API accepts any JSON payload", (fc) => ({ payload: fc.json() }), async (_fixtures, { payload }) => { expect(JSON.parse(payload)).toBeDefined(); }, { fixtures: ["testServer"], numRuns: 200 },);Shrinking and replay
When a sample fails, fast-check shrinks the input to the smallest failing case
and reports the counterexample with the seed. Pass that seed back as
{ seed: ... } to replay the same sequence of samples.
When not to use it
Property tests are the wrong tool when:
- You already know the cases. Write a table-driven test or a loop over
named values instead — Parameterized tests
covers the fixed-matrix form. A generated test with
numRunssamples may never draw the one value you care about. - The property is the same assertion restated.
expect(sorted(x)).toEqual(sorted(x))proves nothing; a property has to be checkable independently of the code under test. - Each run is expensive. Samples multiply cost. A property over a real database or a browser is usually better expressed as a handful of explicit examples.
- The failure is one specific value. A generator that has to be constrained so tightly that it produces one value is a parameterized test in disguise.
Requirements and limits
fast-checkmust be installed in any project that runs a property test.- With
skipLibCheck: false, TypeScript also needsfast-checkinstalled to check the published declarations. See the package README.
Related
- Parameterized tests: the same mechanism over a fixed set of values.
- BDD scenarios:
test.scenario.propfor generated scenario context. - Snapshots and property-testing API:
test.propoptions and the/pbthelper.