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 #11

Merged
merged 4 commits into from
Feb 10, 2025
Merged
Show file tree
Hide file tree
Changes from 2 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
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,24 @@ test('counter button increments the count', async () => {
> 💡 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.
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).

use `renderHook` to test React Hook

```tsx
import { renderHook } from 'vitest-browser-react'
import { expect, test } from 'vitest'
import { act } from 'react'

test('should increment counter', async () => {
const { result } = renderHook(() => useCounter())

act(() => {
result.current.increment()
})

expect(result.current.count).toBe(1)
})
```

`vitest-browser-react` also automatically injects `render` method on the `page`. Example:

```ts
Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { page } from '@vitest/browser/context'
import { beforeEach } from 'vitest'
import { cleanup, render } from './pure'

export { render, cleanup } from './pure'
export { render, renderHook, cleanup } from './pure'
export type { ComponentRenderOptions, RenderResult } from './pure'

page.extend({
Expand Down
63 changes: 62 additions & 1 deletion src/pure.ts → src/pure.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,65 @@ export function render(
}
}

export interface RenderHookOptions<Props> extends ComponentRenderOptions {
/**
* 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 | undefined
}

export interface RenderHookResult<Result, Props> {
/**
* Triggers a re-render. The props will be passed to your renderHook callback.
*/
rerender: (props?: Props) => void
/**
* 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
}

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

const result = React.createRef<Result>() as unknown as { current: Result }

function TestComponent({ renderCallbackProps }: { renderCallbackProps?: Props }) {
const pendingResult = renderCallback(renderCallbackProps)

React.useEffect(() => {
result.current = pendingResult
})

return null
}

const { rerender: baseRerender, unmount } = render(
<TestComponent renderCallbackProps={initialProps} />,
renderOptions,
)

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

return { result, rerender, unmount }
}

export function cleanup(): void {
mountedRootEntries.forEach(({ root, container }) => {
act(() => {
Expand Down Expand Up @@ -157,7 +216,9 @@ function strictModeIfNeeded(innerElement: React.ReactNode) {
: innerElement
}

function wrapUiIfNeeded(innerElement: React.ReactNode, wrapperComponent?: React.JSXElementConstructor<{ children: React.ReactNode }>) {
function wrapUiIfNeeded(innerElement: React.ReactNode, wrapperComponent?: React.JSXElementConstructor<{
children: React.ReactNode
}>) {
return wrapperComponent
? React.createElement(wrapperComponent, null, innerElement)
: innerElement
Expand Down
12 changes: 12 additions & 0 deletions test/fixtures/useCounter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { useCallback, useState } from 'react'

export function useCounter(): {
count: number
increment: () => void
} {
const [count, setCount] = useState(0)

const increment = useCallback(() => setCount(x => x + 1), [])

return { count, increment }
}
60 changes: 60 additions & 0 deletions test/render-hook.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { expect, test } from 'vitest'
import type { PropsWithChildren } from 'react'
import React, { act } from 'react'
import { renderHook } from '../src/index'
import { useCounter } from './fixtures/useCounter'

test('should increment counter', () => {
const { result } = renderHook(() => useCounter())

act(() => {
result.current.increment()
})

expect(result.current.count).toBe(1)
})

test('allows rerendering', () => {
const { result, rerender } = renderHook(
(initialProps) => {
const [left, setLeft] = React.useState('left')
const [right, setRight] = React.useState('right')

switch (initialProps?.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 }: PropsWithChildren) {
return <Context.Provider value="provided">{children}</Context.Provider>
}
const { result } = renderHook(
() => {
return React.useContext(Context)
},
{
wrapper: Wrapper,
},
)

expect(result.current).toEqual('provided')
})