Skip to content

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.

fast-check samples orders of async calls with its scheduler. Here it finds a webhook double charge on the first run.

The code below is in formal-methods-samples/fast-check-webhook.

Two deliveries of the same webhook both read 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:

webhook.ts
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.

Terminal window
pnpm add -D fast-check

Works with any test runner; the example uses Vitest.

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));
It fails on the first run: both 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 run

It 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.

Claim the order in one step with 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 {
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> {
const statusclaimed = await deps.getStatusclaimOrder(orderId);
if (status === 'unpaid') {
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 4ms
The property is a normal test file next to the code. CI runs the tests. Nothing else to install.
./
├── .github/workflows/ci.yml
└── src/
├── webhook.ts the handler
└── webhook.fast-check.test.ts the property, with the wrapped dependencies

Commit 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 run

What 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.
Change the code, wrap any new async call, run the tests, commit.
  1. Change the handler.
  2. If it calls a new async dependency, wrap it with s.scheduleFunction in the test.
  3. Run npm test.
  4. If it fails, pass the printed { seed, path } to fc.assert to get the same order again while you fix it.
  5. 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.

  • 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.schedulerFor replays a given order of calls every time.
  • 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.

Last updated: