useCart - кастомный хук приложения оформления заказа. Кастомный хук - это функция с именем на use, которая вызывает другие хуки: useState для позиций, useMemo для итога. useCart держит корзину: items, addItem, removeItem, applyCoupon и вычисляемый total со скидкой. Его зовут и панель корзины, и страница оформления - переиспользуемая логика, не привязанная к одному экрану.
Отсюда вопрос: проверять хук напрямую или через компонент. Если хук - тонкая склейка над состоянием компонента, тестируйте через компонент, глазами пользователя. Если он держит логику, которую делят несколько компонентов, он заслуживает собственного теста в изоляции - описывающего его контракт независимо от интерфейса.
Наивный ход - считать хук обычной функцией и вызвать его: const cart = useCart(); cart.addItem(...). Ломается сразу: React бросает Invalid hook call, ведь хуки разрешено запускать только внутри рендера компонента - таковы правила хуков. Вне рендера нет фибры, где жило бы состояние.
Решает это renderHook из @testing-library/react. Он монтирует крошечный одноразовый компонент, задача которого - вызвать ваш хук и сохранить возвращаемое значение. Обратно вы получаете result, rerender и unmount. Теперь хук исполняется в настоящем рендере React.
Значение живёт в result.current. result - ссылка на последнее закоммиченное возвращаемое значение, думайте о нём как о ref. Каждый рендер подменяет содержимое. Поэтому result.current читают в момент проверки, а не сохраняют заранее в переменную - копия останется снимком старого рендера и протухнет.
Обновления состояния оборачивают в act. act велит React применить изменение и закоммитить его до проверки; иначе прочитаете значение до обновления, а Jest предупредит not wrapped in act(...). Вызов result.current.addItem внутри act(() => {...}) применяет изменение и обновляет result.current. В компонентных тестах это делают userEvent и findBy сами - в голом тесте хука вы оборачиваете вручную.
Хук, реагирующий на аргументы, проверяют через rerender. useDebouncedValue(value, 300) возвращает значение с задержкой - им гасят частый ввод в поле промокода. renderHook принимает initialProps, затем rerender({ value: next }) имитирует смену пропа, вы прокручиваете фейковые таймеры и проверяете, что result.current обновился - это реакция на меняющийся вход, не только первый монтаж.
Хук, читающий контекст, нуждается в провайдерах над собой. Серверная корзина ходит за данными через QueryClientProvider, общая - через свой CartProvider. Опция wrapper принимает компонент, оборачивающий children в нужный провайдер, и renderHook монтирует хук под ним, иначе он упадёт, не найдя контекста.
Асинхронный хук, который запускает запрос и оседает позже, проверяют через waitFor. await waitFor(() => expect(result.current.isSuccess).toBe(true)) повторяет проверку, пока она не пройдёт или не выйдет таймаут, и сам оборачивает обновления в act. Ручные задержки на setTimeout тут не нужны.
Цена изоляции - связанность с формой хука: такой тест знает сигнатуру плотнее компонентного и оправдан, лишь когда логика правда переиспользуема; тривиальную склейку тестируйте через компонент. Провальные сценарии: проверка протухшего result.current, снятого до act; проигнорированное предупреждение act, скрывающее незакоммиченное обновление; и проверка детали реализации вместо контракта хука. Проверяйте контракт: какие значения хук отдаёт и что делают его действия.
import { renderHook, act } from '@testing-library/react'
import { useCart } from './useCart'
test('купон уменьшает итог на размер скидки', () => {
const { result } = renderHook(() => useCart())
act(() => {
result.current.addItem({ id: 'sku-1', price: 1_000 })
result.current.applyCoupon('WELCOME10')
})
// читаем result.current здесь, а не копию, снятую до act
expect(result.current.total).toBe(900)
})import { renderHook, waitFor } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import type { ReactNode } from 'react'
import { useServerCart } from './useServerCart'
test('подтягивает корзину с сервера', async () => {
const client = new QueryClient()
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={client}>{children}</QueryClientProvider>
)
const { result } = renderHook(() => useServerCart('user-1'), { wrapper })
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(result.current.data?.items).toHaveLength(2)
})