Привет, Хабр!
Сегодня рассмотрим, как тестировать 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‑компонентов и хуков. Это отличный способ выявить свои сильные стороны и области для развития.
