Skip to content
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

feat: add renderHook function #923

Merged
merged 4 commits into from
Apr 26, 2022
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
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 });
// ...
});
```