Как тестировать хуки в React с @testing-library/react-hooks

от автора

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

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


ссылка на оригинал статьи https://habr.com/ru/articles/923214/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *