fast-check
fast-check is the property-based testing library for JavaScript and TypeScript. Its model-based testing runs commands with preconditions against a model and the real system, and it samples orders at random instead of trying every one.
Using fast-check
Section titled “Using fast-check”The code below is in formal-methods-samples/fast-check-webhook.
The problem
Section titled “The problem”unpaid and both charge the card.A service receives an order.confirmed webhook and charges the card. The sender retries when it does not hear back
in time, so the same order can arrive twice. The handler checks the order first:
export async function handleOrderConfirmed(orderId: string, deps: Deps): Promise<void> { const status = await deps.getStatus(orderId); if (status === 'unpaid') { await deps.chargeCard(orderId); await deps.setStatus(orderId, 'paid'); }}A test that runs the retry after the first delivery passes. In production two workers take the two deliveries at the
same time, and each await is a point where the other worker can run.
Install and set up
Section titled “Install and set up”pnpm add -D fast-checkWorks with any test runner; the example uses Vitest.
Writing the check with the scheduler
Section titled “Writing the check with the scheduler”s.scheduleFunction wraps each async dependency; s.waitIdle() releases the calls in an order fast-check picks.fc.scheduler() is an arbitrary that produces a scheduler s. s.scheduleFunction(fn) wraps an async function:
each call is queued, and the scheduler decides when it resolves. The test builds a small in-memory database, wraps
each dependency, and starts two deliveries at once against the real handler:
// webhook.fast-check.test.ts (shortened)const property = fc.asyncProperty(fc.scheduler(), async (s) => { const db: Db = { status: 'unpaid', charges: 0 }; // The handler takes its database calls as deps, so the scheduler can wrap each one. const deps = { getStatus: s.scheduleFunction(async (_id: string) => db.status), chargeCard: s.scheduleFunction(async (_id: string) => { db.charges += 1; }), setStatus: s.scheduleFunction(async (_id: string, status: OrderStatus) => { db.status = status; }), }; const run = Promise.all([handleOrderConfirmed('o1', deps), handleOrderConfirmed('o1', deps)]); // Release the queued calls one at a time, in an order the scheduler picks. await s.waitIdle(); await run; expect(db.charges).toBe(1);});// 100 runs by default; seed 1 makes the run repeatable.const details = await fc.check(property, { seed: 1 });console.log(fc.defaultReportMessage(details));Running it
Section titled “Running it”getStatus calls return unpaid, then both charges run.The real output:
Property failed after 1 tests{ seed: 1, path: "0", endOnFailure: true }Counterexample: [schedulerFor()`-> [task${2}] function::("o1") resolved with value "unpaid"-> [task${1}] function::("o1") resolved with value "unpaid"-> [task${4}] function::("o1") resolved-> [task${3}] function::("o1") resolved-> [task${5}] function::("o1","paid") resolved-> [task${6}] function::("o1","paid") resolved`]Shrunk 0 time(s)
Hint: Enable verbose mode in order to have the list of all failing values encountered during the runIt fails on the first run. The counterexample is the order in which the scheduled calls resolved. Tasks 1 and 2 are
the two getStatus calls, and both return "unpaid". Tasks 3 and 4 are the two chargeCard calls, so the card is
charged twice. Tasks 5 and 6 are the two setStatus(..., "paid") calls. The second line is what you pass back to
replay the failure.
Fixing the bug
Section titled “Fixing the bug”claimOrder. All 100 sampled runs pass.The fix replaces the read with claimOrder, which sets 'charging' only if the order is still 'unpaid' and returns
whether it did. Only the worker that got the claim charges:
export interface Deps { getStatus: (orderId: string) => Promise<OrderStatus>; chargeCard: (orderId: string) => Promise<void>; setStatus: (orderId: string, status: OrderStatus) => Promise<void>;}
export async function handleOrderConfirmed(orderId: string, deps: Deps): Promise<void> { const status = await deps.getStatus(orderId); if (status === 'unpaid') { await deps.chargeCard(orderId); await deps.setStatus(orderId, 'paid'); }}export interface Deps { getStatus: (orderId: string) => Promise<OrderStatus>; claimOrder: (orderId: string) => Promise<boolean>; chargeCard: (orderId: string) => Promise<void>; setStatus: (orderId: string, status: OrderStatus) => Promise<void>;}
export async function handleOrderConfirmed(orderId: string, deps: Deps): Promise<void> { const status = await deps.getStatus(orderId); if (status === 'unpaid') { const claimed = await deps.claimOrder(orderId); if (claimed) { await deps.chargeCard(orderId); await deps.setStatus(orderId, 'paid'); }}export interface Deps {getStatusclaimOrder: (orderId: string) => Promise<OrderStatusboolean>;chargeCard: (orderId: string) => Promise<void>;setStatus: (orderId: string, status: OrderStatus) => Promise<void>;}export async function handleOrderConfirmed(orderId: string, deps: Deps): Promise<void> {conststatusclaimed = await deps.getStatusclaimOrder(orderId);if (status === 'unpaid') {if (claimed) {await deps.chargeCard(orderId);await deps.setStatus(orderId, 'paid');}}
export interface Deps { claimOrder: (orderId: string) => Promise<boolean>; chargeCard: (orderId: string) => Promise<void>; setStatus: (orderId: string, status: OrderStatus) => Promise<void>;}
export async function handleOrderConfirmed(orderId: string, deps: Deps): Promise<void> { const claimed = await deps.claimOrder(orderId); if (claimed) { await deps.chargeCard(orderId); await deps.setStatus(orderId, 'paid'); }}In the test, claimOrder is wrapped with s.scheduleFunction like the others, and the property runs under
fc.assert with the same seed. It passes all 100 sampled runs:
✓ webhook under fast-check > holds for every sampled order once the claim is atomic 4msProject layout and CI
Section titled “Project layout and CI”./├── .github/workflows/ci.yml└── src/ ├── webhook.ts the handler └── webhook.fast-check.test.ts the property, with the wrapped dependenciesCommit the code and the test together. CI needs no API key and no extra tool, only the test runner. fc.assert
throws when the property fails, so the test run exits non-zero:
The CI workflow: .github/workflows/ci.yml (3 lines)
# .github/workflows/ci.yml (steps)- run: npm ci- run: npm test # vitest runWhat fails when:
- A change lets two deliveries charge twice: the test, with the seed and the order of calls that broke it.
- A change adds an async call that is not wrapped: the test can still pass, because the scheduler does not reorder that call.
Making changes
Section titled “Making changes”- Change the handler.
- If it calls a new async dependency, wrap it with
s.scheduleFunctionin the test. - Run
npm test. - If it fails, pass the printed
{ seed, path }tofc.assertto get the same order again while you fix it. - Commit the code and the test together.
Without a fixed seed, each run picks a new seed, so CI tries different orders on each push.
Strengths
Section titled “Strengths”- It runs your real code. It works with any test runner.
- It is strong on data. It can generate any kind of input, and cuts a failure down to the smallest input or order that still fails, repeatable with a seed.
- It can turn another tool’s failing run into a test.
fc.schedulerForreplays a given order of calls every time.
Limitations
Section titled “Limitations”- A clean run is not a proof. It tries 100 random runs by default, so a bug that needs one rare order can be missed. For every order, pnueli, TLA+ or Quint check a model instead.
- It only reorders the promises you wrap. Any async dependency you do not wrap resolves on its own, so a race through it cannot be found.
- “Eventually” rules are not built in. The separate fast-check-ltl package adds them.
Built by Nicolas Dubien, who started fast-check in 2017 and still maintains it, with a large group of contributors; open source.
- fast-check on GitHub: https://github.com/dubzzz/fast-check
- fast-check documentation: https://fast-check.dev
- Model-based testing: https://fast-check.dev/docs/advanced/model-based-testing/
- Race conditions and the scheduler: https://fast-check.dev/docs/advanced/race-conditions/
- fast-check-ltl on npm: https://www.npmjs.com/package/fast-check-ltl