Skip to content

Commit 564e990

Browse files
pierrezimmermannbampierrezimmermannthymikee
authored
feat: add renderHook function (#923)
* feat: add renderHook function * docs: add documentation on renderhook * chore(renderHook): add flow typing * docs: improve readability and consistency Co-authored-by: pierrezimmermann <[email protected]> Co-authored-by: Michał Pierzchała <[email protected]>
1 parent c42237e commit 564e990

File tree

5 files changed

+273
-0
lines changed

5 files changed

+273
-0
lines changed

src/__tests__/renderHook.test.tsx

+62
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import React, { ReactNode } from 'react';
2+
import { renderHook } from '../pure';
3+
4+
test('gives comitted result', () => {
5+
const { result } = renderHook(() => {
6+
const [state, setState] = React.useState(1);
7+
8+
React.useEffect(() => {
9+
setState(2);
10+
}, []);
11+
12+
return [state, setState];
13+
});
14+
15+
expect(result.current).toEqual([2, expect.any(Function)]);
16+
});
17+
18+
test('allows rerendering', () => {
19+
const { result, rerender } = renderHook(
20+
(props: { branch: 'left' | 'right' }) => {
21+
const [left, setLeft] = React.useState('left');
22+
const [right, setRight] = React.useState('right');
23+
24+
// eslint-disable-next-line jest/no-if
25+
switch (props.branch) {
26+
case 'left':
27+
return [left, setLeft];
28+
case 'right':
29+
return [right, setRight];
30+
31+
default:
32+
throw new Error(
33+
'No Props passed. This is a bug in the implementation'
34+
);
35+
}
36+
},
37+
{ initialProps: { branch: 'left' } }
38+
);
39+
40+
expect(result.current).toEqual(['left', expect.any(Function)]);
41+
42+
rerender({ branch: 'right' });
43+
44+
expect(result.current).toEqual(['right', expect.any(Function)]);
45+
});
46+
47+
test('allows wrapper components', async () => {
48+
const Context = React.createContext('default');
49+
function Wrapper({ children }: { children: ReactNode }) {
50+
return <Context.Provider value="provided">{children}</Context.Provider>;
51+
}
52+
const { result } = renderHook(
53+
() => {
54+
return React.useContext(Context);
55+
},
56+
{
57+
wrapper: Wrapper,
58+
}
59+
);
60+
61+
expect(result.current).toEqual('provided');
62+
});

src/pure.ts

+2
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import waitFor from './waitFor';
66
import waitForElementToBeRemoved from './waitForElementToBeRemoved';
77
import { within, getQueriesForElement } from './within';
88
import { getDefaultNormalizer } from './matches';
9+
import { renderHook } from './renderHook';
910

1011
export { act };
1112
export { cleanup };
@@ -15,3 +16,4 @@ export { waitFor };
1516
export { waitForElementToBeRemoved };
1617
export { within, getQueriesForElement };
1718
export { getDefaultNormalizer };
19+
export { renderHook };

src/renderHook.tsx

+55
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import React from 'react';
2+
import type { ComponentType } from 'react';
3+
import render from './render';
4+
5+
interface RenderHookResult<Result, Props> {
6+
rerender: (props: Props) => void;
7+
result: { current: Result };
8+
unmount: () => void;
9+
}
10+
11+
type RenderHookOptions<Props> = Props extends object | string | number | boolean
12+
? {
13+
initialProps: Props;
14+
wrapper?: ComponentType<any>;
15+
}
16+
: { wrapper?: ComponentType<any>; initialProps?: never } | undefined;
17+
18+
export function renderHook<Result, Props>(
19+
renderCallback: (props: Props) => Result,
20+
options?: RenderHookOptions<Props>
21+
): RenderHookResult<Result, Props> {
22+
const initialProps = options?.initialProps;
23+
const wrapper = options?.wrapper;
24+
25+
const result: React.MutableRefObject<Result | null> = React.createRef();
26+
27+
function TestComponent({
28+
renderCallbackProps,
29+
}: {
30+
renderCallbackProps: Props;
31+
}) {
32+
const renderResult = renderCallback(renderCallbackProps);
33+
34+
React.useEffect(() => {
35+
result.current = renderResult;
36+
});
37+
38+
return null;
39+
}
40+
41+
const { rerender: baseRerender, unmount } = render(
42+
// @ts-expect-error since option can be undefined, initialProps can be undefined when it should'nt
43+
<TestComponent renderCallbackProps={initialProps} />,
44+
{ wrapper }
45+
);
46+
47+
function rerender(rerenderCallbackProps: Props) {
48+
return baseRerender(
49+
<TestComponent renderCallbackProps={rerenderCallbackProps} />
50+
);
51+
}
52+
53+
// @ts-expect-error result is ill typed because ref is initialized to null
54+
return { result, rerender, unmount };
55+
}

typings/index.flow.js

+18
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,17 @@ type FireEventAPI = FireEventFunction & {
336336
scroll: (element: ReactTestInstance, ...data: Array<any>) => any,
337337
};
338338

339+
type RenderHookResult<Result, Props> = {
340+
rerender: (props: Props) => void,
341+
result: { current: Result },
342+
unmount: () => void,
343+
};
344+
345+
type RenderHookOptions<Props> = {
346+
initialProps?: Props,
347+
wrapper?: React.ComponentType<any>,
348+
};
349+
339350
declare module '@testing-library/react-native' {
340351
declare export var render: (
341352
component: React.Element<any>,
@@ -363,4 +374,11 @@ declare module '@testing-library/react-native' {
363374
declare export var getDefaultNormalizer: (
364375
normalizerConfig?: NormalizerConfig
365376
) => NormalizerFn;
377+
378+
declare type RenderHookFunction = <Result, Props>(
379+
renderCallback: (props: Props) => Result,
380+
options?: RenderHookOptions<Props>
381+
) => RenderHookResult<Result, Props>;
382+
383+
declare export var renderHook: RenderHookFunction;
366384
}

website/docs/API.md

+136
Original file line numberDiff line numberDiff line change
@@ -465,3 +465,139 @@ expect(submitButtons).toHaveLength(3); // expect 3 elements
465465
## `act`
466466

467467
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]).
468+
469+
## `renderHook`
470+
471+
Defined as:
472+
473+
```ts
474+
function renderHook(
475+
callback: (props?: any) => any,
476+
options?: RenderHookOptions
477+
): RenderHookResult;
478+
```
479+
480+
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.
481+
482+
```ts
483+
import { renderHook } from '@testing-library/react-native';
484+
import { useCount } from '../useCount';
485+
486+
it('should increment count', () => {
487+
const { result } = renderHook(() => useCount());
488+
489+
expect(result.current.count).toBe(0);
490+
act(() => {
491+
// 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.
492+
result.increment();
493+
});
494+
expect(result.current.count).toBe(1);
495+
});
496+
```
497+
498+
```ts
499+
// useCount.js
500+
export const useCount = () => {
501+
const [count, setCount] = useState(0);
502+
const increment = () => setCount((previousCount) => previousCount + 1);
503+
504+
return { count, increment };
505+
};
506+
```
507+
508+
The `renderHook` function accepts the following arguments:
509+
510+
### `callback`
511+
512+
The function that is called each `render` of the test component. This function should call one or more hooks for testing.
513+
514+
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.
515+
516+
### `options` (Optional)
517+
518+
A `RenderHookOptions` object to modify the execution of the `callback` function, containing the following properties:
519+
520+
#### `initialProps`
521+
522+
The initial values to pass as `props` to the `callback` function of `renderHook`.
523+
524+
#### `wrapper`
525+
526+
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.
527+
528+
### `RenderHookResult` object
529+
530+
The `renderHook` function returns an object that has the following properties:
531+
532+
#### `result`
533+
534+
```jsx
535+
{
536+
all: Array<any>
537+
current: any,
538+
error: Error
539+
}
540+
```
541+
542+
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.
543+
544+
#### `rerender`
545+
546+
function rerender(newProps?: any): void
547+
548+
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.
549+
550+
#### `unmount`
551+
552+
function unmount(): void
553+
554+
A function to unmount the test component. This is commonly used to trigger cleanup effects for `useEffect` hooks.
555+
556+
### Examples
557+
558+
Here we present some extra examples of using `renderHook` API.
559+
560+
#### With `initialProps`
561+
562+
```jsx
563+
const useCount = (initialCount: number) => {
564+
const [count, setCount] = useState(initialCount);
565+
const increment = () => setCount((previousCount) => previousCount + 1);
566+
567+
useEffect(() => {
568+
setCount(initialCount);
569+
}, [initialCount]);
570+
571+
return { count, increment };
572+
};
573+
574+
it('should increment count', () => {
575+
const { result, rerender } = renderHook(
576+
(initialCount: number) => useCount(initialCount),
577+
{ initialProps: 1 }
578+
);
579+
580+
expect(result.current.count).toBe(1);
581+
582+
act(() => {
583+
result.increment();
584+
});
585+
586+
expect(result.current.count).toBe(2);
587+
rerender(5);
588+
expect(result.current.count).toBe(5);
589+
});
590+
```
591+
592+
#### With `wrapper`
593+
594+
```jsx
595+
it('should work properly', () => {
596+
function Wrapper({ children }: { children: ReactNode }) {
597+
return <Context.Provider value="provided">{children}</Context.Provider>;
598+
}
599+
600+
const { result } = renderHook(() => useHook(), { wrapper: Wrapper });
601+
// ...
602+
});
603+
```

0 commit comments

Comments
 (0)