Skip to content

wait and waitForValueToChange async utils #200

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Nov 5, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ Please fill out the information below to expedite the review and (hopefully)
merge of your pull request!
-->


**What**:

<!-- What changes are being made? (What feature/bug is being fixed here?) -->
Expand Down
79 changes: 69 additions & 10 deletions docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ more hooks for testing.
The `props` passed into the callback will be the `initialProps` provided in the `options` to
`renderHook`, unless new props are provided by a subsequent `rerender` call.

### `options`
### `options` (Optional)

An options object to modify the execution of the `callback` function. See the
[`renderHook` Options](/reference/api#renderhook-options) section for more details.
Expand Down Expand Up @@ -69,15 +69,6 @@ The `renderHook` function returns an object that has the following properties:
The `current` value or the `result` will reflect whatever is returned from the `callback` passed to
`renderHook`. Any thrown values will be reflected in the `error` value of the `result`.

### `waitForNextUpdate`

```js
function waitForNextUpdate(): Promise<void>
```

- `waitForNextUpdate` (`function`) - returns a `Promise` that resolves the next time the hook
renders, commonly when state is updated as the result of an asynchronous action.

### `rerender`

```js
Expand All @@ -96,6 +87,11 @@ function unmount(): void
A function to unmount the test component. This is commonly used to trigger cleanup effects for
`useEffect` hooks.

### `...asyncUtils`

Utilities to assist with testing asynchronous behaviour. See the
[Async Utils](/reference/api#async-utilities) section for more details.

---

## `act`
Expand Down Expand Up @@ -147,3 +143,66 @@ of the regular imports.

If neither of these approaches are suitable, setting the `RHTL_SKIP_AUTO_CLEANUP` environment
variable to `true` before importing `@testing-library/react-hooks` will also disable this feature.

---

## Async Utilities

### `waitForNextUpdate`

```js
function waitForNextUpdate(options?: WaitOptions): Promise<void>
```

Returns a `Promise` that resolves the next time the hook renders, commonly when state is updated as
the result of an asynchronous update.

See the [`wait` Options](/reference/api#wait-options) section for more details on the available
`options`.

### `wait`

```js
function wait(callback: function(): boolean|void, options?: WaitOptions): Promise<void>
```

Returns a `Promise` that resolves if the provided callback executes without exception and returns a
truthy or `undefined` value. It is safe to use the [`result` of `renderHook`](/reference/api#result)
in the callback to perform assertion or to test values.

The callback is tested after each render of the hook. By default, errors raised from the callback
will be suppressed (`suppressErrors = true`).

See the [`wait` Options](/reference/api#wait-options) section for more details on the available
`options`.

### `waitForValueToChange`

```js
function waitForValueToChange(selector: function(): any, options?: WaitOptions): Promise<void>
```

Returns a `Promise` that resolves if the value returned from the provided selector changes. It
expected that the [`result` of `renderHook`](/reference/api#result) to select the value for
comparison.

The value is selected for comparison after each render of the hook. By default, errors raised from
selecting the value will not be suppressed (`suppressErrors = false`).

See the [`wait` Options](/reference/api#wait-options) section for more details on the available
`options`.

### `wait` Options

The async utilities accepts the following options:

#### `timeout`

The maximum amount of time in milliseconds (ms) to wait. By default, no timeout is applied.

#### `suppressErrors`

If this option is set to `true`, any errors that occur while waiting are treated as a failed check.
If this option is set to `false`, any errors that occur while waiting cause the promise to be
rejected. Please refer to the [utility descriptions](/reference/api#async-utilities) for the default
values of this option (if applicable).
15 changes: 9 additions & 6 deletions docs/usage/advanced-hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,9 +95,9 @@ you, your team, and your project.
## Async

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

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

For more details on the the other async utilities, please refer to the
[API Reference](/reference/api#async-utilities).

### Suspense

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

## Errors

Expand Down
93 changes: 93 additions & 0 deletions src/asyncUtils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { act } from 'react-test-renderer'

function createTimeoutError(utilName, { timeout }) {
const timeoutError = new Error(`Timed out in ${utilName} after ${timeout}ms.`)
timeoutError.timeout = true
return timeoutError
}

function asyncUtils(addResolver) {
let nextUpdatePromise = null

const waitForNextUpdate = async (options = {}) => {
if (!nextUpdatePromise) {
const resolveOnNextUpdate = (resolve, reject) => {
let timeoutId
if (options.timeout > 0) {
timeoutId = setTimeout(
() => reject(createTimeoutError('waitForNextUpdate', options)),
options.timeout
)
}
addResolver(() => {
clearTimeout(timeoutId)
nextUpdatePromise = null
resolve()
})
}

nextUpdatePromise = new Promise(resolveOnNextUpdate)
await act(() => nextUpdatePromise)
}
return await nextUpdatePromise
}

const wait = async (callback, { timeout, suppressErrors = true } = {}) => {
const checkResult = () => {
try {
const callbackResult = callback()
return callbackResult || callbackResult === undefined
} catch (e) {
if (!suppressErrors) {
throw e
}
}
}

const waitForResult = async () => {
const initialTimeout = timeout
while (true) {
const startTime = Date.now()
try {
await waitForNextUpdate({ timeout })
if (checkResult()) {
return
}
} catch (e) {
if (e.timeout) {
throw createTimeoutError('wait', { timeout: initialTimeout })
}
throw e
}
timeout -= Date.now() - startTime
}
}

if (!checkResult()) {
await waitForResult()
}
}

const waitForValueToChange = async (selector, options = {}) => {
const initialValue = selector()
try {
await wait(() => selector() !== initialValue, {
suppressErrors: false,
...options
})
} catch (e) {
if (e.timeout) {
throw createTimeoutError('waitForValueToChange', options)
}
throw e
}
}

return {
wait,
waitForNextUpdate,
waitForValueToChange
}
}

export default asyncUtils
16 changes: 3 additions & 13 deletions src/pure.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React, { Suspense } from 'react'
import { act, create } from 'react-test-renderer'
import asyncUtils from './asyncUtils'
import { cleanup, addCleanup, removeCleanup } from './cleanup'

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

addCleanup(unmountHook)

let waitingForNextUpdate = null
const resolveOnNextUpdate = (resolve) => {
addResolver((...args) => {
waitingForNextUpdate = null
resolve(...args)
})
}

return {
result,
waitForNextUpdate: () => {
waitingForNextUpdate = waitingForNextUpdate || act(() => new Promise(resolveOnNextUpdate))
return waitingForNextUpdate
},
rerender: (newProps = hookProps.current) => {
hookProps.current = newProps
act(() => {
update(toRender())
})
},
unmount: unmountHook
unmount: unmountHook,
...asyncUtils(addResolver)
}
}

Expand Down
Loading