Skip to content

Commit

Permalink
feat(jest-fake-timers): Add feature to enable automatically advancing…
Browse files Browse the repository at this point in the history
… 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.
  • Loading branch information
atscott committed Sep 12, 2024
1 parent bd1c6db commit de306d5
Show file tree
Hide file tree
Showing 5 changed files with 143 additions and 0 deletions.
15 changes: 15 additions & 0 deletions docs/JestObjectAPI.md
Original file line number Diff line number Diff line change
Expand Up @@ -1067,10 +1067,25 @@ 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()`.
Expand Down
10 changes: 10 additions & 0 deletions packages/jest-environment/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,16 @@ export interface Jest {
* Not available when using legacy fake timers implementation.
*/
advanceTimersToNextTimerAsync(steps?: number): Promise<void>;
/**
* 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.
*/
Expand Down
68 changes: 68 additions & 0 deletions packages/jest-fake-timers/src/__tests__/modernFakeTimers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
39 changes: 39 additions & 0 deletions packages/jest-fake-timers/src/modernFakeTimers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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();
}
}
}
11 changes: 11 additions & 0 deletions packages/jest-runtime/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down

0 comments on commit de306d5

Please sign in to comment.