diff --git a/src/__tests__/renderHook.test.tsx b/src/__tests__/renderHook.test.tsx new file mode 100644 index 000000000..389f38ffa --- /dev/null +++ b/src/__tests__/renderHook.test.tsx @@ -0,0 +1,62 @@ +import React, { ReactNode } from 'react'; +import { renderHook } from '../pure'; + +test('gives comitted result', () => { + const { result } = renderHook(() => { + const [state, setState] = React.useState(1); + + React.useEffect(() => { + setState(2); + }, []); + + return [state, setState]; + }); + + expect(result.current).toEqual([2, expect.any(Function)]); +}); + +test('allows rerendering', () => { + const { result, rerender } = renderHook( + (props: { branch: 'left' | 'right' }) => { + const [left, setLeft] = React.useState('left'); + const [right, setRight] = React.useState('right'); + + // eslint-disable-next-line jest/no-if + switch (props.branch) { + case 'left': + return [left, setLeft]; + case 'right': + return [right, setRight]; + + default: + throw new Error( + 'No Props passed. This is a bug in the implementation' + ); + } + }, + { initialProps: { branch: 'left' } } + ); + + expect(result.current).toEqual(['left', expect.any(Function)]); + + rerender({ branch: 'right' }); + + expect(result.current).toEqual(['right', expect.any(Function)]); +}); + +test('allows wrapper components', async () => { + const Context = React.createContext('default'); + function Wrapper({ children }: { children: ReactNode }) { + return {children}; + } + const { result } = renderHook( + () => { + return React.useContext(Context); + }, + { + wrapper: Wrapper, + } + ); + + expect(result.current).toEqual('provided'); +}); diff --git a/src/pure.ts b/src/pure.ts index 9baffa97f..74468d39e 100644 --- a/src/pure.ts +++ b/src/pure.ts @@ -6,6 +6,7 @@ import waitFor from './waitFor'; import waitForElementToBeRemoved from './waitForElementToBeRemoved'; import { within, getQueriesForElement } from './within'; import { getDefaultNormalizer } from './matches'; +import { renderHook } from './renderHook'; export { act }; export { cleanup }; @@ -15,3 +16,4 @@ export { waitFor }; export { waitForElementToBeRemoved }; export { within, getQueriesForElement }; export { getDefaultNormalizer }; +export { renderHook }; diff --git a/src/renderHook.tsx b/src/renderHook.tsx new file mode 100644 index 000000000..35760384b --- /dev/null +++ b/src/renderHook.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import type { ComponentType } from 'react'; +import render from './render'; + +interface RenderHookResult { + rerender: (props: Props) => void; + result: { current: Result }; + unmount: () => void; +} + +type RenderHookOptions = Props extends object | string | number | boolean + ? { + initialProps: Props; + wrapper?: ComponentType; + } + : { wrapper?: ComponentType; initialProps?: never } | undefined; + +export function renderHook( + renderCallback: (props: Props) => Result, + options?: RenderHookOptions +): RenderHookResult { + const initialProps = options?.initialProps; + const wrapper = options?.wrapper; + + const result: React.MutableRefObject = React.createRef(); + + function TestComponent({ + renderCallbackProps, + }: { + renderCallbackProps: Props; + }) { + const renderResult = renderCallback(renderCallbackProps); + + React.useEffect(() => { + result.current = renderResult; + }); + + return null; + } + + const { rerender: baseRerender, unmount } = render( + // @ts-expect-error since option can be undefined, initialProps can be undefined when it should'nt + , + { wrapper } + ); + + function rerender(rerenderCallbackProps: Props) { + return baseRerender( + + ); + } + + // @ts-expect-error result is ill typed because ref is initialized to null + return { result, rerender, unmount }; +} diff --git a/typings/index.flow.js b/typings/index.flow.js index 4c550cff7..173e5356c 100644 --- a/typings/index.flow.js +++ b/typings/index.flow.js @@ -336,6 +336,17 @@ type FireEventAPI = FireEventFunction & { scroll: (element: ReactTestInstance, ...data: Array) => any, }; +type RenderHookResult = { + rerender: (props: Props) => void, + result: { current: Result }, + unmount: () => void, +}; + +type RenderHookOptions = { + initialProps?: Props, + wrapper?: React.ComponentType, +}; + declare module '@testing-library/react-native' { declare export var render: ( component: React.Element, @@ -363,4 +374,11 @@ declare module '@testing-library/react-native' { declare export var getDefaultNormalizer: ( normalizerConfig?: NormalizerConfig ) => NormalizerFn; + + declare type RenderHookFunction = ( + renderCallback: (props: Props) => Result, + options?: RenderHookOptions + ) => RenderHookResult; + + declare export var renderHook: RenderHookFunction; } diff --git a/website/docs/API.md b/website/docs/API.md index dd66193c4..392134d06 100644 --- a/website/docs/API.md +++ b/website/docs/API.md @@ -465,3 +465,139 @@ expect(submitButtons).toHaveLength(3); // expect 3 elements ## `act` Useful function to help testing components that use hooks API. By default any `render`, `update`, `fireEvent`, and `waitFor` calls are wrapped by this function, so there is no need to wrap it manually. This method is re-exported from [`react-test-renderer`](https://github.com/facebook/react/blob/main/packages/react-test-renderer/src/ReactTestRenderer.js#L567]). + +## `renderHook` + +Defined as: + +```ts +function renderHook( + callback: (props?: any) => any, + options?: RenderHookOptions +): RenderHookResult; +``` + +Renders a test component that will call the provided `callback`, including any hooks it calls, every time it renders. Returns [`RenderHookResult`](#renderhookresult-object) object, which you can interact with. + +```ts +import { renderHook } from '@testing-library/react-native'; +import { useCount } from '../useCount'; + +it('should increment count', () => { + const { result } = renderHook(() => useCount()); + + expect(result.current.count).toBe(0); + act(() => { + // Note that you should wrap the calls to functions your hook returns with `act` if they trigger an update of your hook's state to ensure pending useEffects are run before your next assertion. + result.increment(); + }); + expect(result.current.count).toBe(1); +}); +``` + +```ts +// useCount.js +export const useCount = () => { + const [count, setCount] = useState(0); + const increment = () => setCount((previousCount) => previousCount + 1); + + return { count, increment }; +}; +``` + +The `renderHook` function accepts the following arguments: + +### `callback` + +The function that is called each `render` of the test component. This function should call one or 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` (Optional) + +A `RenderHookOptions` object to modify the execution of the `callback` function, containing the following properties: + +#### `initialProps` + +The initial values to pass as `props` to the `callback` function of `renderHook`. + +#### `wrapper` + +A React component to wrap the test component in when rendering. This is usually used to add context providers from `React.createContext` for the hook to access with `useContext`. `initialProps` and props subsequently set by `rerender` will be provided to the wrapper. + +### `RenderHookResult` object + +The `renderHook` function returns an object that has the following properties: + +#### `result` + +```jsx +{ + all: Array + current: any, + error: Error +} +``` + +The `current` value of the `result` will reflect the latest of whatever is returned from the `callback` passed to `renderHook`. Any thrown values from the latest call will be reflected in the `error` value of the `result`. The `all` value is an array containing all the returns (including the most recent) from the callback. These could be `result` or an `error` depending on what the callback returned at the time. + +#### `rerender` + +function rerender(newProps?: any): void + +A function to rerender the test component, causing any hooks to be recalculated. If `newProps` are passed, they will replace the `callback` function's `initialProps` for subsequent rerenders. + +#### `unmount` + +function unmount(): void + +A function to unmount the test component. This is commonly used to trigger cleanup effects for `useEffect` hooks. + +### Examples + +Here we present some extra examples of using `renderHook` API. + +#### With `initialProps` + +```jsx +const useCount = (initialCount: number) => { + const [count, setCount] = useState(initialCount); + const increment = () => setCount((previousCount) => previousCount + 1); + + useEffect(() => { + setCount(initialCount); + }, [initialCount]); + + return { count, increment }; +}; + +it('should increment count', () => { + const { result, rerender } = renderHook( + (initialCount: number) => useCount(initialCount), + { initialProps: 1 } + ); + + expect(result.current.count).toBe(1); + + act(() => { + result.increment(); + }); + + expect(result.current.count).toBe(2); + rerender(5); + expect(result.current.count).toBe(5); +}); +``` + +#### With `wrapper` + +```jsx +it('should work properly', () => { + function Wrapper({ children }: { children: ReactNode }) { + return {children}; + } + + const { result } = renderHook(() => useHook(), { wrapper: Wrapper }); + // ... +}); +```