Skip to content

Commit f4266b2

Browse files
feat: add renderHook (#11)
* feat: add renderHook * docs: update readme to add renderHook example * Update README.md * docs: lint README.md --------- Co-authored-by: Vladimir <[email protected]>
1 parent f348a2d commit f4266b2

File tree

5 files changed

+153
-2
lines changed

5 files changed

+153
-2
lines changed

README.md

+18
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,24 @@ test('counter button increments the count', async () => {
2222
> 💡 This library doesn't expose React's `act` and uses it only to flush operations happening as part of `useEffect` during initial rendering and unmouting.
2323
Other use cases are handled by CDP and `expect.element` which both have built-in [retry-ability mechanism](https://vitest.dev/guide/browser/assertion-api).
2424

25+
`vitest-browser-react` also exposes `renderHook` helper to test React hooks.
26+
27+
```tsx
28+
import { renderHook } from 'vitest-browser-react'
29+
import { expect, test } from 'vitest'
30+
import { act } from 'react'
31+
32+
test('should increment counter', async () => {
33+
const { result } = renderHook(() => useCounter())
34+
35+
act(() => {
36+
result.current.increment()
37+
})
38+
39+
expect(result.current.count).toBe(1)
40+
})
41+
```
42+
2543
`vitest-browser-react` also automatically injects `render` method on the `page`. Example:
2644

2745
```ts

src/index.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { page } from '@vitest/browser/context'
22
import { beforeEach } from 'vitest'
33
import { cleanup, render } from './pure'
44

5-
export { render, cleanup } from './pure'
5+
export { render, renderHook, cleanup } from './pure'
66
export type { ComponentRenderOptions, RenderResult } from './pure'
77

88
page.extend({

src/pure.ts src/pure.tsx

+62-1
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,65 @@ export function render(
112112
}
113113
}
114114

115+
export interface RenderHookOptions<Props> extends ComponentRenderOptions {
116+
/**
117+
* The argument passed to the renderHook callback. Can be useful if you plan
118+
* to use the rerender utility to change the values passed to your hook.
119+
*/
120+
initialProps?: Props | undefined
121+
}
122+
123+
export interface RenderHookResult<Result, Props> {
124+
/**
125+
* Triggers a re-render. The props will be passed to your renderHook callback.
126+
*/
127+
rerender: (props?: Props) => void
128+
/**
129+
* This is a stable reference to the latest value returned by your renderHook
130+
* callback
131+
*/
132+
result: {
133+
/**
134+
* The value returned by your renderHook callback
135+
*/
136+
current: Result
137+
}
138+
/**
139+
* Unmounts the test component. This is useful for when you need to test
140+
* any cleanup your useEffects have.
141+
*/
142+
unmount: () => void
143+
}
144+
145+
export function renderHook<Props, Result>(renderCallback: (initialProps?: Props) => Result, options: RenderHookOptions<Props> = {}): RenderHookResult<Result, Props> {
146+
const { initialProps, ...renderOptions } = options
147+
148+
const result = React.createRef<Result>() as unknown as { current: Result }
149+
150+
function TestComponent({ renderCallbackProps }: { renderCallbackProps?: Props }) {
151+
const pendingResult = renderCallback(renderCallbackProps)
152+
153+
React.useEffect(() => {
154+
result.current = pendingResult
155+
})
156+
157+
return null
158+
}
159+
160+
const { rerender: baseRerender, unmount } = render(
161+
<TestComponent renderCallbackProps={initialProps} />,
162+
renderOptions,
163+
)
164+
165+
function rerender(rerenderCallbackProps?: Props) {
166+
return baseRerender(
167+
<TestComponent renderCallbackProps={rerenderCallbackProps} />,
168+
)
169+
}
170+
171+
return { result, rerender, unmount }
172+
}
173+
115174
export function cleanup(): void {
116175
mountedRootEntries.forEach(({ root, container }) => {
117176
act(() => {
@@ -157,7 +216,9 @@ function strictModeIfNeeded(innerElement: React.ReactNode) {
157216
: innerElement
158217
}
159218

160-
function wrapUiIfNeeded(innerElement: React.ReactNode, wrapperComponent?: React.JSXElementConstructor<{ children: React.ReactNode }>) {
219+
function wrapUiIfNeeded(innerElement: React.ReactNode, wrapperComponent?: React.JSXElementConstructor<{
220+
children: React.ReactNode
221+
}>) {
161222
return wrapperComponent
162223
? React.createElement(wrapperComponent, null, innerElement)
163224
: innerElement

test/fixtures/useCounter.ts

+12
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { useCallback, useState } from 'react'
2+
3+
export function useCounter(): {
4+
count: number
5+
increment: () => void
6+
} {
7+
const [count, setCount] = useState(0)
8+
9+
const increment = useCallback(() => setCount(x => x + 1), [])
10+
11+
return { count, increment }
12+
}

test/render-hook.test.tsx

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

0 commit comments

Comments
 (0)