From fe87a08f928dbb804be9b60f67f22b1a19419911 Mon Sep 17 00:00:00 2001 From: pierrezimmermann Date: Sat, 11 Dec 2021 16:31:32 +0100 Subject: [PATCH 1/4] feat: add renderHook function --- src/__tests__/renderHook.test.tsx | 62 +++++++++++++++++++++++++++++++ src/pure.ts | 2 + src/renderHook.tsx | 55 +++++++++++++++++++++++++++ 3 files changed, 119 insertions(+) create mode 100644 src/__tests__/renderHook.test.tsx create mode 100644 src/renderHook.tsx 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 }; +} From 2331b515b371ab84eb6d13bb4fa350d898f0bf3b Mon Sep 17 00:00:00 2001 From: pierrezimmermann Date: Wed, 20 Apr 2022 22:28:16 +0200 Subject: [PATCH 2/4] docs: add documentation on renderhook --- website/docs/API.md | 134 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 134 insertions(+) diff --git a/website/docs/API.md b/website/docs/API.md index dd66193c4..e49958363 100644 --- a/website/docs/API.md +++ b/website/docs/API.md @@ -465,3 +465,137 @@ 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` + +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. + +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) + +An options object to modify the execution of the `callback` function. See the `renderHook` Options section bellow for more details. + +## `renderHook` Options + +The `renderHook` function accepts the following options as the second parameter: + +### `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. + +## `renderHook` Result + +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` + +### `Basic example` + +```jsx +const useCount = () => { + const [count, setCount] = useState(0); + const increment = () => setCount(previousCount => previousCount + 1); + + return { count, increment }; +} + +it('should increment count', () => { + const { result } = renderHook(() => useCount()); + + expect(result.current.count).toBe(0); + + act(() => { + result.increment() + } + + expect(result.current.count).toBe(1); + } +``` + +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. + +#### `With initial Props` + +```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 } + + ... + } +``` \ No newline at end of file From e835ea8041a81b4e07770324c9205ad4b57e0f6c Mon Sep 17 00:00:00 2001 From: pierrezimmermann Date: Mon, 25 Apr 2022 12:52:34 +0200 Subject: [PATCH 3/4] chore(renderHook): add flow typing --- typings/index.flow.js | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) 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; } From e30230f7e6503c2ae0bc5a94157775349a589f87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 26 Apr 2022 14:47:52 +0200 Subject: [PATCH 4/4] docs: improve readability and consistency --- website/docs/API.md | 166 ++++++++++++++++++++++---------------------- 1 file changed, 84 insertions(+), 82 deletions(-) diff --git a/website/docs/API.md b/website/docs/API.md index e49958363..392134d06 100644 --- a/website/docs/API.md +++ b/website/docs/API.md @@ -468,39 +468,68 @@ Useful function to help testing components that use hooks API. By default any `r ## `renderHook` -function renderHook(callback: (props?: any) => any, options?: RenderHookOptions): RenderHookResult +Defined as: -Renders a test component that will call the provided `callback`, including any hooks it calls, every time it renders. +```ts +function renderHook( + callback: (props?: any) => any, + options?: RenderHookOptions +): RenderHookResult; +``` -The `renderHook` function accepts the following arguments: +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. -### `callback` +```ts +import { renderHook } from '@testing-library/react-native'; +import { useCount } from '../useCount'; -The function that is called each `render` of the test component. This function should call one or more hooks for testing. +it('should increment count', () => { + const { result } = renderHook(() => useCount()); -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. + 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); +}); +``` -### `options` (Optional) +```ts +// useCount.js +export const useCount = () => { + const [count, setCount] = useState(0); + const increment = () => setCount((previousCount) => previousCount + 1); -An options object to modify the execution of the `callback` function. See the `renderHook` Options section bellow for more details. + 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. -## `renderHook` Options +### `options` (Optional) -The `renderHook` function accepts the following options as the second parameter: +A `RenderHookOptions` object to modify the execution of the `callback` function, containing the following properties: -### `initialProps` +#### `initialProps` -The initial values to pass as `props` to the `callback` function of `renderHook`. +The initial values to pass as `props` to the `callback` function of `renderHook`. -### `wrapper` +#### `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. +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. -## `renderHook` Result +### `RenderHookResult` object -The `renderHook` function returns an object that has the following properties: +The `renderHook` function returns an object that has the following properties: -### `result` +#### `result` ```jsx { @@ -510,92 +539,65 @@ The `renderHook` function returns an object that has the following properties: } ``` -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. +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` +#### `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. +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` +#### `unmount` function unmount(): void -A function to unmount the test component. This is commonly used to trigger cleanup effects for `useEffect` hooks. +A function to unmount the test component. This is commonly used to trigger cleanup effects for `useEffect` hooks. -## `Examples` +### Examples -### `Basic example` +Here we present some extra examples of using `renderHook` API. -```jsx -const useCount = () => { - const [count, setCount] = useState(0); - const increment = () => setCount(previousCount => previousCount + 1); - - return { count, increment }; -} - -it('should increment count', () => { - const { result } = renderHook(() => useCount()); - - expect(result.current.count).toBe(0); - - act(() => { - result.increment() - } - - expect(result.current.count).toBe(1); - } -``` - -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. - -#### `With initial Props` +#### 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 }; -} +const useCount = (initialCount: number) => { + const [count, setCount] = useState(initialCount); + const increment = () => setCount((previousCount) => previousCount + 1); - + useEffect(() => { + setCount(initialCount); + }, [initialCount]); -it('should increment count', () => { - const { result, rerender } = renderHook((initialCount: number) => useCount(initialCount), - { initialProps: 1}); - - expect(result.current.count).toBe(1); + return { count, increment }; +}; - act(() => { - result.increment() - } +it('should increment count', () => { + const { result, rerender } = renderHook( + (initialCount: number) => useCount(initialCount), + { initialProps: 1 } + ); - expect(result.current.count).toBe(2); + expect(result.current.count).toBe(1); - rerender(5); + act(() => { + result.increment(); + }); - expect(result.current.count).toBe(5); -} + expect(result.current.count).toBe(2); + rerender(5); + expect(result.current.count).toBe(5); +}); ``` -### `With wrapper` +#### With `wrapper` ```jsx - it('should work properly', () => { - function Wrapper({ children }: { children: ReactNode }) { - return {children}; - } - - const { result } = renderHook(() => useHook(), { wrapper : Wrapper } - - ... - } -``` \ No newline at end of file + function Wrapper({ children }: { children: ReactNode }) { + return {children}; + } + + const { result } = renderHook(() => useHook(), { wrapper: Wrapper }); + // ... +}); +```