Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(jest-fake-timers): Add feature to enable automatically advancing… #15300

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions docs/JestObjectAPI.md
Original file line number Diff line number Diff line change
Expand Up @@ -1037,6 +1037,21 @@ Advances all timers by the needed milliseconds so that only the next timeouts/in

Optionally, you can provide `steps`, so it will run `steps` amount of next timeouts/intervals.

### `jest.advanceTimersToNextTimerAsync(mode)`

Configures whether timers advance automatically. When 'auto', 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.

:::info

This function is not available when using legacy fake timers implementation.

:::

### `jest.advanceTimersToNextTimerAsync(steps)`

Asynchronous equivalent of `jest.advanceTimersToNextTimer(steps)`. It allows any scheduled promise callbacks to execute _before_ running the timers.
Expand Down
24 changes: 21 additions & 3 deletions packages/jest-environment/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@
*/

import type {Context} from 'vm';
import type {LegacyFakeTimers, ModernFakeTimers} from '@jest/fake-timers';
import type {
LegacyFakeTimers,
ModernFakeTimers,
TimerTickMode,
} from '@jest/fake-timers';
import type {Circus, Config, Global} from '@jest/types';
import type {Mocked, ModuleMocker} from 'jest-mock';

Expand Down Expand Up @@ -82,14 +86,28 @@ export interface Jest {
*/
advanceTimersToNextTimer(steps?: number): void;
/**
* Advances the clock to the the moment of the first scheduled timer, firing it.
* When called with no arguments, advances the clock to the moment of the first
* scheduled timer, firing it.
* Optionally, you can provide steps, so it will run steps amount of
* next timeouts/intervals.
*
* When called with a `TimerTickMode`, either 'manual' or 'auto', updates the
* behavior of the timer advancement without.
*
* When 'automatic', 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.
*
* When 'manual' (the default), timers will not advance automatically. Instead,
* timers must be advanced using APIs such as `advanceTimersToNextTimer`, `advanceTimersByTime`, etc.
*
* @remarks
* Not available when using legacy fake timers implementation.
*/
advanceTimersToNextTimerAsync(steps?: number): Promise<void>;
advanceTimersToNextTimerAsync(
stepsOrTickMode?: number | TimerTickMode,
): Promise<void>;
/**
* Disables automatic mocking in the module loader.
*/
Expand Down
72 changes: 72 additions & 0 deletions packages/jest-fake-timers/src/__tests__/modernFakeTimers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1249,6 +1249,78 @@ describe('FakeTimers', () => {
expect(timers.now()).toBe(200);
expect(spy).toHaveBeenCalled();
});

describe('auto advance', () => {
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.advanceTimersToNextTimerAsync('auto');
});

afterEach(() => {
timers.dispose();
});

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.advanceTimersToNextTimerAsync('manual');
await new Promise(resolve => setTimeout(resolve, 5));
expect(p2Resolved).toBe(false);

timers.advanceTimersToNextTimerAsync('auto');
await new Promise(resolve => setTimeout(resolve, 5));
await expect(p2).resolves.toBe(true);
await expect(p3).resolves.toBeUndefined();
expect(p2Resolved).toBe(true);
});
});
});

describe('runAllTimersAsync', () => {
Expand Down
1 change: 1 addition & 0 deletions packages/jest-fake-timers/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@

export {default as LegacyFakeTimers} from './legacyFakeTimers';
export {default as ModernFakeTimers} from './modernFakeTimers';
export type {TimerTickMode} from './modernFakeTimers';
51 changes: 49 additions & 2 deletions packages/jest-fake-timers/src/modernFakeTimers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,18 @@ import {
import type {Config} from '@jest/types';
import {formatStackTrace} from 'jest-message-util';

export type TimerTickMode = 'manual' | 'auto';

export default class FakeTimers {
private _clock!: InstalledClock;
private readonly _config: Config.ProjectConfig;
private _fakingTime: boolean;
private readonly _global: typeof globalThis;
private readonly _fakeTimers: FakeTimerWithContext;
private autoTickMode: {counter: number; mode: TimerTickMode} = {
counter: 0,
mode: 'manual',
};

constructor({
global,
Expand Down Expand Up @@ -84,9 +90,16 @@ export default class FakeTimers {
}
}

async advanceTimersToNextTimerAsync(steps = 1): Promise<void> {
async advanceTimersToNextTimerAsync(
stepsOrMode: number | TimerTickMode = 1,
): Promise<void> {
if (typeof stepsOrMode === 'string') {
this._setTickMode(stepsOrMode);
return;
}

if (this._checkFakeTimers()) {
for (let i = steps; i > 0; i--) {
for (let i = stepsOrMode; i > 0; i--) {
await this._clock.nextAsync();
// Fire all timers at this point: https://github.com/sinonjs/fake-timers/issues/250
await this._clock.tickAsync(0);
Expand Down Expand Up @@ -142,6 +155,21 @@ export default class FakeTimers {
this._fakingTime = true;
}

private _setTickMode(newMode: TimerTickMode): void {
if (!this._checkFakeTimers()) {
return;
}

if (newMode === this.autoTickMode.mode) {
return;
}

this.autoTickMode = {counter: this.autoTickMode.counter + 1, mode: newMode};
if (newMode === 'auto') {
this._advanceUntilModeChanges();
}
}

reset(): void {
if (this._checkFakeTimers()) {
const {now} = this._clock;
Expand Down Expand Up @@ -224,4 +252,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();
}
}
}
5 changes: 2 additions & 3 deletions packages/jest-runtime/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2322,11 +2322,10 @@ export default class Runtime {
},
advanceTimersToNextTimer: steps =>
_getFakeTimers().advanceTimersToNextTimer(steps),
advanceTimersToNextTimerAsync: async steps => {
advanceTimersToNextTimerAsync: async stepsOrTickMode => {
const fakeTimers = _getFakeTimers();

if (fakeTimers === this._environment.fakeTimersModern) {
await fakeTimers.advanceTimersToNextTimerAsync(steps);
await fakeTimers.advanceTimersToNextTimerAsync(stepsOrTickMode);
} else {
throw new TypeError(
'`jest.advanceTimersToNextTimerAsync()` is not available when using legacy fake timers.',
Expand Down
Loading