diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 1b3383dc..e9524ba1 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -14,7 +14,6 @@ Please fill out the information below to expedite the review and (hopefully) merge of your pull request! --> - **What**: diff --git a/docs/api-reference.md b/docs/api-reference.md index e4334878..1bb02705 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -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. @@ -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 -``` - -- `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 @@ -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` @@ -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 +``` + +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 +``` + +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 +``` + +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). diff --git a/docs/usage/advanced-hooks.md b/docs/usage/advanced-hooks.md index fd7abf1e..cb5bdceb 100644 --- a/docs/usage/advanced-hooks.md +++ b/docs/usage/advanced-hooks.md @@ -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`: @@ -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 diff --git a/src/asyncUtils.js b/src/asyncUtils.js new file mode 100644 index 00000000..7d7d6ed6 --- /dev/null +++ b/src/asyncUtils.js @@ -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 diff --git a/src/pure.js b/src/pure.js index f33d362f..579d07f8 100644 --- a/src/pure.js +++ b/src/pure.js @@ -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 }) { @@ -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) } } diff --git a/test/asyncHook.test.js b/test/asyncHook.test.js index 0049e343..61a58cb6 100644 --- a/test/asyncHook.test.js +++ b/test/asyncHook.test.js @@ -1,58 +1,217 @@ -import { useState, useEffect } from 'react' +import { useState, useRef, useEffect } from 'react' import { renderHook } from 'src' describe('async hook tests', () => { - const getSomeName = () => Promise.resolve('Betty') - - const useName = (prefix) => { - const [name, setName] = useState('nobody') + const useSequence = (...values) => { + const [first, ...otherValues] = values + const [value, setValue] = useState(first) + const index = useRef(0) useEffect(() => { - getSomeName().then((theName) => { - setName(prefix ? `${prefix} ${theName}` : theName) - }) - }, [prefix]) - - return name + const interval = setInterval(() => { + setValue(otherValues[index.current++]) + if (index.current === otherValues.length) { + clearInterval(interval) + } + }, 50) + return () => { + clearInterval(interval) + } + }, [...values]) + + return value } test('should wait for next update', async () => { - const { result, waitForNextUpdate } = renderHook(() => useName()) + const { result, waitForNextUpdate } = renderHook(() => useSequence('first', 'second')) - expect(result.current).toBe('nobody') + expect(result.current).toBe('first') await waitForNextUpdate() - expect(result.current).toBe('Betty') + expect(result.current).toBe('second') }) test('should wait for multiple updates', async () => { - const { result, waitForNextUpdate, rerender } = renderHook(({ prefix }) => useName(prefix), { - initialProps: { prefix: 'Mrs.' } - }) + const { result, waitForNextUpdate } = renderHook(() => useSequence('first', 'second', 'third')) - expect(result.current).toBe('nobody') + expect(result.current).toBe('first') await waitForNextUpdate() - expect(result.current).toBe('Mrs. Betty') - - rerender({ prefix: 'Ms.' }) + expect(result.current).toBe('second') await waitForNextUpdate() - expect(result.current).toBe('Ms. Betty') + expect(result.current).toBe('third') }) test('should resolve all when updating', async () => { - const { result, waitForNextUpdate } = renderHook(({ prefix }) => useName(prefix), { - initialProps: { prefix: 'Mrs.' } - }) + const { result, waitForNextUpdate } = renderHook(() => useSequence('first', 'second')) - expect(result.current).toBe('nobody') + expect(result.current).toBe('first') await Promise.all([waitForNextUpdate(), waitForNextUpdate(), waitForNextUpdate()]) - expect(result.current).toBe('Mrs. Betty') + expect(result.current).toBe('second') + }) + + test('should reject if timeout exceeded when waiting for next update', async () => { + const { result, waitForNextUpdate } = renderHook(() => useSequence('first', 'second')) + + expect(result.current).toBe('first') + + await expect(waitForNextUpdate({ timeout: 10 })).rejects.toThrow( + Error('Timed out in waitForNextUpdate after 10ms.') + ) + }) + + test('should wait for expectation to pass', async () => { + const { result, wait } = renderHook(() => useSequence('first', 'second', 'third')) + + expect(result.current).toBe('first') + + let complete = false + await wait(() => { + expect(result.current).toBe('third') + complete = true + }) + expect(complete).toBe(true) + }) + + test('should not hang if expectation is already passing', async () => { + const { result, wait } = renderHook(() => useSequence('first', 'second')) + + expect(result.current).toBe('first') + + let complete = false + await wait(() => { + expect(result.current).toBe('first') + complete = true + }) + expect(complete).toBe(true) + }) + + test('should reject if callback throws error', async () => { + const { result, wait } = renderHook(() => useSequence('first', 'second', 'third')) + + expect(result.current).toBe('first') + + await expect( + wait( + () => { + if (result.current === 'second') { + throw new Error('Something Unexpected') + } + return result.current === 'third' + }, + { + suppressErrors: false + } + ) + ).rejects.toThrow(Error('Something Unexpected')) + }) + + test('should reject if callback immediately throws error', async () => { + const { result, wait } = renderHook(() => useSequence('first', 'second', 'third')) + + expect(result.current).toBe('first') + + await expect( + wait( + () => { + throw new Error('Something Unexpected') + }, + { + suppressErrors: false + } + ) + ).rejects.toThrow(Error('Something Unexpected')) + }) + + test('should wait for truthy value', async () => { + const { result, wait } = renderHook(() => useSequence('first', 'second', 'third')) + + expect(result.current).toBe('first') + + await wait(() => result.current === 'third') + + expect(result.current).toBe('third') + }) + + test('should reject if timeout exceeded when waiting for expectation to pass', async () => { + const { result, wait } = renderHook(() => useSequence('first', 'second', 'third')) + + expect(result.current).toBe('first') + + await expect( + wait( + () => { + expect(result.current).toBe('third') + }, + { timeout: 75 } + ) + ).rejects.toThrow(Error('Timed out in wait after 75ms.')) + }) + + test('should wait for value to change', async () => { + const { result, waitForValueToChange } = renderHook(() => + useSequence('first', 'second', 'third') + ) + + expect(result.current).toBe('first') + + await waitForValueToChange(() => result.current === 'third') + + expect(result.current).toBe('third') + }) + + test('should reject if timeout exceeded when waiting for value to change', async () => { + const { result, waitForValueToChange } = renderHook(() => + useSequence('first', 'second', 'third') + ) + + expect(result.current).toBe('first') + + await expect( + waitForValueToChange(() => result.current === 'third', { + timeout: 75 + }) + ).rejects.toThrow(Error('Timed out in waitForValueToChange after 75ms.')) + }) + + test('should reject if selector throws error', async () => { + const { result, waitForValueToChange } = renderHook(() => useSequence('first', 'second')) + + expect(result.current).toBe('first') + + await expect( + waitForValueToChange(() => { + if (result.current === 'second') { + throw new Error('Something Unexpected') + } + return result.current + }) + ).rejects.toThrow(Error('Something Unexpected')) + }) + + test('should not reject if selector throws error and suppress errors option is enabled', async () => { + const { result, waitForValueToChange } = renderHook(() => + useSequence('first', 'second', 'third') + ) + + expect(result.current).toBe('first') + + await waitForValueToChange( + () => { + if (result.current === 'second') { + throw new Error('Something Unexpected') + } + return result.current === 'third' + }, + { suppressErrors: true } + ) + + expect(result.current).toBe('third') }) })