Skip to content

Commit 30e6f0f

Browse files
committed
feat(waitFor): Automatically advance Jest fake timers
1 parent 7a6e935 commit 30e6f0f

File tree

2 files changed

+175
-19
lines changed

2 files changed

+175
-19
lines changed

src/__tests__/asyncHook.fakeTimers.test.ts

+38-19
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import * as React from 'react'
2+
13
describe('async hook (fake timers) tests', () => {
24
beforeEach(() => {
35
jest.useFakeTimers()
@@ -20,8 +22,6 @@ describe('async hook (fake timers) tests', () => {
2022

2123
let complete = false
2224

23-
jest.advanceTimersByTime(200)
24-
2525
await waitFor(() => {
2626
expect(actual).toBe(expected)
2727
complete = true
@@ -30,26 +30,45 @@ describe('async hook (fake timers) tests', () => {
3030
expect(complete).toBe(true)
3131
})
3232

33-
test('should wait for arbitrary expectation to pass when using runOnlyPendingTimers()', async () => {
34-
const { waitFor } = renderHook(() => null)
35-
36-
let actual = 0
37-
const expected = 1
38-
39-
setTimeout(() => {
40-
actual = expected
41-
}, 200)
42-
43-
let complete = false
44-
45-
jest.runOnlyPendingTimers()
33+
test('it waits for the data to be loaded using', async () => {
34+
const fetchAMessage = () =>
35+
new Promise((resolve) => {
36+
// we are using random timeout here to simulate a real-time example
37+
// of an async operation calling a callback at a non-deterministic time
38+
const randomTimeout = Math.floor(Math.random() * 100)
39+
setTimeout(() => {
40+
resolve({ returnedMessage: 'Hello World' })
41+
}, randomTimeout)
42+
})
43+
44+
function useLoader() {
45+
const [state, setState] = React.useState<{ data: unknown; loading: boolean }>({
46+
data: undefined,
47+
loading: true
48+
})
49+
React.useEffect(() => {
50+
let cancelled = false
51+
fetchAMessage().then((data) => {
52+
if (!cancelled) {
53+
setState({ data, loading: false })
54+
}
55+
})
56+
57+
return () => {
58+
cancelled = true
59+
}
60+
}, [])
61+
62+
return state
63+
}
64+
65+
const { result, waitFor } = renderHook(() => useLoader())
66+
67+
expect(result.current).toEqual({ data: undefined, loading: true })
4668

4769
await waitFor(() => {
48-
expect(actual).toBe(expected)
49-
complete = true
70+
expect(result.current).toEqual({ data: { returnedMessage: 'Hello World' }, loading: false })
5071
})
51-
52-
expect(complete).toBe(true)
5372
})
5473
})
5574
})

src/core/asyncUtils.ts

+137
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,27 @@ import { TimeoutError } from '../helpers/error'
1313
const DEFAULT_INTERVAL = 50
1414
const DEFAULT_TIMEOUT = 1000
1515

16+
// This is so the stack trace the developer sees is one that's
17+
// closer to their code (because async stack traces are hard to follow).
18+
function copyStackTrace(target: Error, source: Error) {
19+
target.stack = source.stack?.replace(source.message, target.message)
20+
}
21+
22+
function jestFakeTimersAreEnabled() {
23+
/* istanbul ignore else */
24+
if (typeof jest !== 'undefined' && jest !== null) {
25+
return (
26+
// legacy timers
27+
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
28+
(setTimeout as any)._isMockFunction === true ||
29+
// modern timers
30+
Object.prototype.hasOwnProperty.call(setTimeout, 'clock')
31+
)
32+
}
33+
// istanbul ignore next
34+
return false
35+
}
36+
1637
function asyncUtils(act: Act, addResolver: (callback: () => void) => void): AsyncUtils {
1738
const wait = async (callback: () => boolean | void, { interval, timeout }: WaitOptions) => {
1839
const checkResult = () => {
@@ -42,6 +63,113 @@ function asyncUtils(act: Act, addResolver: (callback: () => void) => void): Asyn
4263
return !timeoutSignal.timedOut
4364
}
4465

66+
/**
67+
* `waitFor` implementation from `@testing-library/dom` for Jest fake timers
68+
* @param callback
69+
* @param param1
70+
* @returns
71+
*/
72+
const waitForInJestFakeTimers = (
73+
callback: () => boolean | void,
74+
{
75+
interval,
76+
stackTraceError,
77+
timeout
78+
}: { interval: number; timeout: number; stackTraceError: Error }
79+
) => {
80+
return new Promise(async (resolve, reject) => {
81+
let lastError: unknown, intervalId: number | NodeJS.Timer | undefined
82+
let finished = false
83+
84+
const overallTimeoutTimer = setTimeout(handleTimeout, timeout)
85+
86+
const usingJestFakeTimers = jestFakeTimersAreEnabled()
87+
checkCallback()
88+
// this is a dangerous rule to disable because it could lead to an
89+
// infinite loop. However, eslint isn't smart enough to know that we're
90+
// setting finished inside `onDone` which will be called when we're done
91+
// waiting or when we've timed out.
92+
// eslint-disable-next-line no-unmodified-loop-condition
93+
while (!finished) {
94+
if (!jestFakeTimersAreEnabled()) {
95+
const error = new Error(
96+
`Changed from using fake timers to real timers while using waitFor. This is not allowed and will result in very strange behavior. Please ensure you're awaiting all async things your test is doing before changing to real timers. For more info, please go to https://github.com/testing-library/dom-testing-library/issues/830`
97+
)
98+
copyStackTrace(error, stackTraceError)
99+
reject(error)
100+
return
101+
}
102+
// we *could* (maybe should?) use `advanceTimersToNextTimer` but it's
103+
// possible that could make this loop go on forever if someone is using
104+
// third party code that's setting up recursive timers so rapidly that
105+
// the user's timer's don't get a chance to resolve. So we'll advance
106+
// by an interval instead. (We have a test for this case).
107+
jest.advanceTimersByTime(interval)
108+
109+
// It's really important that checkCallback is run *before* we flush
110+
// in-flight promises. To be honest, I'm not sure why, and I can't quite
111+
// think of a way to reproduce the problem in a test, but I spent
112+
// an entire day banging my head against a wall on this.
113+
checkCallback()
114+
115+
if (finished) {
116+
break
117+
}
118+
119+
// In this rare case, we *need* to wait for in-flight promises
120+
// to resolve before continuing. We don't need to take advantage
121+
// of parallelization so we're fine.
122+
// https://stackoverflow.com/a/59243586/971592
123+
await act(async () => {
124+
await new Promise((r) => {
125+
setTimeout(r, 0)
126+
jest.advanceTimersByTime(0)
127+
})
128+
})
129+
}
130+
131+
function onDone(error: unknown, result: boolean | void | null) {
132+
finished = true
133+
clearTimeout(overallTimeoutTimer)
134+
135+
if (!usingJestFakeTimers) {
136+
// @ts-expect-error -- clearInterval(unknown) is fine at runtime. It's cheaper then doing runtime checks here
137+
clearInterval(intervalId)
138+
}
139+
140+
if (error) {
141+
reject(error)
142+
} else {
143+
resolve(result)
144+
}
145+
}
146+
147+
function checkCallback() {
148+
try {
149+
const result = callback()
150+
151+
onDone(null, result)
152+
153+
// If `callback` throws, wait for the next mutation, interval, or timeout.
154+
} catch (error: unknown) {
155+
// Save the most recent callback error to reject the promise with it in the event of a timeout
156+
lastError = error
157+
}
158+
}
159+
160+
function handleTimeout() {
161+
let error
162+
if (lastError) {
163+
error = lastError
164+
} else {
165+
error = new Error('Timed out in waitFor.')
166+
copyStackTrace(error, stackTraceError)
167+
}
168+
onDone(error, null)
169+
}
170+
})
171+
}
172+
45173
const waitFor = async (
46174
callback: () => boolean | void,
47175
{ interval = DEFAULT_INTERVAL, timeout = DEFAULT_TIMEOUT }: WaitForOptions = {}
@@ -54,6 +182,15 @@ function asyncUtils(act: Act, addResolver: (callback: () => void) => void): Asyn
54182
}
55183
}
56184

185+
if (typeof interval === 'number' && typeof timeout === 'number' && jestFakeTimersAreEnabled()) {
186+
// create the error here so its stack trace is as close to the
187+
// calling code as possible
188+
const stackTraceError = new Error('STACK_TRACE_MESSAGE')
189+
return act(async () => {
190+
await waitForInJestFakeTimers(callback, { interval, stackTraceError, timeout })
191+
})
192+
}
193+
57194
const result = await wait(safeCallback, { interval, timeout })
58195
if (!result && timeout) {
59196
throw new TimeoutError(waitFor, timeout)

0 commit comments

Comments
 (0)