From bc4f7e5f7a61021fa3afc583d415ccc6b3464267 Mon Sep 17 00:00:00 2001 From: eps1lon Date: Mon, 8 Nov 2021 10:14:56 +0100 Subject: [PATCH 1/7] feat: Add `renderHook` --- src/__tests__/renderHook.js | 62 +++++++++++++++++++++++++++++++++++++ src/pure.js | 30 +++++++++++++++++- 2 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 src/__tests__/renderHook.js diff --git a/src/__tests__/renderHook.js b/src/__tests__/renderHook.js new file mode 100644 index 00000000..fd6b95a4 --- /dev/null +++ b/src/__tests__/renderHook.js @@ -0,0 +1,62 @@ +import React 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( + ({branch}) => { + const [left, setLeft] = React.useState('left') + const [right, setRight] = React.useState('right') + + // eslint-disable-next-line jest/no-if + switch (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}) { + return {children} + } + const {result} = renderHook( + () => { + return React.useContext(Context) + }, + { + wrapper: Wrapper, + }, + ) + + expect(result.current).toEqual('provided') +}) diff --git a/src/pure.js b/src/pure.js index 75098f78..c3950d3c 100644 --- a/src/pure.js +++ b/src/pure.js @@ -115,9 +115,37 @@ function cleanupAtContainer(container) { mountedContainers.delete(container) } +function renderHook(renderCallback, options = {}) { + const {initialProps, wrapper} = options + const result = React.createRef() + + function TestComponent({renderCallbackProps}) { + const renderResult = renderCallback(renderCallbackProps) + + React.useEffect(() => { + result.current = renderResult + }) + + return null + } + + const {rerender: baseRerender, unmount} = render( + , + {wrapper}, + ) + + function rerender(rerenderCallbackProps) { + return baseRerender( + , + ) + } + + return {result, rerender, unmount} +} + // just re-export everything from dom-testing-library export * from '@testing-library/dom' -export {render, cleanup, act, fireEvent} +export {render, renderHook, cleanup, act, fireEvent} // NOTE: we're not going to export asyncAct because that's our own compatibility // thing for people using react-dom@16.8.0. Anyone else doesn't need it and From a842157d40b7ac759c31bc473f8b55a1f49eca55 Mon Sep 17 00:00:00 2001 From: eps1lon Date: Mon, 15 Nov 2021 19:10:01 +0100 Subject: [PATCH 2/7] Add types --- types/index.d.ts | 19 +++++++++++++++++++ types/test.tsx | 12 +++++++++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/types/index.d.ts b/types/index.d.ts index 92eb2d7b..4261fbe7 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -90,6 +90,25 @@ export function render( options?: Omit, ): RenderResult +// TODO JSDOC +interface RenderHookResult { + rerender: (props?: Props) => void + result: {current: Result} + unmount: () => void +} + +// TODO JSDOC +interface RenderHookOptions { + initialProps?: Props + wrapper?: React.ComponentType +} + +// TODO JSDOC +export function renderHook( + render: (initialProps?: Props) => Result, + options?: RenderHookOptions, +): RenderHookResult + /** * Unmounts React trees that were mounted with render. */ diff --git a/types/test.tsx b/types/test.tsx index 71ea30a9..0c25a7e1 100644 --- a/types/test.tsx +++ b/types/test.tsx @@ -1,5 +1,5 @@ import * as React from 'react' -import {render, fireEvent, screen, waitFor} from '.' +import {render, fireEvent, screen, waitFor, renderHook} from '.' import * as pure from './pure' export async function testRender() { @@ -141,6 +141,16 @@ export function wrappedRenderC( return pure.render(ui, {wrapper: AppWrapperProps, ...options}) } +export function testRenderHook() { + const {result, rerender, unmount} = renderHook(() => React.useState(2)[0]) + + expectType(result.current) + + rerender() + + unmount() +} + /* eslint testing-library/prefer-explicit-assert: "off", From a3f6510a2a66c49bf9d5755f5d1539a87c7296c1 Mon Sep 17 00:00:00 2001 From: eps1lon Date: Tue, 16 Nov 2021 11:57:53 +0100 Subject: [PATCH 3/7] Loosen up type-safety for props --- types/index.d.ts | 2 +- types/test.tsx | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/types/index.d.ts b/types/index.d.ts index 4261fbe7..7f44d9d8 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -105,7 +105,7 @@ interface RenderHookOptions { // TODO JSDOC export function renderHook( - render: (initialProps?: Props) => Result, + render: (initialProps: Props) => Result, options?: RenderHookOptions, ): RenderHookResult diff --git a/types/test.tsx b/types/test.tsx index 0c25a7e1..5a23a878 100644 --- a/types/test.tsx +++ b/types/test.tsx @@ -151,6 +151,19 @@ export function testRenderHook() { unmount() } +export function testRenderHookProps() { + const {result, rerender, unmount} = renderHook( + ({defaultValue}) => React.useState(defaultValue)[0], + {initialProps: {defaultValue: 2}}, + ) + + expectType(result.current) + + rerender() + + unmount() +} + /* eslint testing-library/prefer-explicit-assert: "off", From 887d95b84ddbcedb46932bf52fbda4518abb35c8 Mon Sep 17 00:00:00 2001 From: eps1lon Date: Mon, 22 Nov 2021 12:51:58 +0100 Subject: [PATCH 4/7] renderResult -> pendingResult Conceptually the state is pending to be committed. So this makes more sense than tying it to "render" --- src/pure.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pure.js b/src/pure.js index c3950d3c..21e86705 100644 --- a/src/pure.js +++ b/src/pure.js @@ -120,10 +120,10 @@ function renderHook(renderCallback, options = {}) { const result = React.createRef() function TestComponent({renderCallbackProps}) { - const renderResult = renderCallback(renderCallbackProps) + const pendingResult = renderCallback(renderCallbackProps) React.useEffect(() => { - result.current = renderResult + result.current = pendingResult }) return null From 43157d4464fa5d821f9a030cd859aa6acf07a967 Mon Sep 17 00:00:00 2001 From: Michael Peyper Date: Sun, 10 Apr 2022 22:42:43 +1000 Subject: [PATCH 5/7] test(TS): update props type to include children --- types/test.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/types/test.tsx b/types/test.tsx index 5ee64447..8a5b37b9 100644 --- a/types/test.tsx +++ b/types/test.tsx @@ -130,6 +130,7 @@ export function wrappedRenderC( ) { interface AppWrapperProps { userProviderProps?: {user: string} + children: React.ReactNode } const AppWrapperProps: React.FunctionComponent = ({ children, From 55811ff5d2455cf88921a067dc32677509ef8c37 Mon Sep 17 00:00:00 2001 From: "Kent C. Dodds" Date: Fri, 15 Apr 2022 12:42:31 -0600 Subject: [PATCH 6/7] fix bad merge thing --- types/test.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/types/test.tsx b/types/test.tsx index 9e0572a5..17ba7012 100644 --- a/types/test.tsx +++ b/types/test.tsx @@ -131,7 +131,6 @@ export function wrappedRenderC( interface AppWrapperProps { children?: React.ReactNode userProviderProps?: {user: string} - children: React.ReactNode } const AppWrapperProps: React.FunctionComponent = ({ children, From 144b485818fb57654b62d1c3576cb31562ebe5b2 Mon Sep 17 00:00:00 2001 From: "Kent C. Dodds" Date: Fri, 15 Apr 2022 12:52:04 -0600 Subject: [PATCH 7/7] fix types and add docs --- types/index.d.ts | 37 ++++++++++++++++++++++++++++++++----- 1 file changed, 32 insertions(+), 5 deletions(-) diff --git a/types/index.d.ts b/types/index.d.ts index 3f906fcc..fda03e5b 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -98,20 +98,47 @@ export function render( options?: Omit, ): RenderResult -// TODO JSDOC interface RenderHookResult { + /** + * Triggers a re-render. The props will be passed to your renderHook callback. + */ rerender: (props?: Props) => void - result: {current: Result} + /** + * This is a stable reference to the latest value returned by your renderHook + * callback + */ + result: { + /** + * The value returned by your renderHook callback + */ + current: Result + } + /** + * Unmounts the test component. This is useful for when you need to test + * any cleanup your useEffects have. + */ unmount: () => void } -// TODO JSDOC interface RenderHookOptions { + /** + * The argument passed to the renderHook callback. Can be useful if you plan + * to use the rerender utility to change the values passed to your hook. + */ initialProps?: Props - wrapper?: React.ComponentType + /** + * Pass a React Component as the wrapper option to have it rendered around the inner element. This is most useful for creating + * reusable custom render functions for common data providers. See setup for examples. + * + * @see https://testing-library.com/docs/react-testing-library/api/#wrapper + */ + wrapper?: React.JSXElementConstructor<{children: React.ReactElement}> } -// TODO JSDOC +/** + * Allows you to render a hook within a test React component without having to + * create that component yourself. + */ export function renderHook( render: (initialProps: Props) => Result, options?: RenderHookOptions,