Привет, Хабр!
Сегодня рассмотрим, как тестировать React-хуки с помощью @testing-library/react-hooks
.
Подход к базовым хукам
Сначала тестить будем на примере простого счётчика. Вот у нас хук:
import { useState, useCallback } from 'react'
export default function useCounter() {
const [count, setCount] = useState(0)
const increment = useCallback(() => setCount((x) => x + 1), [])
return { count, increment }
}
Хуки не рендерятся напрямую, их нужно оборачивать через renderHook
. Всё как с тестами компонентов, только у нас тут немного алхимии:
import { renderHook } from '@testing-library/react-hooks'
import useCounter from './useCounter'
test('should initialize counter', () => {
const { result } = renderHook(() => useCounter())
expect(result.current.count).toBe(0)
expect(typeof result.current.increment).toBe('function')
})
result.current
— это всегда актуальное значение. Нельзя деструктурировать const { count } = result.current
в начале и потом ожидать, что оно обновится — это снимок, а не ссылка. И вот тут ловят баги те, кто думает, что это как ref
.
Теперь проверим обновление. Тут уже придётся звать act()
— он нужен, чтобы React не ругался, что стейт меняется вне жизненного цикла.
import { renderHook, act } from '@testing-library/react-hooks'
test('should increment counter', () => {
const { result } = renderHook(() => useCounter())
act(() => {
result.current.increment()
})
expect(result.current.count).toBe(1)
})
Без act()
React может выкинуть ворнинг. Иногда даже не в этом тесте, а в следующем.
Передача параметров и сброс состояния
Теперь добавим чуть больше реальности. Скажем, наш счётчик может принимать initialValue
и делать reset()
:
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 }
}
И теперь хочется протестировать изменение initialValue
при перерендере. Вот простой способ:
test('should reset to new initial value after rerender', () => {
let initial = 5
const { result, rerender } = renderHook(() => useCounter(initial))
initial = 10
rerender()
act(() => result.current.reset())
expect(result.current.count).toBe(10)
})
Но на проде у вас, скорее всего, будет куча пропсов. Там let
не спасает. Поэтому лучше использовать initialProps
:
test('should reset to updated initial value with initialProps', () => {
const { result, rerender } = renderHook(({ init }) => useCounter(init), {
initialProps: { init: 3 }
})
rerender({ init: 7 })
act(() => result.current.reset())
expect(result.current.count).toBe(7)
})
Именно так можно протестировать поведение хука при смене параметров — и не попасть в ловушку мутабельных переменных.
Работа с контекстом и обёртками
Как только в хук прилетает useContext
— дело усложняется. Но не критично. Всё решается wrapper
-компонентом.
Вот как может выглядеть CounterContext
:
const CounterStepContext = React.createContext(1)
export const CounterStepProvider = ({ step, children }) => (
<CounterStepContext.Provider value={step}>{children}</CounterStepContext.Provider>
)
export function useCounter(initialValue = 0) {
const [count, setCount] = useState(initialValue)
const step = useContext(CounterStepContext)
const increment = useCallback(() => setCount((x) => x + step), [step])
return { count, increment }
}
А вот тест с контекстом:
test('should increment with custom context step', () => {
const wrapper = ({ children }) => <CounterStepProvider step={2}>{children}</CounterStepProvider>
const { result } = renderHook(() => useCounter(), { wrapper })
act(() => result.current.increment())
expect(result.current.count).toBe(2)
})
Можно и динамически менять step
:
test('should change step on rerender', () => {
const wrapper = ({ children, step }) => (
<CounterStepProvider step={step}>{children}</CounterStepProvider>
)
const { result, rerender } = renderHook(() => useCounter(), {
wrapper,
initialProps: { step: 2 }
})
act(() => result.current.increment())
expect(result.current.count).toBe(2)
rerender({ step: 5 })
act(() => result.current.increment())
expect(result.current.count).toBe(7)
})
Если вам ESLint начнёт жаловаться на отсутствие displayName
, просто отключите это правило в тесте:
/* eslint-disable react/display-name */
Иногда проще отключить один раз, чем писать отдельный компонент-обёртку.
Асинхронные хуки и waitForNextUpdate
Когда в хук завозят setTimeout
, fetch
, debounce
, — всё, обычный expect
уже не работает. Тут нужен waitForNextUpdate()
:
export function useCounter(initialValue = 0) {
const [count, setCount] = useState(initialValue)
const increment = useCallback(() => setCount(x => x + 1), [])
const incrementAsync = useCallback(() => setTimeout(increment, 100), [increment])
return { count, increment, incrementAsync }
}
Тест:
test('should async increment after delay', async () => {
const { result, waitForNextUpdate } = renderHook(() => useCounter())
result.current.incrementAsync()
await waitForNextUpdate()
expect(result.current.count).toBe(1)
})
act()
тут не нужен — waitForNextUpdate()
уже оборачивает всё в act
под капотом. Но если вы используете кастомный async-поток, то может понадобиться waitFor()
.
И ещё: если вы тестируете debounce
, throttle
или requestAnimationFrame
— скорее всего, стоит подменить таймеры через jest.useFakeTimers()
.
Обработка ошибок и граничные случаи
И наконец, тестировать ошибки. Да, можно и это. Вот хук, который выкидывает исключение, если счётчик выше 9000:
export function useCounter(initialValue = 0) {
const [count, setCount] = useState(initialValue)
const increment = useCallback(() => setCount((x) => x + 1), [])
if (count > 9000) {
throw new Error("It's over 9000!")
}
return { count, increment }
}
И вот как это тестируется:
test('should throw when over 9000', () => {
const { result } = renderHook(() => useCounter(9000))
act(() => result.current.increment())
expect(result.error).toEqual(Error("It's over 9000!"))
})
result.error
— редкая, но полезная штука. Она содержит исключение, если оно произошло в процессе рендера. Такой способ отлично подходит для useMemo
, useEffect
, useReducer
— если ошибка происходит на первом рендере.
Подробнее с инструментом можно ознакомиться здесь.
Готовы углубить знания в React и освоить эффективное тестирование хуков? Приглашаем вас на два открытых урока, где опытные эксперты расскажут, как правильно использовать @testing‑library/react‑hooks и работать с асинхронными сценариями:
Как стать уверенным JavaScript‑разработчиком: план от джуна до мидла — 10 июля в 20:00
Зачем JavaScript‑разработчику понимать бэкенд? От fetch до Node.js — 23 июля в 20:00
Кроме того, пройдите вступительный тест и узнайте, насколько хорошо вы уже владеете тестированием React‑компонентов и хуков. Это отличный способ выявить свои сильные стороны и области для развития.