Skip to content

Commit

Permalink
feat: add renderHook function (#923)
Browse files Browse the repository at this point in the history
* feat: add renderHook function

* docs: add documentation on renderhook

* chore(renderHook): add flow typing

* docs: improve readability and consistency

Co-authored-by: pierrezimmermann <pierrez@nam.tech>
Co-authored-by: Michał Pierzchała <thymikee@gmail.com>
  • Loading branch information
3 people authored Apr 26, 2022
1 parent c42237e commit 564e990
Show file tree
Hide file tree
Showing 5 changed files with 273 additions and 0 deletions.
62 changes: 62 additions & 0 deletions src/__tests__/renderHook.test.tsx
Original file line number Diff line number Diff line change
@@ -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 <Context.Provider value="provided">{children}</Context.Provider>;
}
const { result } = renderHook(
() => {
return React.useContext(Context);
},
{
wrapper: Wrapper,
}
);

expect(result.current).toEqual('provided');
});
2 changes: 2 additions & 0 deletions src/pure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand All @@ -15,3 +16,4 @@ export { waitFor };
export { waitForElementToBeRemoved };
export { within, getQueriesForElement };
export { getDefaultNormalizer };
export { renderHook };
55 changes: 55 additions & 0 deletions src/renderHook.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import React from 'react';
import type { ComponentType } from 'react';
import render from './render';

interface RenderHookResult<Result, Props> {
rerender: (props: Props) => void;
result: { current: Result };
unmount: () => void;
}

type RenderHookOptions<Props> = Props extends object | string | number | boolean
? {
initialProps: Props;
wrapper?: ComponentType<any>;
}
: { wrapper?: ComponentType<any>; initialProps?: never } | undefined;

export function renderHook<Result, Props>(
renderCallback: (props: Props) => Result,
options?: RenderHookOptions<Props>
): RenderHookResult<Result, Props> {
const initialProps = options?.initialProps;
const wrapper = options?.wrapper;

const result: React.MutableRefObject<Result | null> = 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
<TestComponent renderCallbackProps={initialProps} />,
{ wrapper }
);

function rerender(rerenderCallbackProps: Props) {
return baseRerender(
<TestComponent renderCallbackProps={rerenderCallbackProps} />
);
}

// @ts-expect-error result is ill typed because ref is initialized to null
return { result, rerender, unmount };
}
18 changes: 18 additions & 0 deletions typings/index.flow.js
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,17 @@ type FireEventAPI = FireEventFunction & {
scroll: (element: ReactTestInstance, ...data: Array<any>) => any,
};

type RenderHookResult<Result, Props> = {
rerender: (props: Props) => void,
result: { current: Result },
unmount: () => void,
};

type RenderHookOptions<Props> = {
initialProps?: Props,
wrapper?: React.ComponentType<any>,
};

declare module '@testing-library/react-native' {
declare export var render: (
component: React.Element<any>,
Expand Down Expand Up @@ -363,4 +374,11 @@ declare module '@testing-library/react-native' {
declare export var getDefaultNormalizer: (
normalizerConfig?: NormalizerConfig
) => NormalizerFn;

declare type RenderHookFunction = <Result, Props>(
renderCallback: (props: Props) => Result,
options?: RenderHookOptions<Props>
) => RenderHookResult<Result, Props>;

declare export var renderHook: RenderHookFunction;
}
136 changes: 136 additions & 0 deletions website/docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<any>
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 <Context.Provider value="provided">{children}</Context.Provider>;
}

const { result } = renderHook(() => useHook(), { wrapper: Wrapper });
// ...
});
```

0 comments on commit 564e990

Please sign in to comment.