From 05e7f28b409e9be6011829697cdfc8d117fc0713 Mon Sep 17 00:00:00 2001 From: Erick Wendel Date: Thu, 22 Jun 2023 01:01:52 -0300 Subject: [PATCH] test_runner: add initial draft for fakeTimers Signed-off-by: Erick Wendel PR-URL: https://github.com/nodejs/node/pull/47775 Backport-PR-URL: https://github.com/nodejs/node/pull/49618 Reviewed-By: Benjamin Gruenbaum Reviewed-By: Moshe Atlow --- doc/api/test.md | 528 +++++++++++++++++ lib/internal/test_runner/{ => mock}/mock.js | 8 + lib/internal/test_runner/mock/mock_timers.js | 335 +++++++++++ lib/internal/test_runner/test.js | 2 +- lib/test.js | 2 +- test/parallel/test-runner-mock-timers.js | 570 +++++++++++++++++++ 6 files changed, 1443 insertions(+), 2 deletions(-) rename lib/internal/test_runner/{ => mock}/mock.js (97%) create mode 100644 lib/internal/test_runner/mock/mock_timers.js create mode 100644 test/parallel/test-runner-mock-timers.js diff --git a/doc/api/test.md b/doc/api/test.md index da7f512d41ef62..6aaae17303872d 100644 --- a/doc/api/test.md +++ b/doc/api/test.md @@ -507,6 +507,116 @@ test('spies on an object method', (t) => { }); ``` +### Timers + +Mocking timers is a technique commonly used in software testing to simulate and +control the behavior of timers, such as `setInterval` and `setTimeout`, +without actually waiting for the specified time intervals. + +Refer to the [`MockTimers`][] class for a full list of methods and features. + +This allows developers to write more reliable and +predictable tests for time-dependent functionality. + +The example below shows how to mock `setTimeout`. +Using `.enable(['setTimeout']);` +it will mock the `setTimeout` functions in the [node:timers](./timers.md) and +[node:timers/promises](./timers.md#timers-promises-api) modules, +as well as from the Node.js global context. + +**Note:** Destructuring functions such as +`import { setTimeout } from 'node:timers'` +is currently not supported by this API. + +```mjs +import assert from 'node:assert'; +import { mock, test } from 'node:test'; + +test('mocks setTimeout to be executed synchronously without having to actually wait for it', () => { + const fn = mock.fn(); + + // Optionally choose what to mock + mock.timers.enable(['setTimeout']); + setTimeout(fn, 9999); + assert.strictEqual(fn.mock.callCount(), 0); + + // Advance in time + mock.timers.tick(9999); + assert.strictEqual(fn.mock.callCount(), 1); + + // Reset the globally tracked mocks. + mock.timers.reset(); + + // If you call reset mock instance, it will also reset timers instance + mock.reset(); +}); +``` + +```js +const assert = require('node:assert'); +const { mock, test } = require('node:test'); + +test('mocks setTimeout to be executed synchronously without having to actually wait for it', () => { + const fn = mock.fn(); + + // Optionally choose what to mock + mock.timers.enable(['setTimeout']); + setTimeout(fn, 9999); + assert.strictEqual(fn.mock.callCount(), 0); + + // Advance in time + mock.timers.tick(9999); + assert.strictEqual(fn.mock.callCount(), 1); + + // Reset the globally tracked mocks. + mock.timers.reset(); + + // If you call reset mock instance, it'll also reset timers instance + mock.reset(); +}); +``` + +The same mocking functionality is also exposed in the mock property on the [`TestContext`][] object +of each test. The benefit of mocking via the test context is +that the test runner will automatically restore all mocked timers +functionality once the test finishes. + +```mjs +import assert from 'node:assert'; +import { test } from 'node:test'; + +test('mocks setTimeout to be executed synchronously without having to actually wait for it', (context) => { + const fn = context.mock.fn(); + + // Optionally choose what to mock + context.mock.timers.enable(['setTimeout']); + setTimeout(fn, 9999); + assert.strictEqual(fn.mock.callCount(), 0); + + // Advance in time + context.mock.timers.tick(9999); + assert.strictEqual(fn.mock.callCount(), 1); +}); +``` + +```js +const assert = require('node:assert'); +const { test } = require('node:test'); + +test('mocks setTimeout to be executed synchronously without having to actually wait for it', (context) => { + const fn = context.mock.fn(); + + // Optionally choose what to mock + context.mock.timers.enable(['setTimeout']); + setTimeout(fn, 9999); + assert.strictEqual(fn.mock.callCount(), 0); + + // Advance in time + context.mock.timers.tick(9999); + assert.strictEqual(fn.mock.callCount(), 1); +}); +``` + ## Test reporters + +> Stability: 1 - Experimental + +Mocking timers is a technique commonly used in software testing to simulate and +control the behavior of timers, such as `setInterval` and `setTimeout`, +without actually waiting for the specified time intervals. + +The [`MockTracker`][] provides a top-level `timers` export +which is a `MockTimers` instance. + +### `timers.enable([timers])` + + + +Enables timer mocking for the specified timers. + +* `timers` {Array} An optional array containing the timers to mock. + The currently supported timer values are `'setInterval'` and `'setTimeout'`. + If no array is provided, all timers (`'setInterval'`, `'clearInterval'`, `'setTimeout'`, + and `'clearTimeout'`) will be mocked by default. + +**Note:** When you enable mocking for a specific timer, its associated +clear function will also be implicitly mocked. + +Example usage: + +```mjs +import { mock } from 'node:test'; +mock.timers.enable(['setInterval']); +``` + +```js +const { mock } = require('node:test'); +mock.timers.enable(['setInterval']); +``` + +The above example enables mocking for the `setInterval` timer and +implicitly mocks the `clearInterval` function. Only the `setInterval` +and `clearInterval` functions from [node:timers](./timers.md), +[node:timers/promises](./timers.md#timers-promises-api), and +`globalThis` will be mocked. + +Alternatively, if you call `mock.timers.enable()` without any parameters: + +All timers (`'setInterval'`, `'clearInterval'`, `'setTimeout'`, and `'clearTimeout'`) +will be mocked. The `setInterval`, `clearInterval`, `setTimeout`, and `clearTimeout` +functions from `node:timers`, `node:timers/promises`, +and `globalThis` will be mocked. + +### `timers.reset()` + + + +This function restores the default behavior of all mocks that were previously +created by this `MockTimers` instance and disassociates the mocks +from the `MockTracker` instance. + +**Note:** After each test completes, this function is called on +the test context's `MockTracker`. + +```mjs +import { mock } from 'node:test'; +mock.timers.reset(); +``` + +```js +const { mock } = require('node:test'); +mock.timers.reset(); +``` + +### `timers.tick(milliseconds)` + + + +Advances time for all mocked timers. + +* `milliseconds` {number} The amount of time, in milliseconds, + to advance the timers. + +**Note:** This diverges from how `setTimeout` in Node.js behaves and accepts +only positive numbers. In Node.js, `setTimeout` with negative numbers is +only supported for web compatibility reasons. + +The following example mocks a `setTimeout` function and +by using `.tick` advances in +time triggering all pending timers. + +```mjs +import assert from 'node:assert'; +import { test } from 'node:test'; + +test('mocks setTimeout to be executed synchronously without having to actually wait for it', (context) => { + const fn = context.mock.fn(); + + context.mock.timers.enable(['setTimeout']); + + setTimeout(fn, 9999); + + assert.strictEqual(fn.mock.callCount(), 0); + + // Advance in time + context.mock.timers.tick(9999); + + assert.strictEqual(fn.mock.callCount(), 1); +}); +``` + +```js +const assert = require('node:assert'); +const { test } = require('node:test'); + +test('mocks setTimeout to be executed synchronously without having to actually wait for it', (context) => { + const fn = context.mock.fn(); + context.mock.timers.enable(['setTimeout']); + + setTimeout(fn, 9999); + assert.strictEqual(fn.mock.callCount(), 0); + + // Advance in time + context.mock.timers.tick(9999); + + assert.strictEqual(fn.mock.callCount(), 1); +}); +``` + +Alternativelly, the `.tick` function can be called many times + +```mjs +import assert from 'node:assert'; +import { test } from 'node:test'; + +test('mocks setTimeout to be executed synchronously without having to actually wait for it', (context) => { + const fn = context.mock.fn(); + context.mock.timers.enable(['setTimeout']); + const nineSecs = 9000; + setTimeout(fn, nineSecs); + + const twoSeconds = 3000; + context.mock.timers.tick(twoSeconds); + context.mock.timers.tick(twoSeconds); + context.mock.timers.tick(twoSeconds); + + assert.strictEqual(fn.mock.callCount(), 1); +}); +``` + +```js +const assert = require('node:assert'); +const { test } = require('node:test'); + +test('mocks setTimeout to be executed synchronously without having to actually wait for it', (context) => { + const fn = context.mock.fn(); + context.mock.timers.enable(['setTimeout']); + const nineSecs = 9000; + setTimeout(fn, nineSecs); + + const twoSeconds = 3000; + context.mock.timers.tick(twoSeconds); + context.mock.timers.tick(twoSeconds); + context.mock.timers.tick(twoSeconds); + + assert.strictEqual(fn.mock.callCount(), 1); +}); +``` + +#### Using clear functions + +As mentioned, all clear functions from timers (`clearTimeout` and `clearInterval`) +are implicity mocked. Take a look at this example using `setTimeout`: + +```mjs +import assert from 'node:assert'; +import { test } from 'node:test'; + +test('mocks setTimeout to be executed synchronously without having to actually wait for it', (context) => { + const fn = context.mock.fn(); + + // Optionally choose what to mock + context.mock.timers.enable(['setTimeout']); + const id = setTimeout(fn, 9999); + + // Implicity mocked as well + clearTimeout(id); + context.mock.timers.tick(9999); + + // As that setTimeout was cleared the mock function will never be called + assert.strictEqual(fn.mock.callCount(), 0); +}); +``` + +```js +const assert = require('node:assert'); +const { test } = require('node:test'); + +test('mocks setTimeout to be executed synchronously without having to actually wait for it', (context) => { + const fn = context.mock.fn(); + + // Optionally choose what to mock + context.mock.timers.enable(['setTimeout']); + const id = setTimeout(fn, 9999); + + // Implicity mocked as well + clearTimeout(id); + context.mock.timers.tick(9999); + + // As that setTimeout was cleared the mock function will never be called + assert.strictEqual(fn.mock.callCount(), 0); +}); +``` + +#### Working with Node.js timers modules + +Once you enable mocking timers, [node:timers](./timers.md), +[node:timers/promises](./timers.md#timers-promises-api) modules, +and timers from the Node.js global context are enabled: + +**Note:** Destructuring functions such as +`import { setTimeout } from 'node:timers'` is currently +not supported by this API. + +```mjs +import assert from 'node:assert'; +import { test } from 'node:test'; +import nodeTimers from 'node:timers'; +import nodeTimersPromises from 'node:timers/promises'; + +test('mocks setTimeout to be executed synchronously without having to actually wait for it', async (context) => { + const globalTimeoutObjectSpy = context.mock.fn(); + const nodeTimerSpy = context.mock.fn(); + const nodeTimerPromiseSpy = context.mock.fn(); + + // Optionally choose what to mock + context.mock.timers.enable(['setTimeout']); + setTimeout(globalTimeoutObjectSpy, 9999); + nodeTimers.setTimeout(nodeTimerSpy, 9999); + + const promise = nodeTimersPromises.setTimeout(9999).then(nodeTimerPromiseSpy); + + // Advance in time + context.mock.timers.tick(9999); + assert.strictEqual(globalTimeoutObjectSpy.mock.callCount(), 1); + assert.strictEqual(nodeTimerSpy.mock.callCount(), 1); + await promise; + assert.strictEqual(nodeTimerPromiseSpy.mock.callCount(), 1); +}); +``` + +```js +const assert = require('node:assert'); +const { test } = require('node:test'); +const nodeTimers = require('node:timers'); +const nodeTimersPromises = require('node:timers/promises'); + +test('mocks setTimeout to be executed synchronously without having to actually wait for it', async (context) => { + const globalTimeoutObjectSpy = context.mock.fn(); + const nodeTimerSpy = context.mock.fn(); + const nodeTimerPromiseSpy = context.mock.fn(); + + // Optionally choose what to mock + context.mock.timers.enable(['setTimeout']); + setTimeout(globalTimeoutObjectSpy, 9999); + nodeTimers.setTimeout(nodeTimerSpy, 9999); + + const promise = nodeTimersPromises.setTimeout(9999).then(nodeTimerPromiseSpy); + + // Advance in time + context.mock.timers.tick(9999); + assert.strictEqual(globalTimeoutObjectSpy.mock.callCount(), 1); + assert.strictEqual(nodeTimerSpy.mock.callCount(), 1); + await promise; + assert.strictEqual(nodeTimerPromiseSpy.mock.callCount(), 1); +}); +``` + +In Node.js, `setInterval` from [node:timers/promises](./timers.md#timers-promises-api) +is an `AsyncGenerator` and is also supported by this API: + +```mjs +import assert from 'node:assert'; +import { test } from 'node:test'; +import nodeTimersPromises from 'node:timers/promises'; +test('should tick five times testing a real use case', async (context) => { + context.mock.timers.enable(['setInterval']); + + const expectedIterations = 3; + const interval = 1000; + const startedAt = Date.now(); + async function run() { + const times = []; + for await (const time of nodeTimersPromises.setInterval(interval, startedAt)) { + times.push(time); + if (times.length === expectedIterations) break; + } + return times; + } + + const r = run(); + context.mock.timers.tick(interval); + context.mock.timers.tick(interval); + context.mock.timers.tick(interval); + + const timeResults = await r; + assert.strictEqual(timeResults.length, expectedIterations); + for (let it = 1; it < expectedIterations; it++) { + assert.strictEqual(timeResults[it - 1], startedAt + (interval * it)); + } +}); +``` + +```js +const assert = require('node:assert'); +const { test } = require('node:test'); +const nodeTimersPromises = require('node:timers/promises'); +test('should tick five times testing a real use case', async (context) => { + context.mock.timers.enable(['setInterval']); + + const expectedIterations = 3; + const interval = 1000; + const startedAt = Date.now(); + async function run() { + const times = []; + for await (const time of nodeTimersPromises.setInterval(interval, startedAt)) { + times.push(time); + if (times.length === expectedIterations) break; + } + return times; + } + + const r = run(); + context.mock.timers.tick(interval); + context.mock.timers.tick(interval); + context.mock.timers.tick(interval); + + const timeResults = await r; + assert.strictEqual(timeResults.length, expectedIterations); + for (let it = 1; it < expectedIterations; it++) { + assert.strictEqual(timeResults[it - 1], startedAt + (interval * it)); + } +}); +``` + +### `timers.runAll()` + + + +Triggers all pending mocked timers immediately. + +The example below triggers all pending timers immediately, +causing them to execute without any delay. + +```mjs +import assert from 'node:assert'; +import { test } from 'node:test'; + +test('runAll functions following the given order', (context) => { + context.mock.timers.enable(['setTimeout']); + const results = []; + setTimeout(() => results.push(1), 9999); + + // Notice that if both timers have the same timeout, + // the order of execution is guaranteed + setTimeout(() => results.push(3), 8888); + setTimeout(() => results.push(2), 8888); + + assert.deepStrictEqual(results, []); + + context.mock.timers.runAll(); + + assert.deepStrictEqual(results, [3, 2, 1]); +}); +``` + +```js +const assert = require('node:assert'); +const { test } = require('node:test'); + +test('runAll functions following the given order', (context) => { + context.mock.timers.enable(['setTimeout']); + const results = []; + setTimeout(() => results.push(1), 9999); + + // Notice that if both timers have the same timeout, + // the order of execution is guaranteed + setTimeout(() => results.push(3), 8888); + setTimeout(() => results.push(2), 8888); + + assert.deepStrictEqual(results, []); + + context.mock.timers.runAll(); + + assert.deepStrictEqual(results, [3, 2, 1]); +}); +``` + +**Note:** The `runAll()` function is specifically designed for +triggering timers in the context of timer mocking. +It does not have any effect on real-time system +clocks or actual timers outside of the mocking environment. + ## Class: `TestsStream`