Race conditions
Easily detect race conditions in your JavaScript code
Overview
Race conditions can easily occur in JavaScript due to its event-driven nature. Any situation where JavaScript has the ability to schedule tasks could potentially lead to race conditions.
A race condition […] is the condition […] where the system's substantive behavior is dependent on the sequence or timing of other uncontrollable events.
Source: https://en.wikipedia.org/wiki/Race_condition
Identifying and fixing race conditions can be challenging as they can occur unexpectedly. It requires a thorough understanding of potential event flows and often involves using advanced debugging and testing tools. To address this issue, fast-check includes a set of built-in tools specifically designed to help in detecting race conditions. The scheduler
arbitrary has been specifically designed for detecting and testing race conditions, making it an ideal tool for addressing these challenges in your testing process.
The scheduler instance
The scheduler
arbitrary is able to generate instances of Scheduler
. They come with following interface:
schedule: <T>(task: Promise<T>, label?: string, metadata?: TMetadata, act?: SchedulerAct) => Promise<T>
- Wrap an existing promise using the scheduler. The newly created promise will resolve when the scheduler decides to resolve it (seewaitOne
andwaitAll
methods).scheduleFunction: <TArgs extends any[], T>(asyncFunction: (...args: TArgs) => Promise<T>, act?: SchedulerAct) => (...args: TArgs) => Promise<T>
- Wrap all the promise produced by an API using the scheduler.scheduleFunction(callApi)
scheduleSequence(sequenceBuilders: SchedulerSequenceItem<TMetadata>[], act?: SchedulerAct): { done: boolean; faulty: boolean, task: Promise<{ done: boolean; faulty: boolean }> }
- Schedule a sequence of operations. Each operation requires the previous one to be resolved before being started. Each of the operations will be executed until its end before starting any other scheduled operation.count(): number
- Number of pending tasks waiting to be scheduled by the scheduler.waitOne: (act?: SchedulerAct) => Promise<void>
- Wait one scheduled task to be executed. Throws if there is no more pending tasks.waitAll: (act?: SchedulerAct) => Promise<void>
- Wait all scheduled tasks, including the ones that might be created by one of the resolved task. Do not use ifwaitAll
call has to be wrapped into an helper function such asact
that can relaunch new tasks afterwards. In this specific case use awhile
loop running whilecount() !== 0
and callingwaitOne
- see CodeSandbox example on userProfile.waitFor: <T>(unscheduledTask: Promise<T>, act?: SchedulerAct) => Promise<T>
- Wait as many scheduled tasks as need to resolve the received task. Contrary towaitOne
orwaitAll
it can be used to wait for calls not yet scheduled when calling it (some test solutions like supertest use such trick not to run any query before the user really calls then on the request itself). Be aware that while this helper will wait eveything to be ready forunscheduledTask
to resolve, having uncontrolled tasks triggering stuff required forunscheduledTask
might make replay of failures harder as such asynchronous triggers stay out-of-control for fast-check.report: () => SchedulerReportItem<TMetaData>[]
- Produce an array containing all the scheduled tasks so far with their execution status. If the task has been executed, it includes a string representation of the associated output or error produced by the task if any. Tasks will be returned in the order they get executed by the scheduler.
With:
type SchedulerSequenceItem<TMetadata> =
| { builder: () => Promise<any>; label: string; metadata?: TMetadata }
| (() => Promise<any>);
You can also define an hardcoded scheduler by using fc.schedulerFor(ordering: number[])
- should be passed through fc.constant
if you want to use it as an arbitrary. For instance: fc.schedulerFor([1,3,2])
means that the first scheduled promise will resolve first, the third one second and at the end we will resolve the second one that have been scheduled.
Scheduling methods
schedule
Create a scheduled Promise
based on an existing one — aka. wrapped Promise
.
The life-cycle of the wrapped Promise
will not be altered at all.
On its side the scheduled Promise
will only resolve when the scheduler decides it.
Once scheduled by the scheduler, the scheduler will wait the wrapped Promise
to resolve before sheduling anything else.
Similar to any other Promise
, if there is a possibility that the wrapped Promise
may be rejected, you have to handle the output of the scheduled Promise
on your end, just as you would with the original Promise
.
Signature
schedule: <T>(task: Promise<T>) => Promise<T>;
schedule: <T>(task: Promise<T>, label?: string, metadata?: TMetadata, customAct?: SchedulerAct) => Promise<T>;
Usage
Any algorithm taking raw Promise
as input might be tested using this scheduler.
For instance, Promise.all
and Promise.race
are examples of such algorithms.
Snippet
// Let suppose:
// - s : Scheduler
// - shortTask: Promise - Very quick operation
// - longTask : Promise - Relatively long operation
shortTask.then(() => {
// not impacted by the scheduler
// as it is directly using the original promise
});
const scheduledShortTask = s.schedule(shortTask);
const scheduledLongTask = s.schedule(longTask);
// Even if in practice, shortTask is quicker than longTask
// If the scheduler selected longTask to end first,
// it will wait longTask to end, then once ended it will resolve scheduledLongTask,
// while scheduledShortTask will still be pending until scheduled.
await s.waitOne();
scheduleFunction
Create a producer of scheduled Promise
.
Many asynchronous codes utilize functions that can produce Promise
based on inputs. For example, fetching from a REST API using fetch("http://domain/")
or accessing data from a database db.query("SELECT * FROM table")
.
scheduleFunction
is able to re-order when these Promise
resolveby waiting the go of the scheduler.
Signature
scheduleFunction: <TArgs extends any[], T>(asyncFunction: (...args: TArgs) => Promise<T>, customAct?: SchedulerAct) =>
(...args: TArgs) =>
Promise<T>;
Usage
Any algorithm making calls to asynchronous APIs can highly benefit from this wrapper to re-order calls.
scheduleFunction
is only postponing the resolution of the function. The call to the function itself is started immediately when the caller calls something on the scheduled function.
Snippet
// Let suppose:
// - s : Scheduler
// - getUserDetails: (uid: string) => Promise - API call to get details for a User
const getUserDetailsScheduled = s.scheduleFunction(getUserDetails);
getUserDetailsScheduled('user-001')
// What happened under the hood?
// - A call to getUserDetails('user-001') has been triggered
// - The promise returned by the call to getUserDetails('user-001') has been registered to the scheduler
.then((dataUser001) => {
// This block will only be executed when the scheduler
// will schedule this Promise
});
// Unlock one of the scheduled Promise registered on s
// Not necessarily the first one that resolves
await s.waitOne();
scheduleSequence
Create a sequence of asynchrnous calls running in a precise order.
One important fact about scheduled sequence is that whenever one task of the sequence gets scheduled, no other scheduled task in the scheduler can be unqueued while this task has not ended. It means that tasks defined within a scheduled sequence must not require other scheduled task to end to fulfill themselves — it does not mean that they should not force the scheduling of other scheduled tasks.
Signature
type SchedulerSequenceItem =
{ builder: () => Promise<any>; label: string } |
(() => Promise<any>)
;
scheduleSequence(sequenceBuilders: SchedulerSequenceItem[], customAct?: SchedulerAct): { done: boolean; faulty: boolean, task: Promise<{ done: boolean; faulty: boolean }> }
Usage
You want to check the status of a database, a webpage after many known operations.
Most of the time, model based testing might be a better fit for that purpose.
Snippet
// Let suppose:
// - s: Scheduler
const initialUserId = '001';
const otherUserId1 = '002';
const otherUserId2 = '003';
// render profile for user {initialUserId}
// Note: api calls to get back details for one user are also scheduled
const { rerender } = render(<UserProfilePage userId={initialUserId} />);
s.scheduleSequence([
async () => rerender(<UserProfilePage userId={otherUserId1} />),
async () => rerender(<UserProfilePage userId={otherUserId2} />),
]);
await s.waitAll();
// expect to see profile for user otherUserId2
Advanced recipes
Scheduling a function call
In some tests, we may want to experiment with scenarios where multiple queries are launched concurrently towards our service to observe its behavior in the context of concurrent operations.
const scheduleCall = <T>(s: Scheduler, f: () => Promise<T>) => {
s.schedule(Promise.resolve('Start the call')).then(() => f());
};
// Calling doStuff will be part of the task scheduled in s
scheduleCall(s, () => doStuff());
Scheduling a call to a mocked server
Unlike the behavior of scheduleFunction
, actual calls to servers are not instantaneous, and you may want to schedule when the call reaches your mocked-server.
For instance, suppose you are creating a TODO-list application. In this app, users can only add a new TODO item if there is no other item with the same label. If you utilize the built-in scheduleFunction
to test this feature, the mocked-server will always receive the calls in the same order as they were made.
const scheduleMockedServerFunction = <TArgs extends unknown[], TOut>(
s: Scheduler,
f: (...args: TArgs) => Promise<TOut>,
) => {
return (...args: TArgs) => {
return s.schedule(Promise.resolve('Server received the call')).then(() => f(...args));
};
};
const newAddTodo = scheduleMockedServerFunction(s, (label) => mockedApi.addTodo(label));
// With newAddTodo = s.scheduleFunction((label) => mockedApi.addTodo(label))
// The mockedApi would have received todo-1 first, followed by todo-2
// When each of those calls resolve would have been the responsibility of s
// In the contrary, with scheduleMockedServerFunction, the mockedApi might receive todo-2 first.
newAddTodo('todo-1'); // .then
newAddTodo('todo-2'); // .then
// or...
const scheduleMockedServerFunction = <TArgs extends unknown[], TOut>(
s: Scheduler,
f: (...args: TArgs) => Promise<TOut>,
) => {
const scheduledF = s.scheduleFunction(f);
return (...args: TArgs) => {
return s.schedule(Promise.resolve('Server received the call')).then(() => scheduledF(...args));
};
};
Wrapping calls automatically using act
scheduler
can be given an act
function that will be called in order to wrap all the scheduled tasks. A code like the following one:
fc.assert(
fc.asyncProperty(fc.scheduler(), async s => () {
// Pushing tasks into the scheduler ...
// ....................................
while (s.count() !== 0) {
await act(async () => {
// This construct is mostly needed when you want to test stuff in React
// In the context of act from React, using waitAll would not have worked
// as some scheduled tasks are triggered after waitOne resolved
// and because of act (effects...)
await s.waitOne();
});
}
}))
Is equivalent to:
fc.assert(
fc.asyncProperty(fc.scheduler({ act }), async s => () {
// Pushing tasks into the scheduler ...
// ....................................
await s.waitAll();
}))
This pattern can be helpful whenever you need to make sure that continuations attached to your tasks get called in proper contexts. For instance, when testing React applications, one cannot perform updates of states outside of act
.
The act
function can be defined on case by case basis instead of being defined globally for all tasks. Check the act
argument available on the methods of the scheduler.
Scheduling native timers
Occasionally, our asynchronous code depends on native timers provided by the JavaScript engine, such as setTimeout
or setInterval
. Unlike other asynchronous operations, timers are ordered, meaning that a timer set to wait for 10ms will be executed before a timer set to wait for 100ms. Consequently, they require special handling.
The code snippet below defines a custom act
function able to schedule timers. It uses Jest, but it can be modified for other testing frameworks if necessary.
// You should call: `jest.useFakeTimers()` at the beginning of your test
// The function below automatically schedules tasks for pending timers.
// It detects any timer added when tasks get resolved by the scheduler (via the act pattern).
// Instead of calling `await s.waitFor(p)`, you can call `await s.waitFor(p, buildWrapWithTimersAct(s))`.
// Instead of calling `await s.waitAll()`, you can call `await s.waitAll(buildWrapWithTimersAct(s))`.
function buildWrapWithTimersAct(s: fc.Scheduler) {
let timersAlreadyScheduled = false;
function scheduleTimersIfNeeded() {
if (timersAlreadyScheduled || jest.getTimerCount() === 0) {
return;
}
timersAlreadyScheduled = true;
s.schedule(Promise.resolve('advance timers')).then(() => {
timersAlreadyScheduled = false;
jest.advanceTimersToNextTimer();
scheduleTimersIfNeeded();
});
}
return async function wrapWithTimersAct(f: () => Promise<unknown>) {
try {
await f();
} finally {
scheduleTimersIfNeeded();
}
};
}
Model based testing and race conditions
Model-based testing features can be combined with race condition detection through the use of scheduledModelRun
. By utilizing this function, the execution of the model will also be processed through the scheduler.
Neither check
nor run
should rely on the completion of other scheduled tasks to fulfill themselves. But they can still trigger new scheduled tasks as long as they don't wait for them to resolve.