Skip to content

Commit 38124fa

Browse files
authored
Merge pull request #200 from testing-library/pr/async-utils
wait and waitForValueToChange async utils
2 parents 82ff129 + 66a49f7 commit 38124fa

File tree

6 files changed

+360
-57
lines changed

6 files changed

+360
-57
lines changed

.github/PULL_REQUEST_TEMPLATE.md

-1
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ Please fill out the information below to expedite the review and (hopefully)
1414
merge of your pull request!
1515
-->
1616

17-
1817
**What**:
1918

2019
<!-- What changes are being made? (What feature/bug is being fixed here?) -->

docs/api-reference.md

+69-10
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ more hooks for testing.
3535
The `props` passed into the callback will be the `initialProps` provided in the `options` to
3636
`renderHook`, unless new props are provided by a subsequent `rerender` call.
3737

38-
### `options`
38+
### `options` (Optional)
3939

4040
An options object to modify the execution of the `callback` function. See the
4141
[`renderHook` Options](/reference/api#renderhook-options) section for more details.
@@ -69,15 +69,6 @@ The `renderHook` function returns an object that has the following properties:
6969
The `current` value or the `result` will reflect whatever is returned from the `callback` passed to
7070
`renderHook`. Any thrown values will be reflected in the `error` value of the `result`.
7171
72-
### `waitForNextUpdate`
73-
74-
```js
75-
function waitForNextUpdate(): Promise<void>
76-
```
77-
78-
- `waitForNextUpdate` (`function`) - returns a `Promise` that resolves the next time the hook
79-
renders, commonly when state is updated as the result of an asynchronous action.
80-
8172
### `rerender`
8273
8374
```js
@@ -96,6 +87,11 @@ function unmount(): void
9687
A function to unmount the test component. This is commonly used to trigger cleanup effects for
9788
`useEffect` hooks.
9889

90+
### `...asyncUtils`
91+
92+
Utilities to assist with testing asynchronous behaviour. See the
93+
[Async Utils](/reference/api#async-utilities) section for more details.
94+
9995
---
10096

10197
## `act`
@@ -147,3 +143,66 @@ of the regular imports.
147143
148144
If neither of these approaches are suitable, setting the `RHTL_SKIP_AUTO_CLEANUP` environment
149145
variable to `true` before importing `@testing-library/react-hooks` will also disable this feature.
146+
147+
---
148+
149+
## Async Utilities
150+
151+
### `waitForNextUpdate`
152+
153+
```js
154+
function waitForNextUpdate(options?: WaitOptions): Promise<void>
155+
```
156+
157+
Returns a `Promise` that resolves the next time the hook renders, commonly when state is updated as
158+
the result of an asynchronous update.
159+
160+
See the [`wait` Options](/reference/api#wait-options) section for more details on the available
161+
`options`.
162+
163+
### `wait`
164+
165+
```js
166+
function wait(callback: function(): boolean|void, options?: WaitOptions): Promise<void>
167+
```
168+
169+
Returns a `Promise` that resolves if the provided callback executes without exception and returns a
170+
truthy or `undefined` value. It is safe to use the [`result` of `renderHook`](/reference/api#result)
171+
in the callback to perform assertion or to test values.
172+
173+
The callback is tested after each render of the hook. By default, errors raised from the callback
174+
will be suppressed (`suppressErrors = true`).
175+
176+
See the [`wait` Options](/reference/api#wait-options) section for more details on the available
177+
`options`.
178+
179+
### `waitForValueToChange`
180+
181+
```js
182+
function waitForValueToChange(selector: function(): any, options?: WaitOptions): Promise<void>
183+
```
184+
185+
Returns a `Promise` that resolves if the value returned from the provided selector changes. It
186+
expected that the [`result` of `renderHook`](/reference/api#result) to select the value for
187+
comparison.
188+
189+
The value is selected for comparison after each render of the hook. By default, errors raised from
190+
selecting the value will not be suppressed (`suppressErrors = false`).
191+
192+
See the [`wait` Options](/reference/api#wait-options) section for more details on the available
193+
`options`.
194+
195+
### `wait` Options
196+
197+
The async utilities accepts the following options:
198+
199+
#### `timeout`
200+
201+
The maximum amount of time in milliseconds (ms) to wait. By default, no timeout is applied.
202+
203+
#### `suppressErrors`
204+
205+
If this option is set to `true`, any errors that occur while waiting are treated as a failed check.
206+
If this option is set to `false`, any errors that occur while waiting cause the promise to be
207+
rejected. Please refer to the [utility descriptions](/reference/api#async-utilities) for the default
208+
values of this option (if applicable).

docs/usage/advanced-hooks.md

+9-6
Original file line numberDiff line numberDiff line change
@@ -95,9 +95,9 @@ you, your team, and your project.
9595
## Async
9696

9797
Sometimes, a hook can trigger asynchronous updates that will not be immediately reflected in the
98-
`result.current` value. Luckily, `renderHook` returns a utility that allows the test to wait for the
99-
hook to update using `async/await` (or just promise callbacks if you prefer) called
100-
`waitForNextUpdate`.
98+
`result.current` value. Luckily, `renderHook` returns some utilities that allows the test to wait
99+
for the hook to update using `async/await` (or just promise callbacks if you prefer). The most basic
100+
async utility is called `waitForNextUpdate`.
101101

102102
Let's further extend `useCounter` to have an `incrementAsync` callback that will update the `count`
103103
after `100ms`:
@@ -132,11 +132,14 @@ test('should increment counter after delay', async () => {
132132
})
133133
```
134134

135+
For more details on the the other async utilities, please refer to the
136+
[API Reference](/reference/api#async-utilities).
137+
135138
### Suspense
136139

137-
`waitForNextUpdate` will also wait for hooks that suspends using
138-
[React's `Suspense`](https://reactjs.org/docs/code-splitting.html#suspense) functionality finish
139-
rendering.
140+
All the [async utilities](/reference/api#async-utilities) will also wait for hooks that suspends
141+
using [React's `Suspense`](https://reactjs.org/docs/code-splitting.html#suspense) functionality to
142+
complete rendering.
140143

141144
## Errors
142145

src/asyncUtils.js

+93
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { act } from 'react-test-renderer'
2+
3+
function createTimeoutError(utilName, { timeout }) {
4+
const timeoutError = new Error(`Timed out in ${utilName} after ${timeout}ms.`)
5+
timeoutError.timeout = true
6+
return timeoutError
7+
}
8+
9+
function asyncUtils(addResolver) {
10+
let nextUpdatePromise = null
11+
12+
const waitForNextUpdate = async (options = {}) => {
13+
if (!nextUpdatePromise) {
14+
const resolveOnNextUpdate = (resolve, reject) => {
15+
let timeoutId
16+
if (options.timeout > 0) {
17+
timeoutId = setTimeout(
18+
() => reject(createTimeoutError('waitForNextUpdate', options)),
19+
options.timeout
20+
)
21+
}
22+
addResolver(() => {
23+
clearTimeout(timeoutId)
24+
nextUpdatePromise = null
25+
resolve()
26+
})
27+
}
28+
29+
nextUpdatePromise = new Promise(resolveOnNextUpdate)
30+
await act(() => nextUpdatePromise)
31+
}
32+
return await nextUpdatePromise
33+
}
34+
35+
const wait = async (callback, { timeout, suppressErrors = true } = {}) => {
36+
const checkResult = () => {
37+
try {
38+
const callbackResult = callback()
39+
return callbackResult || callbackResult === undefined
40+
} catch (e) {
41+
if (!suppressErrors) {
42+
throw e
43+
}
44+
}
45+
}
46+
47+
const waitForResult = async () => {
48+
const initialTimeout = timeout
49+
while (true) {
50+
const startTime = Date.now()
51+
try {
52+
await waitForNextUpdate({ timeout })
53+
if (checkResult()) {
54+
return
55+
}
56+
} catch (e) {
57+
if (e.timeout) {
58+
throw createTimeoutError('wait', { timeout: initialTimeout })
59+
}
60+
throw e
61+
}
62+
timeout -= Date.now() - startTime
63+
}
64+
}
65+
66+
if (!checkResult()) {
67+
await waitForResult()
68+
}
69+
}
70+
71+
const waitForValueToChange = async (selector, options = {}) => {
72+
const initialValue = selector()
73+
try {
74+
await wait(() => selector() !== initialValue, {
75+
suppressErrors: false,
76+
...options
77+
})
78+
} catch (e) {
79+
if (e.timeout) {
80+
throw createTimeoutError('waitForValueToChange', options)
81+
}
82+
throw e
83+
}
84+
}
85+
86+
return {
87+
wait,
88+
waitForNextUpdate,
89+
waitForValueToChange
90+
}
91+
}
92+
93+
export default asyncUtils

src/pure.js

+3-13
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import React, { Suspense } from 'react'
22
import { act, create } from 'react-test-renderer'
3+
import asyncUtils from './asyncUtils'
34
import { cleanup, addCleanup, removeCleanup } from './cleanup'
45

56
function TestHook({ callback, hookProps, onError, children }) {
@@ -83,27 +84,16 @@ function renderHook(callback, { initialProps, wrapper } = {}) {
8384

8485
addCleanup(unmountHook)
8586

86-
let waitingForNextUpdate = null
87-
const resolveOnNextUpdate = (resolve) => {
88-
addResolver((...args) => {
89-
waitingForNextUpdate = null
90-
resolve(...args)
91-
})
92-
}
93-
9487
return {
9588
result,
96-
waitForNextUpdate: () => {
97-
waitingForNextUpdate = waitingForNextUpdate || act(() => new Promise(resolveOnNextUpdate))
98-
return waitingForNextUpdate
99-
},
10089
rerender: (newProps = hookProps.current) => {
10190
hookProps.current = newProps
10291
act(() => {
10392
update(toRender())
10493
})
10594
},
106-
unmount: unmountHook
95+
unmount: unmountHook,
96+
...asyncUtils(addResolver)
10797
}
10898
}
10999

0 commit comments

Comments
 (0)