Skip to content

Commit 2ccec3c

Browse files
mikeduminythymikee
andauthored
fix(breaking): use real timers internally to fix awaiting with fake timers (#568)
* capture global timers, use them in waitFor, tests+ * use real timers in flushMicroTasks * test to show changes with modern timer mocks * use enum-like object for timer modes * fix tests * remove default timer mode, add flow types export other timer helpers, adjust tests and docs * base waitFor on RTL implementation * [jest] Capture and restore global promise * [waitFor] Simulate intervals when using fake timers * [tests] Fix waitForElementToBeRemoved * cleanups; use more specific global * rename globals to match others Co-authored-by: Michael James Duminy <[email protected]> Co-authored-by: Michał Pierzchała <[email protected]>
1 parent 77a9e2e commit 2ccec3c

12 files changed

+373
-79
lines changed

jest/preset.js

+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
const reactNativePreset = require('react-native/jest-preset');
2+
3+
module.exports = {
4+
...reactNativePreset,
5+
// this is needed to make modern fake timers work
6+
// because the react-native preset overrides global.Promise
7+
setupFiles: [require.resolve('./save-promise.js')]
8+
.concat(reactNativePreset.setupFiles)
9+
.concat([require.resolve('./restore-promise.js')]),
10+
};

jest/restore-promise.js

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
global.Promise = global.RNTL_ORIGINAL_PROMISE;

jest/save-promise.js

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
global.RNTL_ORIGINAL_PROMISE = Promise;

package.json

+3-2
Original file line numberDiff line numberDiff line change
@@ -70,11 +70,12 @@
7070
"build": "rm -rf build; babel src --out-dir build --ignore 'src/__tests__/*'"
7171
},
7272
"jest": {
73-
"preset": "react-native",
73+
"preset": "../jest/preset.js",
7474
"moduleFileExtensions": [
7575
"js",
7676
"json"
7777
],
78-
"rootDir": "./src"
78+
"rootDir": "./src",
79+
"testPathIgnorePatterns": ["timerUtils"]
7980
}
8081
}

src/__tests__/timerUtils.js

+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
// @flow
2+
3+
import { setTimeout } from '../helpers/timers';
4+
5+
const TimerMode = {
6+
Legacy: 'legacy',
7+
Modern: 'modern', // broken for now
8+
};
9+
10+
async function sleep(ms: number): Promise<void> {
11+
return new Promise((resolve) => setTimeout(resolve, ms));
12+
}
13+
14+
export { TimerMode, sleep };

src/__tests__/timers.test.js

+29
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
// @flow
2+
import waitFor from '../waitFor';
3+
import { TimerMode } from './timerUtils';
4+
5+
describe.each([TimerMode.Legacy, TimerMode.Modern])(
6+
'%s fake timers tests',
7+
(fakeTimerType) => {
8+
beforeEach(() => {
9+
jest.useFakeTimers(fakeTimerType);
10+
});
11+
12+
test('it successfully runs tests', () => {
13+
expect(true).toBeTruthy();
14+
});
15+
16+
test('it successfully uses waitFor', async () => {
17+
await waitFor(() => {
18+
expect(true).toBeTruthy();
19+
});
20+
});
21+
22+
test('it successfully uses waitFor with real timers', async () => {
23+
jest.useRealTimers();
24+
await waitFor(() => {
25+
expect(true).toBeTruthy();
26+
});
27+
});
28+
}
29+
);

src/__tests__/waitFor.test.js

+53-28
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
// @flow
22
import * as React from 'react';
3-
import { View, Text, TouchableOpacity } from 'react-native';
4-
import { render, fireEvent, waitFor } from '..';
3+
import { Text, TouchableOpacity, View } from 'react-native';
4+
import { fireEvent, render, waitFor } from '..';
5+
import { TimerMode } from './timerUtils';
56

67
class Banana extends React.Component<any> {
78
changeFresh = () => {
@@ -76,39 +77,63 @@ test('waits for element with custom interval', async () => {
7677
// suppress
7778
}
7879

79-
expect(mockFn).toHaveBeenCalledTimes(3);
80+
expect(mockFn).toHaveBeenCalledTimes(2);
8081
});
8182

82-
test('works with legacy fake timers', async () => {
83-
jest.useFakeTimers('legacy');
83+
test.each([TimerMode.Legacy, TimerMode.Modern])(
84+
'waits for element until it stops throwing using %s fake timers',
85+
async (fakeTimerType) => {
86+
jest.useFakeTimers(fakeTimerType);
87+
const { getByText, queryByText } = render(<BananaContainer />);
8488

85-
const mockFn = jest.fn(() => {
86-
throw Error('test');
87-
});
89+
fireEvent.press(getByText('Change freshness!'));
90+
expect(queryByText('Fresh')).toBeNull();
8891

89-
try {
90-
waitFor(() => mockFn(), { timeout: 400, interval: 200 });
91-
} catch (e) {
92-
// suppress
92+
jest.advanceTimersByTime(300);
93+
const freshBananaText = await waitFor(() => getByText('Fresh'));
94+
95+
expect(freshBananaText.props.children).toBe('Fresh');
9396
}
94-
jest.advanceTimersByTime(400);
97+
);
9598

96-
expect(mockFn).toHaveBeenCalledTimes(3);
97-
});
99+
test.each([TimerMode.Legacy, TimerMode.Modern])(
100+
'waits for assertion until timeout is met with %s fake timers',
101+
async (fakeTimerType) => {
102+
jest.useFakeTimers(fakeTimerType);
98103

99-
test('works with fake timers', async () => {
100-
jest.useFakeTimers('modern');
104+
const mockFn = jest.fn(() => {
105+
throw Error('test');
106+
});
101107

102-
const mockFn = jest.fn(() => {
103-
throw Error('test');
104-
});
108+
try {
109+
await waitFor(() => mockFn(), { timeout: 400, interval: 200 });
110+
} catch (error) {
111+
// suppress
112+
}
105113

106-
try {
107-
waitFor(() => mockFn(), { timeout: 400, interval: 200 });
108-
} catch (e) {
109-
// suppress
114+
expect(mockFn).toHaveBeenCalledTimes(3);
110115
}
111-
jest.advanceTimersByTime(400);
112-
113-
expect(mockFn).toHaveBeenCalledTimes(3);
114-
});
116+
);
117+
118+
test.each([TimerMode.Legacy, TimerMode.Legacy])(
119+
'awaiting something that succeeds before timeout works with %s fake timers',
120+
async (fakeTimerType) => {
121+
jest.useFakeTimers(fakeTimerType);
122+
123+
let calls = 0;
124+
const mockFn = jest.fn(() => {
125+
calls += 1;
126+
if (calls < 3) {
127+
throw Error('test');
128+
}
129+
});
130+
131+
try {
132+
await waitFor(() => mockFn(), { timeout: 400, interval: 200 });
133+
} catch (error) {
134+
// suppress
135+
}
136+
137+
expect(mockFn).toHaveBeenCalledTimes(3);
138+
}
139+
);

src/__tests__/waitForElementToBeRemoved.test.js

+19-25
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import React, { useState } from 'react';
33
import { View, Text, TouchableOpacity } from 'react-native';
44
import { render, fireEvent, waitForElementToBeRemoved } from '..';
5+
import { TimerMode } from './timerUtils';
56

67
const TestSetup = ({ shouldUseDelay = true }) => {
78
const [isAdded, setIsAdded] = useState(true);
@@ -120,7 +121,7 @@ test('waits with custom interval', async () => {
120121

121122
try {
122123
await waitForElementToBeRemoved(() => mockFn(), {
123-
timeout: 400,
124+
timeout: 600,
124125
interval: 200,
125126
});
126127
} catch (e) {
@@ -130,30 +131,23 @@ test('waits with custom interval', async () => {
130131
expect(mockFn).toHaveBeenCalledTimes(4);
131132
});
132133

133-
test('works with legacy fake timers', async () => {
134-
jest.useFakeTimers('legacy');
134+
test.each([TimerMode.Legacy, TimerMode.Modern])(
135+
'works with %s fake timers',
136+
async (fakeTimerType) => {
137+
jest.useFakeTimers(fakeTimerType);
135138

136-
const mockFn = jest.fn(() => <View />);
137-
138-
waitForElementToBeRemoved(() => mockFn(), {
139-
timeout: 400,
140-
interval: 200,
141-
});
142-
143-
jest.advanceTimersByTime(400);
144-
expect(mockFn).toHaveBeenCalledTimes(4);
145-
});
146-
147-
test('works with fake timers', async () => {
148-
jest.useFakeTimers('modern');
139+
const mockFn = jest.fn(() => <View />);
149140

150-
const mockFn = jest.fn(() => <View />);
151-
152-
waitForElementToBeRemoved(() => mockFn(), {
153-
timeout: 400,
154-
interval: 200,
155-
});
141+
try {
142+
await waitForElementToBeRemoved(() => mockFn(), {
143+
timeout: 400,
144+
interval: 200,
145+
});
146+
} catch (e) {
147+
// Suppress expected error
148+
}
156149

157-
jest.advanceTimersByTime(400);
158-
expect(mockFn).toHaveBeenCalledTimes(4);
159-
});
150+
// waitForElementToBeRemoved runs an initial call of the expectation
151+
expect(mockFn).toHaveBeenCalledTimes(4);
152+
}
153+
);

src/flushMicroTasks.js

+1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
// @flow
22
import { printDeprecationWarning } from './helpers/errors';
3+
import { setImmediate } from './helpers/timers';
34

45
type Thenable<T> = { then: (() => T) => mixed };
56

src/helpers/timers.js

+88
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
// Most content of this file sourced directly from https://github.com/testing-library/dom-testing-library/blob/master/src/helpers.js
2+
// @flow
3+
/* globals jest */
4+
5+
const globalObj = typeof window === 'undefined' ? global : window;
6+
7+
// Currently this fn only supports jest timers, but it could support other test runners in the future.
8+
function runWithRealTimers<T>(callback: () => T): T {
9+
const fakeTimersType = getJestFakeTimersType();
10+
if (fakeTimersType) {
11+
jest.useRealTimers();
12+
}
13+
14+
const callbackReturnValue = callback();
15+
16+
if (fakeTimersType) {
17+
jest.useFakeTimers(fakeTimersType);
18+
}
19+
20+
return callbackReturnValue;
21+
}
22+
23+
function getJestFakeTimersType() {
24+
// istanbul ignore if
25+
if (
26+
typeof jest === 'undefined' ||
27+
typeof globalObj.setTimeout === 'undefined'
28+
) {
29+
return null;
30+
}
31+
32+
if (
33+
typeof globalObj.setTimeout._isMockFunction !== 'undefined' &&
34+
globalObj.setTimeout._isMockFunction
35+
) {
36+
return 'legacy';
37+
}
38+
39+
if (
40+
typeof globalObj.setTimeout.clock !== 'undefined' &&
41+
// $FlowIgnore[prop-missing]
42+
typeof jest.getRealSystemTime !== 'undefined'
43+
) {
44+
try {
45+
// jest.getRealSystemTime is only supported for Jest's `modern` fake timers and otherwise throws
46+
// $FlowExpectedError
47+
jest.getRealSystemTime();
48+
return 'modern';
49+
} catch {
50+
// not using Jest's modern fake timers
51+
}
52+
}
53+
return null;
54+
}
55+
56+
const jestFakeTimersAreEnabled = (): boolean =>
57+
Boolean(getJestFakeTimersType());
58+
59+
// we only run our tests in node, and setImmediate is supported in node.
60+
function setImmediatePolyfill(fn) {
61+
return globalObj.setTimeout(fn, 0);
62+
}
63+
64+
type BindTimeFunctions = {
65+
clearTimeoutFn: typeof clearTimeout,
66+
setImmediateFn: typeof setImmediate,
67+
setTimeoutFn: typeof setTimeout,
68+
};
69+
70+
function bindTimeFunctions(): BindTimeFunctions {
71+
return {
72+
clearTimeoutFn: globalObj.clearTimeout,
73+
setImmediateFn: globalObj.setImmediate || setImmediatePolyfill,
74+
setTimeoutFn: globalObj.setTimeout,
75+
};
76+
}
77+
78+
const { clearTimeoutFn, setImmediateFn, setTimeoutFn } = (runWithRealTimers(
79+
bindTimeFunctions
80+
): BindTimeFunctions);
81+
82+
export {
83+
runWithRealTimers,
84+
jestFakeTimersAreEnabled,
85+
clearTimeoutFn as clearTimeout,
86+
setImmediateFn as setImmediate,
87+
setTimeoutFn as setTimeout,
88+
};

0 commit comments

Comments
 (0)