Testing for Race Conditions
Relevant source files
The following files were used as context for generating this wiki page:
- packages/fast-check/src/arbitrary/scheduler.ts
- examples/005-race/counter/main.spec.ts
- examples/005-race/autocomplete/main.spec.tsx
- packages/fast-check/test/e2e/AsyncScheduler.spec.ts
- website/blog/2023-05-21-whats-new-in-fast-check-3-9-0/index.md
- website/blog/2025-07-18-whats-new-in-fast-check-4-2-0/index.md
- website/blog/2025-03-28-beyond-flaky-tests-bringing-controlled-randomness-to-vitest/index.md
fast-check provides powerful tools for detecting race conditions in asynchronous code. Instead of relying on non-deterministic setTimeout or flaky tests, it offers a controlled, deterministic environment through the fc.scheduler arbitrary. This allows developers to explore different interleavings of asynchronous operations, uncovering bugs that only manifest under specific timing conditions.
The core of this feature is the Scheduler arbitrary, which generates a scheduler instance. This instance acts as a controller for promises and other asynchronous tasks, allowing the test to dictate the order and timing of their execution. By wrapping async functions and promises with the scheduler, you can write property-based tests that systematically check for race conditions.
Sources: packages/fast-check/src/arbitrary/scheduler.ts, website/blog/2025-03-28-beyond-flaky-tests-bringing-controlled-randomness-to-vitest/index.md:73-86
The fc.scheduler Arbitrary
The fc.scheduler() arbitrary is the entry point for race condition testing. It doesn't generate a simple value but an instance of the Scheduler class. This instance is then used within a property to manage and orchestrate asynchronous tasks.
A typical test setup involves using fc.asyncProperty with fc.scheduler() to get a scheduler instance s. This instance is then used to wrap functions or promises that are part of the asynchronous logic being tested.
import fc from 'fast-check';
test('should handle asynchronous operations correctly', async () => {
await fc.assert(
fc.asyncProperty(fc.scheduler(), async (s) => {
// `s` is an instance of Scheduler
// Test logic using `s` goes here
})
);
});
This diagram shows the basic workflow for using the scheduler in a test.
The Scheduler Interface
The Scheduler instance provides a set of methods to control the execution of asynchronous code. These methods allow you to wrap existing promises, schedule functions that return promises, and control when these tasks are executed.
This diagram illustrates the main components of the Scheduler interface.
Key Methods
| Method | Description |
|---|---|
schedule(promise, ...) | Wraps an already-created Promise. The scheduler will control its resolution. |
scheduleFunction(function) | Wraps a function that returns a Promise. Each time the wrapped function is called, the returned promise is scheduled. |
waitAll() | (Deprecated) Waits for all currently known scheduled tasks to complete. Can be unpredictable with microtasks. |
waitOne() | (Deprecated) Waits for just one of the currently known scheduled tasks to complete. |
waitIdle() | A more predictable version of waitAll. It waits for all tasks to be scheduled and executed, handling microtasks more robustly. |
waitNext(num) | A more predictable version of waitOne. It waits for a specific number (num) of tasks to be scheduled and executed. |
waitFor(promise) | Runs scheduled tasks until the given promise is resolved or rejected. |
report() | Returns a report of the scheduler's activity, showing what tasks were scheduled and released. |
Sources: packages/fast-check/src/arbitrary/scheduler.ts:28-111, website/blog/2025-07-18-whats-new-in-fast-check-4-2-0/index.md:21-61
Scheduling Tasks
There are two primary ways to schedule tasks: schedule for existing promises and scheduleFunction for functions that produce promises.
The following sequence diagram shows how scheduleFunction works. The test code wraps an async function, calls it, and then uses the scheduler to control when the underlying promise actually resolves.
Waiting for Tasks
The scheduler provides several methods to wait for tasks to complete. The modern waitIdle() and waitNext() are recommended over the legacy waitAll() and waitOne(). The older methods had subtle issues where they might finish before all tasks were properly scheduled, especially when dealing with microtasks. waitIdle() solves this by waiting until the scheduler is truly idle, with no more tasks to schedule or run.
// Unreliable test with waitAll
test('our test', async () => {
await fc.assert(
fc.asyncProperty(fc.scheduler(), async (s) => {
const fetchIdFor = s.scheduleFunction(async (name) => `id:${name}`);
// ...
doStuff('name').then(() => (done = true));
await s.waitAll(); // This might finish before fetchIdFor is even seen by the scheduler
expect(done).toBe(true); // This can fail
}),
);
});
// With waitIdle, the test would pass because it waits for the task to be scheduled.
Sources: website/blog/2025-07-18-whats-new-in-fast-check-4-2-0/index.md:21-52
Integration with UI Frameworks (act)
When testing UI components (e.g., React), state updates must be wrapped in an act function to ensure the component re-renders and the test behaves like a user in a browser. The scheduler supports this by allowing an act function to be passed at two levels:
- Per-schedule: When scheduling an individual task.
- Per-wait: When waiting for tasks using methods like
waitAllorwaitIdle.
This is crucial for testing custom hooks or components that involve asynchronous data fetching, preventing React warnings about updates not being wrapped in act.
// Example from a React hook test
test('should only update the state with the last value to come', () => {
fc.assert(
fc.asyncProperty(fc.scheduler(), async (s) => {
// Arrange
const { act, result, rerender, waitFor } = await s.run(async () => {
const { result, rerender, waitFor } = renderHook(
({ p, s }: { p: Promise<number>; s: Scheduler }) => useDelayedState(p, s),
{ initialProps: { p: Promise.resolve(0), s } },
);
return { act, result, rerender, waitFor };
});
// Act
const p1 = Promise.resolve(1);
act(() => rerender({ p: p1, s }));
const p2 = Promise.resolve(2);
act(() => rerender({ p: p2, s }));
// Assert
await act(async () => {
await s.waitIdle(); // Using act at the wait level
});
expect(result.current).toBe(2);
}),
);
});
In this example, s.waitIdle() is wrapped in act, ensuring that all promise resolutions triggered by the scheduler that result in a React state update are correctly handled.
Sources: examples/005-race/autocomplete/main.spec.tsx:28-52, website/blog/2023-05-21-whats-new-in-fast-check-3-9-0/index.md:23-112
Conclusion
The fc.scheduler arbitrary is an essential tool for building robust asynchronous JavaScript applications. It provides a deterministic way to find and reproduce race conditions by giving the developer full control over the execution order of asynchronous tasks. By moving from flaky, time-based tests to controlled, property-based tests with the scheduler, you can significantly increase confidence in your concurrent code. The introduction of more predictable waiting mechanisms like waitIdle and seamless integration with UI testing patterns via act make it a versatile and powerful feature of fast-check.
Sources: website/blog/2025-07-18-whats-new-in-fast-check-4-2-0/index.md, website/blog/2023-05-21-whats-new-in-fast-check-3-9-0/index.md