When testing React components with Testing Library, we should always be using real timers. Fake timers should be a rare exception. Let me offer some reasons why.
The philosophy of Testing Library is that it runs your React code in an environment as close as possible to the browser. React components are rendered using the default DOM renderer, and a real DOM tree is constructed. The DOM is jsdom, you get very limited CSS styles, no layout or painting, and element dimensions are always 0, but other than that, it’s a pretty good DOM. You perform your test assertions on this DOM, not on some artificial data structure like a component tree produced by react-test-renderer (which is not used by Testing Library at all, except in the react-native flavor). Events are dispatched to this DOM tree, too, TL’s fireEvent is a very thin wrapper around element.dispatchEvent().
userEvent also tries to be as realistic as possible. Part of that is doing a delay: 0 between events, because that’s close to what the browser also does. Consider this code:
function handleEvent(e) {
console.log('one', e.type);
Promise.resolve().then(()=>console.log('two', e.type));
}
return <div onMouseDown={handleEvent} onClick={handleEvent}/>
It logs the mousedown and click events, once synchronously and once after a microtask tick.
In a browser you get this sequence logged to console:
one mousedown
two mousedown
one click
two click
Both events are dispatched in separate event loop ticks, and all microtasks scheduled by mousedown run before click is dispatched.
If you used userEvent.click() with delay: null, you would get a different order:
one mousedown
one click
two mousedown
two click
Here both mouse event’s are dispatched synchronously, with no tick between them. The microtasks have a chance to run only after both dispatches. That’s why the default delay is delay: 0. It leads to a setTimeout(0) wait between the events, and that leaves room for the scheduled microtasks to finish. The result is more realistic scheduling.
Generally, Testing Library offers an environment where there’s as little mocking ans as little magic as possible. But Jest fake timers? They are very magical. For example, one striking feature of a test like this:
function callAfterSecondAndThenAgain(cb) {
setTimeout(() => {
cb();
setTimeout(() => {
cb();
}, 1000 );
}, 1000 );
}
it('calls the callbacks', () => {
const cb = jest.fn();
callAfterSecondAndThenAgain(cb);
jest.advanceTimersByTime(2000);
expect(cb).toHaveBeenCalledTimes(2);
});
is that although the tested function is clearly async, the test is completely synchronous. It’s completely executed within one event loop tick. There is no done callback to be called, no promise returned and awaited. Fake timers keep track of scheduled timeouts and advanceTimersByTime() will synchronously execute them one by one before returning.
But that’s no longer true when your code uses promises. Promises are always async, they are not affected by fake timers at all. If your async code uses both setTimeout (or setInterval or setImmediate) and promises, fake timers convert it into something that’s half-sync/half-async, and the execution environment is no longer realistic.
There’s this example code posted on one StackOverflow question:
jest.useFakeTimers()
it('simpleTimer', async () => {
async function simpleTimer(callback) {
await callback() // without await here, test works as expected.
setTimeout(() => {
simpleTimer(callback)
}, 1000)
}
const callback = jest.fn()
await simpleTimer(callback)
jest.advanceTimersByTime(8000)
expect(callback).toHaveBeenCalledTimes(9)
}
With await callback() on line 5, the test fails, calling callback only two times. Removing the await “fixes” it, calling callback nine times. Let’s dissect what happens:
The await case:
simpleTimeris called,callbackis called (call 1)- in next microtask tick (after
await), timeout is scheduled.simpleTimerreturns. advanceTimersByTimeis called. It sees one scheduled timeout, so it executes it. The timeout callback callssimpleTimeragain.- This
simpleTimercallscallbackimmediately and sychronously (call 2), and then immediately returns a promise. That’s because it’s anasyncfunction. They execute synchronously until the firstawaitand then return a promise to wait for the rest. ThesetTimeoutcall is scheduled for the next microtask tick, afterawait. - The timeout callback returns (the promise returned by
simpleTimeris ignored) andadvanceTimersByTimetakes control again. There are no more timers schedules, so it returns. expectcheck the number of calls tocallbackand finds two.- The test finishes, and only after it finished, the microtask with
setTimeoutis executed. A new timer is added to the fake timers queue, but nobody cares anymore:advanceTimersByTimehas finished already. The scheduled timer will be probably removed in someafterEachfake timer’s cleanup.
The no-await case:
The crucial difference is in step 4. The setTimeout call in simpleTimer will schedule another timer before simpleTimer returns. When control returns to advanceTimersByTime, the timer is already scheduled and advanceTimersByTime sees it. So it will advance timers by another 1000ms and execute the timer callback. This (infinite) loop will continue until advanceTimersByTime spends its entire budget of 8000ms and then it returns. Now callback has been called 9 times.
That’s fairly complex, isn’t it? You need to track the tasks very carefully to understand this. In a real-life complex code, I’d argue that fake timers with promises become intractable. In the Testing Library codebase, in the waitFor implemenation, the part that handles the fake timers + promises combo, even the library author admits he doesn’t really know what he’s doing:
It’s really important that checkCallback is run *before* we flush in-flight promises. To be honest, I’m not sure why, and I can’t quite think of a way to reproduce the problem in a test, but I spent an entire day banging my head against a wall on this.
Kent C. Dodds
Leave a comment