Привет, Хабр!

Сегодня рассмотрим, как тестировать 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 и работать с асинхронными сценариями:

  1. Как стать уверенным JavaScript‑разработчиком: план от джуна до мидла — 10 июля в 20:00

  2. Зачем JavaScript‑разработчику понимать бэкенд? От fetch до Node.js — 23 июля в 20:00

Кроме того, пройдите вступительный тест и узнайте, насколько хорошо вы уже владеете тестированием React‑компонентов и хуков. Это отличный способ выявить свои сильные стороны и области для развития.

Комментарии (0)