Skip to content

Commit 82d54fe

Browse files
JiaLiPassionjosephperrott
authored andcommitted
feat(zone.js): add jest fakeTimers support (angular#39016)
Close angular#38851, support `jest` fakeTimers APIs' integration with `fakeAsync()`. After enable this feature, calling `jest.useFakeTimers()` will make all test run into `fakeAsync()` automatically. ``` beforeEach(() => { jest.useFakeTimers('modern'); }); afterEach(() => { jest.useRealTimers(); }); test('should run into fakeAsync() automatically', () => { const fakeAsyncZoneSpec = Zone.current.get('FakeAsyncTestZoneSpec'); expect(fakeAsyncZoneSpec).toBeTruthy(); }); ``` Also there are mappings between `jest` and `zone` APIs. - `jest.runAllTicks()` will call `flushMicrotasks()`. - `jest.runAllTimers()` will call `flush()`. - `jest.advanceTimersByTime()` will call `tick()` - `jest.runOnlyPendingTimers()` will call `flushOnlyPendingTimers()` - `jest.advanceTimersToNextTimer()` will call `tickToNext()` - `jest.clearAllTimers()` will call `removeAllTimers()` - `jest.getTimerCount()` will call `getTimerCount()` PR Close angular#39016
1 parent a48c8ed commit 82d54fe

13 files changed

+4700
-14
lines changed

.circleci/config.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -749,7 +749,8 @@ jobs:
749749
cp dist/bin/packages/zone.js/npm_package/bundles/zone-mix.umd.js ./packages/zone.js/test/extra/ &&
750750
cp dist/bin/packages/zone.js/npm_package/bundles/zone-patch-electron.umd.js ./packages/zone.js/test/extra/ &&
751751
yarn --cwd packages/zone.js electrontest
752-
- run: yarn --cwd packages/zone.js jesttest
752+
- run: yarn --cwd packages/zone.js jest:test
753+
- run: yarn --cwd packages/zone.js jest:nodetest
753754
- run: yarn --cwd packages/zone.js/test/typings install --frozen-lockfile --non-interactive
754755
- run: yarn --cwd packages/zone.js/test/typings test
755756

packages/zone.js/lib/jest/jest.ts

Lines changed: 175 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,13 @@
88

99
'use strict';
1010

11-
Zone.__load_patch('jest', (context: any, Zone: ZoneType) => {
11+
Zone.__load_patch('jest', (context: any, Zone: ZoneType, api: _ZonePrivate) => {
1212
if (typeof jest === 'undefined' || jest['__zone_patch__']) {
1313
return;
1414
}
1515

1616
jest['__zone_patch__'] = true;
1717

18-
19-
if (typeof Zone === 'undefined') {
20-
throw new Error('Missing Zone.js');
21-
}
22-
2318
const ProxyZoneSpec = (Zone as any)['ProxyZoneSpec'];
2419
const SyncTestZoneSpec = (Zone as any)['SyncTestZoneSpec'];
2520

@@ -29,7 +24,8 @@ Zone.__load_patch('jest', (context: any, Zone: ZoneType) => {
2924

3025
const rootZone = Zone.current;
3126
const syncZone = rootZone.fork(new SyncTestZoneSpec('jest.describe'));
32-
const proxyZone = rootZone.fork(new ProxyZoneSpec());
27+
const proxyZoneSpec = new ProxyZoneSpec();
28+
const proxyZone = rootZone.fork(proxyZoneSpec);
3329

3430
function wrapDescribeFactoryInZone(originalJestFn: Function) {
3531
return function(this: unknown, ...tableArgs: any[]) {
@@ -65,11 +61,20 @@ Zone.__load_patch('jest', (context: any, Zone: ZoneType) => {
6561
* execute in a ProxyZone zone.
6662
* This will run in the `proxyZone`.
6763
*/
68-
function wrapTestInZone(testBody: Function): Function {
64+
function wrapTestInZone(testBody: Function, isTestFunc = false): Function {
6965
if (typeof testBody !== 'function') {
7066
return testBody;
7167
}
7268
const wrappedFunc = function() {
69+
if ((Zone as any)[api.symbol('useFakeTimersCalled')] === true && testBody &&
70+
!(testBody as any).isFakeAsync) {
71+
// jest.useFakeTimers is called, run into fakeAsyncTest automatically.
72+
const fakeAsyncModule = (Zone as any)[Zone.__symbol__('fakeAsyncTest')];
73+
if (fakeAsyncModule && typeof fakeAsyncModule.fakeAsync === 'function') {
74+
testBody = fakeAsyncModule.fakeAsync(testBody);
75+
}
76+
}
77+
proxyZoneSpec.isTestFunc = isTestFunc;
7378
return proxyZone.run(testBody, null, arguments as any);
7479
};
7580
// Update the length of wrappedFunc to be the same as the length of the testBody
@@ -102,7 +107,7 @@ Zone.__load_patch('jest', (context: any, Zone: ZoneType) => {
102107
}
103108
context[Zone.__symbol__(methodName)] = originalJestFn;
104109
context[methodName] = function(this: unknown, ...args: any[]) {
105-
args[1] = wrapTestInZone(args[1]);
110+
args[1] = wrapTestInZone(args[1], true);
106111
return originalJestFn.apply(this, args);
107112
};
108113
context[methodName].each = wrapTestFactoryInZone((originalJestFn as any).each);
@@ -125,4 +130,165 @@ Zone.__load_patch('jest', (context: any, Zone: ZoneType) => {
125130
return originalJestFn.apply(this, args);
126131
};
127132
});
133+
134+
(Zone as any).patchJestObject = function patchJestObject(Timer: any, isModern = false) {
135+
// check whether currently the test is inside fakeAsync()
136+
function isPatchingFakeTimer() {
137+
const fakeAsyncZoneSpec = Zone.current.get('FakeAsyncTestZoneSpec');
138+
return !!fakeAsyncZoneSpec;
139+
}
140+
141+
// check whether the current function is inside `test/it` or other methods
142+
// such as `describe/beforeEach`
143+
function isInTestFunc() {
144+
const proxyZoneSpec = Zone.current.get('ProxyZoneSpec');
145+
return proxyZoneSpec && proxyZoneSpec.isTestFunc;
146+
}
147+
148+
if (Timer[api.symbol('fakeTimers')]) {
149+
return;
150+
}
151+
152+
Timer[api.symbol('fakeTimers')] = true;
153+
// patch jest fakeTimer internal method to make sure no console.warn print out
154+
api.patchMethod(Timer, '_checkFakeTimers', delegate => {
155+
return function(self: any, args: any[]) {
156+
if (isPatchingFakeTimer()) {
157+
return true;
158+
} else {
159+
return delegate.apply(self, args);
160+
}
161+
}
162+
});
163+
164+
// patch useFakeTimers(), set useFakeTimersCalled flag, and make test auto run into fakeAsync
165+
api.patchMethod(Timer, 'useFakeTimers', delegate => {
166+
return function(self: any, args: any[]) {
167+
(Zone as any)[api.symbol('useFakeTimersCalled')] = true;
168+
if (isModern || isInTestFunc()) {
169+
return delegate.apply(self, args);
170+
}
171+
return self;
172+
}
173+
});
174+
175+
// patch useRealTimers(), unset useFakeTimers flag
176+
api.patchMethod(Timer, 'useRealTimers', delegate => {
177+
return function(self: any, args: any[]) {
178+
(Zone as any)[api.symbol('useFakeTimersCalled')] = false;
179+
if (isModern || isInTestFunc()) {
180+
return delegate.apply(self, args);
181+
}
182+
return self;
183+
}
184+
});
185+
186+
// patch setSystemTime(), call setCurrentRealTime() in the fakeAsyncTest
187+
api.patchMethod(Timer, 'setSystemTime', delegate => {
188+
return function(self: any, args: any[]) {
189+
const fakeAsyncZoneSpec = Zone.current.get('FakeAsyncTestZoneSpec');
190+
if (fakeAsyncZoneSpec && isPatchingFakeTimer()) {
191+
fakeAsyncZoneSpec.setCurrentRealTime(args[0]);
192+
} else {
193+
return delegate.apply(self, args);
194+
}
195+
}
196+
});
197+
198+
// patch getSystemTime(), call getCurrentRealTime() in the fakeAsyncTest
199+
api.patchMethod(Timer, 'getRealSystemTime', delegate => {
200+
return function(self: any, args: any[]) {
201+
const fakeAsyncZoneSpec = Zone.current.get('FakeAsyncTestZoneSpec');
202+
if (fakeAsyncZoneSpec && isPatchingFakeTimer()) {
203+
return fakeAsyncZoneSpec.getCurrentRealTime(args[0]);
204+
} else {
205+
return delegate.apply(self, args);
206+
}
207+
}
208+
});
209+
210+
// patch runAllTicks(), run all microTasks inside fakeAsync
211+
api.patchMethod(Timer, 'runAllTicks', delegate => {
212+
return function(self: any, args: any[]) {
213+
const fakeAsyncZoneSpec = Zone.current.get('FakeAsyncTestZoneSpec');
214+
if (fakeAsyncZoneSpec) {
215+
fakeAsyncZoneSpec.flushMicrotasks();
216+
} else {
217+
return delegate.apply(self, args);
218+
}
219+
}
220+
});
221+
222+
// patch runAllTimers(), run all macroTasks inside fakeAsync
223+
api.patchMethod(Timer, 'runAllTimers', delegate => {
224+
return function(self: any, args: any[]) {
225+
const fakeAsyncZoneSpec = Zone.current.get('FakeAsyncTestZoneSpec');
226+
if (fakeAsyncZoneSpec) {
227+
fakeAsyncZoneSpec.flush(100, true);
228+
} else {
229+
return delegate.apply(self, args);
230+
}
231+
}
232+
});
233+
234+
// patch advanceTimersByTime(), call tick() in the fakeAsyncTest
235+
api.patchMethod(Timer, 'advanceTimersByTime', delegate => {
236+
return function(self: any, args: any[]) {
237+
const fakeAsyncZoneSpec = Zone.current.get('FakeAsyncTestZoneSpec');
238+
if (fakeAsyncZoneSpec) {
239+
fakeAsyncZoneSpec.tick(args[0]);
240+
} else {
241+
return delegate.apply(self, args);
242+
}
243+
}
244+
});
245+
246+
// patch runOnlyPendingTimers(), call flushOnlyPendingTimers() in the fakeAsyncTest
247+
api.patchMethod(Timer, 'runOnlyPendingTimers', delegate => {
248+
return function(self: any, args: any[]) {
249+
const fakeAsyncZoneSpec = Zone.current.get('FakeAsyncTestZoneSpec');
250+
if (fakeAsyncZoneSpec) {
251+
fakeAsyncZoneSpec.flushOnlyPendingTimers();
252+
} else {
253+
return delegate.apply(self, args);
254+
}
255+
}
256+
});
257+
258+
// patch advanceTimersToNextTimer(), call tickToNext() in the fakeAsyncTest
259+
api.patchMethod(Timer, 'advanceTimersToNextTimer', delegate => {
260+
return function(self: any, args: any[]) {
261+
const fakeAsyncZoneSpec = Zone.current.get('FakeAsyncTestZoneSpec');
262+
if (fakeAsyncZoneSpec) {
263+
fakeAsyncZoneSpec.tickToNext(args[0]);
264+
} else {
265+
return delegate.apply(self, args);
266+
}
267+
}
268+
});
269+
270+
// patch clearAllTimers(), call removeAllTimers() in the fakeAsyncTest
271+
api.patchMethod(Timer, 'clearAllTimers', delegate => {
272+
return function(self: any, args: any[]) {
273+
const fakeAsyncZoneSpec = Zone.current.get('FakeAsyncTestZoneSpec');
274+
if (fakeAsyncZoneSpec) {
275+
fakeAsyncZoneSpec.removeAllTimers();
276+
} else {
277+
return delegate.apply(self, args);
278+
}
279+
}
280+
});
281+
282+
// patch getTimerCount(), call getTimerCount() in the fakeAsyncTest
283+
api.patchMethod(Timer, 'getTimerCount', delegate => {
284+
return function(self: any, args: any[]) {
285+
const fakeAsyncZoneSpec = Zone.current.get('FakeAsyncTestZoneSpec');
286+
if (fakeAsyncZoneSpec) {
287+
return fakeAsyncZoneSpec.getTimerCount();
288+
} else {
289+
return delegate.apply(self, args);
290+
}
291+
}
292+
});
293+
}
128294
});

packages/zone.js/lib/testing/fake-async.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ Zone.__load_patch('fakeasync', (global: any, Zone: ZoneType, api: _ZonePrivate)
5252
*/
5353
function fakeAsync(fn: Function): (...args: any[]) => any {
5454
// Not using an arrow function to preserve context passed from call site
55-
return function(this: unknown, ...args: any[]) {
55+
const fakeAsyncFn: any = function(this: unknown, ...args: any[]) {
5656
const proxyZoneSpec = ProxyZoneSpec.assertPresent();
5757
if (Zone.current.get('FakeAsyncTestZoneSpec')) {
5858
throw new Error('fakeAsync() calls can not be nested');
@@ -93,6 +93,8 @@ Zone.__load_patch('fakeasync', (global: any, Zone: ZoneType, api: _ZonePrivate)
9393
resetFakeAsyncZone();
9494
}
9595
};
96+
(fakeAsyncFn as any).isFakeAsync = true;
97+
return fakeAsyncFn;
9698
}
9799

98100
function _getFakeAsyncZoneSpec(): any {

packages/zone.js/lib/zone-spec/fake-async-test.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,10 @@ class Scheduler {
9292
this._currentRealTime = realTime;
9393
}
9494

95+
getRealSystemTime() {
96+
return OriginalDate.now();
97+
}
98+
9599
scheduleFunction(cb: Function, delay: number, options?: {
96100
args?: any[],
97101
isPeriodic?: boolean,
@@ -145,6 +149,27 @@ class Scheduler {
145149
}
146150
}
147151

152+
removeAll(): void {
153+
this._schedulerQueue = [];
154+
}
155+
156+
getTimerCount(): number {
157+
return this._schedulerQueue.length;
158+
}
159+
160+
tickToNext(step: number = 1, doTick?: (elapsed: number) => void, tickOptions?: {
161+
processNewMacroTasksSynchronously: boolean
162+
}) {
163+
if (this._schedulerQueue.length < step) {
164+
return;
165+
}
166+
// Find the last task currently queued in the scheduler queue and tick
167+
// till that time.
168+
const startTime = this._currentTime;
169+
const targetTask = this._schedulerQueue[step - 1];
170+
this.tick(targetTask.endTime - startTime, doTick, tickOptions);
171+
}
172+
148173
tick(millis: number = 0, doTick?: (elapsed: number) => void, tickOptions?: {
149174
processNewMacroTasksSynchronously: boolean
150175
}): void {
@@ -212,6 +237,18 @@ class Scheduler {
212237
}
213238
}
214239

240+
flushOnlyPendingTimers(doTick?: (elapsed: number) => void): number {
241+
if (this._schedulerQueue.length === 0) {
242+
return 0;
243+
}
244+
// Find the last task currently queued in the scheduler queue and tick
245+
// till that time.
246+
const startTime = this._currentTime;
247+
const lastTask = this._schedulerQueue[this._schedulerQueue.length - 1];
248+
this.tick(lastTask.endTime - startTime, doTick, {processNewMacroTasksSynchronously: false});
249+
return this._currentTime - startTime;
250+
}
251+
215252
flush(limit = 20, flushPeriodic = false, doTick?: (elapsed: number) => void): number {
216253
if (flushPeriodic) {
217254
return this.flushPeriodic(doTick);
@@ -401,6 +438,10 @@ class FakeAsyncTestZoneSpec implements ZoneSpec {
401438
this._scheduler.setCurrentRealTime(realTime);
402439
}
403440

441+
getRealSystemTime() {
442+
return this._scheduler.getRealSystemTime();
443+
}
444+
404445
static patchDate() {
405446
if (!!global[Zone.__symbol__('disableDatePatching')]) {
406447
// we don't want to patch global Date
@@ -450,6 +491,20 @@ class FakeAsyncTestZoneSpec implements ZoneSpec {
450491
FakeAsyncTestZoneSpec.resetDate();
451492
}
452493

494+
tickToNext(steps: number = 1, doTick?: (elapsed: number) => void, tickOptions: {
495+
processNewMacroTasksSynchronously: boolean
496+
} = {processNewMacroTasksSynchronously: true}): void {
497+
if (steps <= 0) {
498+
return;
499+
}
500+
FakeAsyncTestZoneSpec.assertInZone();
501+
this.flushMicrotasks();
502+
this._scheduler.tickToNext(steps, doTick, tickOptions);
503+
if (this._lastError !== null) {
504+
this._resetLastErrorAndThrow();
505+
}
506+
}
507+
453508
tick(millis: number = 0, doTick?: (elapsed: number) => void, tickOptions: {
454509
processNewMacroTasksSynchronously: boolean
455510
} = {processNewMacroTasksSynchronously: true}): void {
@@ -486,6 +541,27 @@ class FakeAsyncTestZoneSpec implements ZoneSpec {
486541
return elapsed;
487542
}
488543

544+
flushOnlyPendingTimers(doTick?: (elapsed: number) => void): number {
545+
FakeAsyncTestZoneSpec.assertInZone();
546+
this.flushMicrotasks();
547+
const elapsed = this._scheduler.flushOnlyPendingTimers(doTick);
548+
if (this._lastError !== null) {
549+
this._resetLastErrorAndThrow();
550+
}
551+
return elapsed;
552+
}
553+
554+
removeAllTimers() {
555+
FakeAsyncTestZoneSpec.assertInZone();
556+
this._scheduler.removeAll();
557+
this.pendingPeriodicTimers = [];
558+
this.pendingTimers = [];
559+
}
560+
561+
getTimerCount() {
562+
return this._scheduler.getTimerCount() + this._microtasks.length;
563+
}
564+
489565
// ZoneSpec implementation below.
490566

491567
name: string;

packages/zone.js/package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,16 @@
1313
"devDependencies": {
1414
"@types/node": "^10.9.4",
1515
"domino": "2.1.2",
16-
"jest": "^25.1.0",
16+
"jest": "^26.4",
1717
"mocha": "^3.1.2",
1818
"mock-require": "3.0.3",
1919
"promises-aplus-tests": "^2.1.2",
2020
"typescript": "4.0.2"
2121
},
2222
"scripts": {
2323
"electrontest": "cd test/extra && node electron.js",
24-
"jesttest": "jest --config ./test/jest/jest.config.js ./test/jest/jest.spec.js",
24+
"jest:test": "jest --config ./test/jest/jest.config.js ./test/jest/jest.spec.js",
25+
"jest:nodetest": "jest --config ./test/jest/jest.node.config.js ./test/jest/jest.spec.js",
2526
"promisetest": "tsc -p . && node ./test/promise/promise-test.js",
2627
"promisefinallytest": "tsc -p . && mocha ./test/promise/promise.finally.spec.js"
2728
},
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
const exportFakeTimersToSandboxGlobal = function(jestEnv) {
2+
jestEnv.global.legacyFakeTimers = jestEnv.fakeTimers;
3+
jestEnv.global.modernFakeTimers = jestEnv.fakeTimersModern;
4+
};
5+
6+
module.exports = exportFakeTimersToSandboxGlobal;

0 commit comments

Comments
 (0)