3rd‐party property based testing - uhop/tape-six GitHub Wiki
3rd-party property-based testing
Property-based testing generates many random inputs and checks an invariant holds for all of them. tape-six doesn't ship a generator — bring fast-check (or another lib that throws on counterexample) and assert via t.*.
fast-check
Install: npm install --save-dev fast-check. Throws plain Error (not AssertionError) on counterexample, with a message containing the failing seed and shrunk inputs.
Passing property
import * as fc from 'fast-check';
import test from 'tape-six';
test('string concat is associative', t => {
fc.assert(
fc.property(fc.string(), fc.string(), fc.string(), (a, b, c) => {
return a + (b + c) === a + b + c;
})
);
t.pass('property held over 100 random inputs');
});
fc.assert runs the property a configurable number of times (default 100) and returns silently on success. Add a t.pass() so the test contributes a counted assertion.
Failing property — caught with t.throws
For negative-case tests where you expect the property to fail (e.g. demonstrating a counterexample exists), wrap with t.throws:
test('addition is not subtraction', t => {
t.throws(
() => fc.assert(fc.property(fc.integer(), fc.integer(), (a, b) => a + b === a - b)),
err => err && /Property failed/.test(String(err.message)),
'fast-check found a counterexample'
);
});
Failure rendering
A real test failure (the property is supposed to hold but doesn't) surfaces as UNEXPECTED EXCEPTION with fast-check's diagnostic message rendered as-is:
not ok N UNEXPECTED EXCEPTION: Error: Property failed after 4 tests
{ seed: 2045860912, path: "3:0", endOnFailure: true }
Counterexample: [...]
Shrunk: ...
The seed and path let you reproduce the exact failure with fc.assert(prop, {seed: ..., path: ...}). fast-check's report is deliberately passed through unparsed — it is well-designed as-is.
fast-check also sets error.cause to the root failure from inside your predicate, and tape-six unchains cause (and AggregateError members) generically for every reported error — so the failing line of your code surfaces without any fast-check-specific handling:
causes:
- "Error: boom at length 3"
causeStack:
- "at file:///.../my-test.mjs:6:30"
Tuning runs and seeds
fc.assert(prop, {numRuns: 1000, seed: 42}); // deterministic, more iterations
Useful when a flaky property needs investigation, or when you want to lock down a known-good seed in CI.
Race conditions with fc.scheduler
fc.scheduler() is an arbitrary that takes control of promise resolution order and explores interleavings — first-class race-condition testing with the same shrink-and-replay mechanics as plain properties. Wrap the async operations under test with s.scheduleFunction(...) (or s.schedule(promise)), then await s.waitAll():
import * as fc from 'fast-check';
import test from 'tape-six';
test('fc.scheduler finds the lost-update interleaving', async t => {
await t.rejects(
fc.assert(
fc.asyncProperty(fc.scheduler(), async s => {
const counter = {value: 0};
const increment = s.scheduleFunction(async function increment() {
const read = counter.value; // read...
await Promise.resolve(); // ...yield to other tasks...
counter.value = read + 1; // ...write
});
const runs = Promise.all([increment(), increment()]);
await s.waitAll();
await runs;
return counter.value === 2;
})
),
/Property failed/,
'the racy counter loses an update under some ordering'
);
});
Here the property should fail — the read-modify-write is not atomic, and the scheduler finds the ordering where both calls read 0 — so the demo asserts the rejection with t.rejects. In a real suite the property guards code that is supposed to be interleaving-safe, and a failure report carries the schedule that broke it plus the seed/path to replay it deterministically (fc.schedulerFor replays a specific ordering).
Model-based testing
fast-check also tests stateful APIs against a simplified model: commands mutate the real system and the model together and check they agree; on failure the command sequence shrinks to a minimal reproduction:
class Counter {
#value = 0;
increment() {
++this.#value;
}
reset() {
this.#value = 0;
}
get value() {
return this.#value;
}
}
const incrementCmd = {
check: () => true,
run(model, real) {
real.increment();
++model.count;
if (real.value !== model.count) throw new Error(`drift: ${real.value} != ${model.count}`);
},
toString: () => 'increment'
};
const resetCmd = {
check: () => true,
run(model, real) {
real.reset();
model.count = 0;
if (real.value !== model.count) throw new Error(`drift: ${real.value} != ${model.count}`);
},
toString: () => 'reset'
};
test('Counter matches its model under command sequences', t => {
fc.assert(
fc.property(fc.commands([fc.constant(incrementCmd), fc.constant(resetCmd)]), cmds => {
fc.modelRun(() => ({model: {count: 0}, real: new Counter()}), cmds);
})
);
t.pass('all command sequences agree with the model');
});
check(model) gates when a command is applicable (always true here); run(model, real) applies it to both and asserts agreement. Use fc.asyncModelRun for async systems. Real command arbitraries usually carry generated arguments — e.g. fc.nat().map(n => new AddCommand(n)) — rather than fc.constant.
Browser tests
fast-check ships an ESM build. Add it to your importmap:
{
"tape6": {
"importmap": {
"imports": {
"fast-check": "/node_modules/fast-check/lib/esm/fast-check.js"
}
}
}
}
Verify the path against node_modules/fast-check/package.json#exports for the version you installed.
See also
- Assertion libraries — chai, node:assert, expect.
- Mock libraries — node:test mock, sinon.