Skip to content

Commit 0c3140c

Browse files
committed
feat: Add additional auto advance time controls
TL;DR This adds a new mode for automatically advancing time that moves more quickly than the existing shouldAdvanceTime, which uses real time. Testing with mock clocks can often turn into a real struggle when dealing with situations where some work in the test is truly async and other work is captured by the mock clock. In addition, when using mock clocks, testers are always forced to write tests with intimate knowledge of when the mock clock needs to be ticked. Oftentimes, the purpose of using a mock clock is to speed up the execution time of the test when there are timeouts involved. It is not often a goal to test the exact timeout values. This can cause tests to be riddled with manual advancements of fake time. It ideal for test code to be written in a way that is independent of whether a mock clock is installed or which mock clock library is used. For example: ``` document.getElementById('submit'); // https://testing-library.com/docs/dom-testing-library/api-async/#waitfor await waitFor(() => expect(mockAPI).toHaveBeenCalledTimes(1)) ``` When mock clocks are involved, the above may not be possible if there is some delay involved between the click and the request to the API. Instead, developers would need to manually tick the clock beyond the delay to trigger the API call. This is different from the existing `shouldAdvanceTime` in the following ways: `shouldAdvanceTime` is essentially `setInterval(() => clock.tick(ms), ms)` while this feature is `const loop = () => setTimeout(() => clock.nextAsync().then(() => loop()), 0);` There are two key differences between these two: 1. `shouldAdvanceTime` uses `clock.tick(ms)` so it synchronously runs all timers inside the "ms" of the clock queue. This doesn't allow the microtask queue to empty between the macrotask timers in the clock whereas something like `tickAsync(ms)` (or a loop around `nextAsync`) would. This could arguably be considered a fixable bug in its implementation 2. `shouldAdvanceTime` uses real time to advance the same amount of real time in the mock clock. The way I understand it, this feels somewhat like "real time with the opportunity to advance more quickly by manually advancing time". This would be quite different: It advances time as quickly possible and as far as necessary. Without manual ticks, `shouldAdvanceTime` would only be capabale of automatically advancing as far as the timeout of the test and take the whole real time of the test timeout. In contrast, `setTickMode({mode: "nextAsync"})` can theoretically advance infinitely far, limited only by processing speed. Somewhat similar to the [--virtual-time-budget](https://developer.chrome.com/docs/chromium/headless#--virtual-time-budget) feature of headless chrome. In addition to the "quick mode" of `shouldAdvanceTime`, this also adds the ability to modify the initially configured values for shouldAdvanceTime and advanceTimeDelta.
1 parent 404829d commit 0c3140c

File tree

3 files changed

+300
-96
lines changed

3 files changed

+300
-96
lines changed

README.md

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,12 @@ setTimeout(() => {
146146
}, 50);
147147
```
148148

149+
In addition to the above, mocked time can be configured to advance more quickly
150+
using `clock.setTickMode({ mode: "nextAsync" });`. With this mode, the clock
151+
advances to the first scheduled timer and fires it, in a loop. Between each timer,
152+
it will also break the event loop, allowing any scheduled promise
153+
callbacks to execute _before_ running the next one.
154+
149155
## API Reference
150156

151157
### `var clock = FakeTimers.createClock([now[, loopLimit]])`
@@ -180,13 +186,25 @@ The following configuration options are available
180186

181187
Allows configuring how the clock advances time, automatically or manually.
182188

183-
There are 2 different types of modes for advancing timers:
189+
There are 3 different types of modes for advancing timers:
184190

185191
- `{mode: 'manual'}`: Timers do not advance without explicit, manual calls to the tick
186192
APIs (`jest.advanceTimersToNextTimer`, `jest.runAllTimers`, etc). This mode is equivalent to `false`.
193+
- `{mode: 'nextAsync'}`: The clock will continuously break the event loop, then run the next timer until the mode changes.
194+
As a result, tests can be written in a way that is independent from whether fake timers are installed.
195+
Tests can always be written to wait for timers to resolve, even when using fake timers.
187196
- `{mode: 'interval', delta?: <number>}`: This is the same as specifying `shouldAdvanceTime: true` with an `advanceTimeDelta`. If the delta is
188197
not specified, 20 will be used by default.
189198

199+
The 'nextAsync' mode differs from `interval` in two key ways:
200+
201+
1. The microtask queue is allowed to empty between each timer execution,
202+
as would be the case without fake timers installed.
203+
1. It advances as quickly and as far as necessary. If the next timer in
204+
the queue is at 1000ms, it will advance 1000ms immediately whereas interval,
205+
without manually advancing time in the test, would take `1000 / advanceTimeDelta`
206+
real time to reach and execute the timer.
207+
190208
### `var id = clock.setTimeout(callback, timeout)`
191209

192210
Schedules the callback to be fired once `timeout` milliseconds have ticked by.

src/fake-timers-src.js

Lines changed: 155 additions & 95 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,12 @@ if (typeof require === "function" && typeof module === "object") {
1616
}
1717

1818
/**
19-
* @typedef {"manual" | "interval"} TickMode
19+
* @typedef {"nextAsync" | "manual" | "interval"} TickMode
20+
*/
21+
22+
/**
23+
* @typedef {object} NextAsyncTickMode
24+
* @property {"nextAsync"} mode
2025
*/
2126

2227
/**
@@ -31,7 +36,7 @@ if (typeof require === "function" && typeof module === "object") {
3136
*/
3237

3338
/**
34-
* @typedef {IntervalTickMode | ManualTickMode} TimerTickMode
39+
* @typedef {IntervalTickMode | NextAsyncTickMode | ManualTickMode} TimerTickMode
3540
*/
3641

3742
/**
@@ -1243,11 +1248,54 @@ function withGlobal(_global) {
12431248
delta: newDelta,
12441249
};
12451250

1246-
if (newMode === "interval") {
1251+
if (newMode === "nextAsync") {
1252+
advanceUntilModeChanges();
1253+
} else if (newMode === "interval") {
12471254
createIntervalTick(clock, newDelta || 20);
12481255
}
12491256
};
12501257

1258+
async function advanceUntilModeChanges() {
1259+
async function newMacrotask() {
1260+
// MessageChannel ensures that setTimeout is not throttled to 4ms.
1261+
// https://developer.mozilla.org/en-US/docs/Web/API/setTimeout#reasons_for_delays_longer_than_specified
1262+
// https://stackblitz.com/edit/stackblitz-starters-qtlpcc
1263+
const channel = new MessageChannel();
1264+
await new Promise((resolve) => {
1265+
channel.port1.onmessage = () => {
1266+
resolve();
1267+
channel.port1.close();
1268+
};
1269+
channel.port2.postMessage(undefined);
1270+
});
1271+
channel.port1.close();
1272+
channel.port2.close();
1273+
// setTimeout ensures microtask queue is emptied
1274+
await new Promise((resolve) => {
1275+
originalSetTimeout(resolve);
1276+
});
1277+
}
1278+
1279+
const { counter } = clock.tickMode;
1280+
while (clock.tickMode.counter === counter) {
1281+
await newMacrotask();
1282+
if (clock.tickMode.counter !== counter) {
1283+
return;
1284+
}
1285+
clock.next();
1286+
}
1287+
}
1288+
1289+
function pauseAutoTickUntilFinished(promise) {
1290+
if (clock.tickMode.mode !== "nextAsync") {
1291+
return promise;
1292+
}
1293+
clock.setTickMode({ mode: "manual" });
1294+
return promise.finally(() => {
1295+
clock.setTickMode({ mode: "nextAsync" });
1296+
});
1297+
}
1298+
12511299
clock.requestIdleCallback = function requestIdleCallback(
12521300
func,
12531301
timeout,
@@ -1546,15 +1594,17 @@ function withGlobal(_global) {
15461594
* @returns {Promise}
15471595
*/
15481596
clock.tickAsync = function tickAsync(tickValue) {
1549-
return new _global.Promise(function (resolve, reject) {
1550-
originalSetTimeout(function () {
1551-
try {
1552-
doTick(tickValue, true, resolve, reject);
1553-
} catch (e) {
1554-
reject(e);
1555-
}
1556-
});
1557-
});
1597+
return pauseAutoTickUntilFinished(
1598+
new _global.Promise(function (resolve, reject) {
1599+
originalSetTimeout(function () {
1600+
try {
1601+
doTick(tickValue, true, resolve, reject);
1602+
} catch (e) {
1603+
reject(e);
1604+
}
1605+
});
1606+
}),
1607+
);
15581608
};
15591609
}
15601610

@@ -1578,37 +1628,39 @@ function withGlobal(_global) {
15781628

15791629
if (typeof _global.Promise !== "undefined") {
15801630
clock.nextAsync = function nextAsync() {
1581-
return new _global.Promise(function (resolve, reject) {
1582-
originalSetTimeout(function () {
1583-
try {
1584-
const timer = firstTimer(clock);
1585-
if (!timer) {
1586-
resolve(clock.now);
1587-
return;
1588-
}
1589-
1590-
let err;
1591-
clock.duringTick = true;
1592-
clock.now = timer.callAt;
1631+
return pauseAutoTickUntilFinished(
1632+
new _global.Promise(function (resolve, reject) {
1633+
originalSetTimeout(function () {
15931634
try {
1594-
callTimer(clock, timer);
1595-
} catch (e) {
1596-
err = e;
1597-
}
1598-
clock.duringTick = false;
1599-
1600-
originalSetTimeout(function () {
1601-
if (err) {
1602-
reject(err);
1603-
} else {
1635+
const timer = firstTimer(clock);
1636+
if (!timer) {
16041637
resolve(clock.now);
1638+
return;
16051639
}
1606-
});
1607-
} catch (e) {
1608-
reject(e);
1609-
}
1610-
});
1611-
});
1640+
1641+
let err;
1642+
clock.duringTick = true;
1643+
clock.now = timer.callAt;
1644+
try {
1645+
callTimer(clock, timer);
1646+
} catch (e) {
1647+
err = e;
1648+
}
1649+
clock.duringTick = false;
1650+
1651+
originalSetTimeout(function () {
1652+
if (err) {
1653+
reject(err);
1654+
} else {
1655+
resolve(clock.now);
1656+
}
1657+
});
1658+
} catch (e) {
1659+
reject(e);
1660+
}
1661+
});
1662+
}),
1663+
);
16121664
};
16131665
}
16141666

@@ -1641,51 +1693,55 @@ function withGlobal(_global) {
16411693

16421694
if (typeof _global.Promise !== "undefined") {
16431695
clock.runAllAsync = function runAllAsync() {
1644-
return new _global.Promise(function (resolve, reject) {
1645-
let i = 0;
1646-
/**
1647-
*
1648-
*/
1649-
function doRun() {
1650-
originalSetTimeout(function () {
1651-
try {
1652-
runJobs(clock);
1653-
1654-
let numTimers;
1655-
if (i < clock.loopLimit) {
1656-
if (!clock.timers) {
1657-
resetIsNearInfiniteLimit();
1658-
resolve(clock.now);
1659-
return;
1660-
}
1661-
1662-
numTimers = Object.keys(
1663-
clock.timers,
1664-
).length;
1665-
if (numTimers === 0) {
1666-
resetIsNearInfiniteLimit();
1667-
resolve(clock.now);
1696+
return pauseAutoTickUntilFinished(
1697+
new _global.Promise(function (resolve, reject) {
1698+
let i = 0;
1699+
/**
1700+
*
1701+
*/
1702+
function doRun() {
1703+
originalSetTimeout(function () {
1704+
try {
1705+
runJobs(clock);
1706+
1707+
let numTimers;
1708+
if (i < clock.loopLimit) {
1709+
if (!clock.timers) {
1710+
resetIsNearInfiniteLimit();
1711+
resolve(clock.now);
1712+
return;
1713+
}
1714+
1715+
numTimers = Object.keys(
1716+
clock.timers,
1717+
).length;
1718+
if (numTimers === 0) {
1719+
resetIsNearInfiniteLimit();
1720+
resolve(clock.now);
1721+
return;
1722+
}
1723+
1724+
clock.next();
1725+
1726+
i++;
1727+
1728+
doRun();
1729+
checkIsNearInfiniteLimit(clock, i);
16681730
return;
16691731
}
16701732

1671-
clock.next();
1672-
1673-
i++;
1674-
1675-
doRun();
1676-
checkIsNearInfiniteLimit(clock, i);
1677-
return;
1733+
const excessJob = firstTimer(clock);
1734+
reject(
1735+
getInfiniteLoopError(clock, excessJob),
1736+
);
1737+
} catch (e) {
1738+
reject(e);
16781739
}
1679-
1680-
const excessJob = firstTimer(clock);
1681-
reject(getInfiniteLoopError(clock, excessJob));
1682-
} catch (e) {
1683-
reject(e);
1684-
}
1685-
});
1686-
}
1687-
doRun();
1688-
});
1740+
});
1741+
}
1742+
doRun();
1743+
}),
1744+
);
16891745
};
16901746
}
16911747

@@ -1701,21 +1757,25 @@ function withGlobal(_global) {
17011757

17021758
if (typeof _global.Promise !== "undefined") {
17031759
clock.runToLastAsync = function runToLastAsync() {
1704-
return new _global.Promise(function (resolve, reject) {
1705-
originalSetTimeout(function () {
1706-
try {
1707-
const timer = lastTimer(clock);
1708-
if (!timer) {
1709-
runJobs(clock);
1710-
resolve(clock.now);
1711-
}
1760+
return pauseAutoTickUntilFinished(
1761+
new _global.Promise(function (resolve, reject) {
1762+
originalSetTimeout(function () {
1763+
try {
1764+
const timer = lastTimer(clock);
1765+
if (!timer) {
1766+
runJobs(clock);
1767+
resolve(clock.now);
1768+
}
17121769

1713-
resolve(clock.tickAsync(timer.callAt - clock.now));
1714-
} catch (e) {
1715-
reject(e);
1716-
}
1717-
});
1718-
});
1770+
resolve(
1771+
clock.tickAsync(timer.callAt - clock.now),
1772+
);
1773+
} catch (e) {
1774+
reject(e);
1775+
}
1776+
});
1777+
}),
1778+
);
17191779
};
17201780
}
17211781

0 commit comments

Comments
 (0)