From 7630c43b436c4b7f01fdf2340dd1868152520685 Mon Sep 17 00:00:00 2001 From: Andrew Scott Date: Wed, 11 Sep 2024 15:34:16 -0700 Subject: [PATCH] feat(jest-fake-timers): Add feature to enable automatically advancing timers 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 commit attempts to resolve these issues by adding a feature which allows jest to advance timers automatically with the passage of time, just as clocks do without mocks installed. --- docs/JestObjectAPI.md | 16 +++++ packages/jest-environment/src/index.ts | 10 +++ .../src/__tests__/modernFakeTimers.test.ts | 68 +++++++++++++++++++ .../jest-fake-timers/src/modernFakeTimers.ts | 39 +++++++++++ packages/jest-runtime/src/index.ts | 11 +++ 5 files changed, 144 insertions(+) diff --git a/docs/JestObjectAPI.md b/docs/JestObjectAPI.md index 8b0e6ece428f..b0ca7ddb56e9 100644 --- a/docs/JestObjectAPI.md +++ b/docs/JestObjectAPI.md @@ -1067,10 +1067,26 @@ This means, if any timers have been scheduled (but have not yet executed), they Returns the number of fake timers still left to run. +### `jest.setAdvanceTimersAutomatically()` + +Configures whether timers advance automatically. When enabled, jest will advance the clock to the next timer in the queue after a macrotask. With automatically advancing timers enabled, tests can be written in a way that is independent from whether fake timers are installed. Tests can always be written to wait for timers to resolve, even when using fake timers. + +This feature differs from the `advanceTimers` in two key ways: + +1. The microtask queue is allowed to empty between each timer execution, as would be the case without fake timers installed. +1. It advances as quickly and as far as necessary. If the next timer in the queue is at 1000ms, it will advance +1000ms immediately whereas `advanceTimers`, without manually advancing time in the test, would take `1000 / advanceTimersMs` real time to reach and execute the timer. + ### `jest.now()` Returns the time in ms of the current clock. This is equivalent to `Date.now()` if real timers are in use, or if `Date` is mocked. In other cases (such as legacy timers) it may be useful for implementing custom mocks of `Date.now()`, `performance.now()`, etc. +:::info + +This function is not available when using legacy fake timers implementation. + +::: + ### `jest.setSystemTime(now?: number | Date)` Set the current system time used by fake timers. Simulates a user changing the system clock while your program is running. It affects the current time but it does not in itself cause e.g. timers to fire; they will fire exactly as they would have done without the call to `jest.setSystemTime()`. diff --git a/packages/jest-environment/src/index.ts b/packages/jest-environment/src/index.ts index 9f1eccc3c378..d9bb2b844a3c 100644 --- a/packages/jest-environment/src/index.ts +++ b/packages/jest-environment/src/index.ts @@ -90,6 +90,16 @@ export interface Jest { * Not available when using legacy fake timers implementation. */ advanceTimersToNextTimerAsync(steps?: number): Promise; + /** + * Configures whether timers advance automatically. With automatically advancing + * timers enabled, tests can be written in a way that is independent from whether + * fake timers are installed. Tests can always be written to wait for timers to + * resolve, even when using fake timers. + * + * @remarks + * Not available when using legacy fake timers implementation. + */ + setAdvanceTimersAutomatically(autoAdvance: boolean): void; /** * Disables automatic mocking in the module loader. */ diff --git a/packages/jest-fake-timers/src/__tests__/modernFakeTimers.test.ts b/packages/jest-fake-timers/src/__tests__/modernFakeTimers.test.ts index 71a542c607da..12b677042c14 100644 --- a/packages/jest-fake-timers/src/__tests__/modernFakeTimers.test.ts +++ b/packages/jest-fake-timers/src/__tests__/modernFakeTimers.test.ts @@ -1330,6 +1330,74 @@ describe('FakeTimers', () => { }); }); + describe('setAdvanceTimersAutomatically', () => { + let global: typeof globalThis; + let timers: FakeTimers; + beforeEach(() => { + global = { + Date, + Promise, + clearTimeout, + process, + setTimeout, + } as unknown as typeof globalThis; + + timers = new FakeTimers({config: makeProjectConfig(), global}); + + timers.useFakeTimers(); + timers.setAdvanceTimersAutomatically(true); + }); + + it('can always wait for a timer to execute', async () => { + const p = new Promise(resolve => { + global.setTimeout(resolve, 100); + }); + await expect(p).resolves.toBeUndefined(); + }); + + it('can mix promises inside timers', async () => { + const p = new Promise(resolve => + global.setTimeout(async () => { + await Promise.resolve(); + global.setTimeout(resolve, 100); + }, 100), + ); + await expect(p).resolves.toBeUndefined(); + }); + + it('automatically advances all timers', async () => { + const p1 = new Promise(resolve => global.setTimeout(resolve, 50)); + const p2 = new Promise(resolve => global.setTimeout(resolve, 50)); + const p3 = new Promise(resolve => global.setTimeout(resolve, 100)); + await expect(Promise.all([p1, p2, p3])).resolves.toEqual([ + undefined, + undefined, + undefined, + ]); + }); + + it('can turn off and on auto advancing of time', async () => { + let p2Resolved = false; + const p1 = new Promise(resolve => global.setTimeout(resolve, 50)); + const p2 = new Promise(resolve => global.setTimeout(resolve, 51)).then( + () => (p2Resolved = true), + ); + const p3 = new Promise(resolve => global.setTimeout(resolve, 52)); + + await expect(p1).resolves.toBeUndefined(); + + timers.setAdvanceTimersAutomatically(false); + await new Promise(resolve => setTimeout(resolve, 5)); + expect(p2Resolved).toBe(false); + + timers.setAdvanceTimersAutomatically(true); + await new Promise(resolve => setTimeout(resolve, 5)); + await expect(p2).resolves.toBe(true); + await expect(p3).resolves.toBeUndefined(); + expect(p2Resolved).toBe(true); + }); + }); + describe('now', () => { let timers: FakeTimers; let fakedGlobal: typeof globalThis; diff --git a/packages/jest-fake-timers/src/modernFakeTimers.ts b/packages/jest-fake-timers/src/modernFakeTimers.ts index 310cf4be6b85..2454ee90a7bc 100644 --- a/packages/jest-fake-timers/src/modernFakeTimers.ts +++ b/packages/jest-fake-timers/src/modernFakeTimers.ts @@ -21,6 +21,10 @@ export default class FakeTimers { private _fakingTime: boolean; private readonly _global: typeof globalThis; private readonly _fakeTimers: FakeTimerWithContext; + private autoTickMode: {counter: number; mode: 'manual' | 'auto'} = { + counter: 0, + mode: 'manual', + }; constructor({ global, @@ -142,6 +146,22 @@ export default class FakeTimers { this._fakingTime = true; } + setAdvanceTimersAutomatically(autoAdvance: boolean): void { + if (!this._checkFakeTimers()) { + return; + } + + const newMode = autoAdvance ? 'auto' : 'manual'; + if (newMode === this.autoTickMode.mode) { + return; + } + + this.autoTickMode = {counter: this.autoTickMode.counter + 1, mode: newMode}; + if (autoAdvance) { + this._advanceUntilModeChanges(); + } + } + reset(): void { if (this._checkFakeTimers()) { const {now} = this._clock; @@ -224,4 +244,23 @@ export default class FakeTimers { toFake: [...toFake], }; } + + /** + * Advances the Clock's time until the mode changes. + * + * The time is advanced asynchronously, giving microtasks and events a chance + * to run before each timer runs. + */ + private async _advanceUntilModeChanges() { + if (!this._checkFakeTimers()) { + return; + } + const {counter} = this.autoTickMode; + + while (this.autoTickMode.counter === counter && this._fakingTime) { + // nextAsync always resolves in a setTimeout, even when there are no timers. + // https://github.com/sinonjs/fake-timers/blob/710cafad25abe9465c807efd8ed9cf3a15985fb1/src/fake-timers-src.js#L1517-L1546 + await this._clock.nextAsync(); + } + } } diff --git a/packages/jest-runtime/src/index.ts b/packages/jest-runtime/src/index.ts index 477be4b7ef22..cc65ca0250b2 100644 --- a/packages/jest-runtime/src/index.ts +++ b/packages/jest-runtime/src/index.ts @@ -2407,6 +2407,17 @@ export default class Runtime { ); } }, + setAdvanceTimersAutomatically: (autoAdvance: boolean) => { + const fakeTimers = _getFakeTimers(); + + if (fakeTimers === this._environment.fakeTimersModern) { + fakeTimers.setAdvanceTimersAutomatically(autoAdvance); + } else { + throw new TypeError( + '`jest.setAdvanceTimersAutomatically()` is not available when using legacy fake timers.', + ); + } + }, setMock: (moduleName, mock) => setMockFactory(moduleName, () => mock), setSystemTime: now => { const fakeTimers = _getFakeTimers();