Basic Hooks
Rendering
Imagine we have a simple hook that we want to test:
import { useState, useCallback } from 'react'export default function useCounter() {const [count, setCount] = useState(0)const increment = useCallback(() => setCount((x) => x + 1), [])return { count, increment }}
To test useCounter
we need to render it using the renderHook
function provided by
react-hooks-testing-library
:
import { renderHook } from '@testing-library/react-hooks'import useCounter from './useCounter'test('should use counter', () => {const { result } = renderHook(() => useCounter())expect(result.current.count).toBe(0)expect(typeof result.current.increment).toBe('function')})
As you can see, the result's current value matches what is returned by our hook.
Updates
The test shown above is great and all, but it doesn't actually test what we want to use the counter
for, i.e. counting. We can easily improve this test by calling the increment
function and checking
that the count
value increases:
import { renderHook, act } from '@testing-library/react-hooks'import useCounter from './useCounter'test('should increment counter', () => {const { result } = renderHook(() => useCounter())act(() => {result.current.increment()})expect(result.current.count).toBe(1)})
After increment
is called, the current count
value now reflects the new value returned by our
hook.
You may have also noticed that we also wrapped the increment
call in act
. This utility simulates
how our hook will act in a browser, allowing us to update the values within it. For more details on
act
, please see the React documentation.
NOTE: There's a gotcha with updates. renderHook
mutates the value of current
when updates
happen so you cannot destructure its values as the assignment will make a copy locking into the
value at that time.
Providing Props
Sometimes a hook relies on the props passed to it in order to do its thing. For example the
useCounter
hook could easily accept the initial value of the counter as a prop:
import { useState, useCallback } from 'react'export default function useCounter(initialValue = 0) {const [count, setCount] = useState(initialValue)const increment = useCallback(() => setCount((x) => x + 1), [])return { count, increment }}
Setting the initialValue
prop in our test is as easy as calling the hook with the value we want to
use:
import { renderHook, act } from '@testing-library/react-hooks'import useCounter from './useCounter'test('should increment counter from custom initial value', () => {const { result } = renderHook(() => useCounter(9000))act(() => {result.current.increment()})expect(result.current.count).toBe(9001)})
Props
Many of the hook primitives use an array of dependent values to determine when to perform specific
actions, such as recalculating an expensive value or running an effect. If we extend our
useCounter
hook to have a reset
function that resets the value to the initialValue
it might
look something like this:
import { useState, useCallback } from 'react'export default function useCounter(initialValue = 0) {const [count, setCount] = useState(initialValue)const increment = useCallback(() => setCount((x) => x + 1), [])const reset = useCallback(() => setCount(initialValue), [initialValue])return { count, increment, reset }}
Now, the only time the reset
function will be updated is if initialValue
changes. The most basic
way to handle changing the input props of our hook in a test is to simply update the value in a
variable and rerender the hook:
import { renderHook, act } from '@testing-library/react-hooks'import useCounter from './useCounter'test('should reset counter to updated initial value', () => {let initialValue = 0const { result, rerender } = renderHook(() => useCounter(initialValue))initialValue = 10rerender()act(() => {result.current.reset()})expect(result.current.count).toBe(10)})
This is fine, but if there are lots of props, it can become a bit difficult to have variables to
keep track of them all. Another option is to use the initialProps
option and newProps
of
rerender
:
import { renderHook, act } from '@testing-library/react-hooks'import useCounter from './useCounter'test('should reset counter to updated initial value', () => {const { result, rerender } = renderHook(({ initialValue }) => useCounter(initialValue), {initialProps: { initialValue: 0 }})rerender({ initialValue: 10 })act(() => {result.current.reset()})expect(result.current.count).toBe(10)})
Another case where this is useful is when you want to limit the scope of the variables being closed
over to just be inside the hook callback. The following (contrived) example fails because the id
value changes for both the setup and cleanup of the useEffect
call:
import { useEffect } from 'react'import { renderHook } from '@testing-library/react-hooks'import sideEffect from './sideEffect'test('should clean up side effect', () => {let id = 'first'const { rerender } = renderHook(() => {useEffect(() => {sideEffect.start(id)return () => {sideEffect.stop(id) // this id will get the new value when the effect is cleaned up}}, [id])})id = 'second'rerender()expect(sideEffect.get('first')).toBe(false)expect(sideEffect.get('second')).toBe(true)})
By using the initialProps
and newProps
the captured id
value from the first render is used to
clean up the effect, allowing the test to pass as expected:
import { useEffect } from 'react'import { renderHook } from '@testing-library/react-hooks'import sideEffect from './sideEffect'test('should clean up side effect', () => {const { rerender } = renderHook(({ id }) => {useEffect(() => {sideEffect.start(id)return () => {sideEffect.stop(id) // this id will get the old value when the effect is cleaned up}}, [id])},{initialProps: { id: 'first' }})rerender({ id: 'second' })expect(sideEffect.get('first')).toBe(false)expect(sideEffect.get('second')).toBe(true)})
This is a fairly obscure case, so pick the method that fits best for you and your test.