open atlas
↑ К треку
React с нуля до senior RCT · 07 · 03

Тестирование хуков и контекста: renderHook, фейковые таймеры, устаревшие снимки

renderHook монтирует одноразовый компонент-хост вокруг хука; result.current — живое свойство: перечитывайте после каждого act, деструктурированная копия — замороженный рендер. wrapper внедряет провайдеры. Фейковые таймеры вешают user-event, пока setup не получит advanceTimers.

RCT Senior ◷ 18 min
Уровень
ОсновыJuniorMiddleSenior

Миграция автосохранения должна была занять вечер: перевести тесты хука useAutosave с реальных ожиданий 800-мс дебаунса на фейковые таймеры и срезать четыре минуты со сьюта. Вместо этого первый же сконвертированный тест повесил CI-джобу до десятиминутного kill-сигнала. Локально он висел тоже — без ошибки, поначалу без сообщения о таймауте, просто замёрзший await user.type(editor, "draft"). Инженер бисектил почти день: не хук, не MSW, не версия React. Дедлок был структурным: vi.useFakeTimers() глобально патчит setTimeout, а user-event планирует свои межклавишные задержки через ровно этот пропатченный setTimeout — и ждёт таймер, который никто никогда не продвинет, потому что тест сам ждёт user-event. Замороженные часы ждут тест; тест ждёт замороженные часы. Лечение — одна строка: userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) — задокументированная, известная и всё равно переоткрываемая на собственной шкуре командой за командой. Та же миграция вскрыла вторую классику: проверку на деструктурированном const { current } = result, вечно читавшую значение до дебаунса, потому что result.current — живое свойство, а деструктурированная копия — снимок одного рендера. Два бага, ноль продуктового кода — оба в модели времени и идентичности, которую держали тесты.

Что на самом деле делает renderHook

Хуки не работают вне компонента — Правила React это физика, а не стиль. renderHook (поставляется в @testing-library/react) строит минимальную легальную физическую лабораторию: монтирует одноразовый компонент-хост, вызывает в нём ваш хук с initialProps и копирует возвращаемое значение хука в result.current после каждого закоммиченного рендера. Больше ничего особенного — именно поэтому API имеет такую форму:

const { result, rerender, unmount } = renderHook(
  ({ userId }) => usePermissions(userId),
  {
    initialProps: { userId: "u1" },
    wrapper: ({ children }) => (
      <PermissionsProvider value={stubPermissions}>{children}</PermissionsProvider>
    ),
  },
);

rerender({ userId: "u2" });           // перерендеривает хост с новыми пропсами
expect(result.current.canEdit).toBe(false);
unmount();                            // запускает cleanup эффектов — тестируйте и их

wrapper — точка внедрения провайдеров, и в ней спрятано проектное решение: передавайте стабовое значение провайдера — простой объект, реализующий контракт контекста, — а не vi.mock модуля провайдера. Стабовое значение прогоняет настоящую проводку useContext и переживает рефакторинги провайдера; мок модуля привязывает тест к путям файлов и форме экспортов. Для контекста вроде query-клиента правило острее: создавайте свежий клиент на каждый тест внутри wrapper, иначе закешированные данные текут между тестами и фабрикуют зависимость от порядка.

Когда хуку вообще положен собственный тест? Тестируйте хук напрямую, когда он и есть публичный контракт — общий usePagination, потребляемый двенадцатью командами, заслуживает контрактных тестов независимо от любого потребителя. Когда хук существует ради одного компонента — тестируйте компонент: тест хука может оставаться зелёным при сломанной интеграции (неверная протяжка пропсов, тайминг рендера), а сеньорский сьют тратит бюджет там, где живёт видимый пользователю контракт.

Викторина

Тест делает: const { current } = result; await act(async () => result.current.save()); expect(current.status).toBe('saved') — и падает со status 'idle'. В продакшене хук работает. Что не так?

Чтение result.current: идентичность, а не тайминг

result.current — свойство, которое харнесс переприсваивает после каждого коммита. Когда видите в тесте деструктуризацию result в начале и чтение из неё позже — флагуйте сразу: это самый распространённый скрытый баг в сьютах с хуками. Три следствия, каждое — пункт код-ревью. Первое — никогда не деструктурируйте: const { current } = result (или сохранённое «на потом» const value = result.current) замораживает выдачу одного рендера; каждая проверка после следующего обновления состояния читает историю. Правильная идиома скучно повторяется: result.current.x в каждой точке чтения, потому что каждое чтение должно наблюдать последний коммит. Второе — обновлениям нужен act: вызов result.current.save() вне act запускает обновление без детерминированного флаша (и предупреждает); оборачивайте меняющие состояние вызовы, await асинхронные. Третье — после unmount() result.current хранит последнее закоммиченное значение: это удобно для проверки финального состояния, но «чтение без ошибки» не доказывает, что хук жив; unmount на самом деле проверяет cleanup — интервалы очищены, подписки закрыты, висящий дебаунс сброшен или отменён — то, что обещает ваш контракт.

Фейковые таймеры, дебаунс и дедлок user-event

Дебаунс- или интервальный хук, тестируемый по реальным часам, стоит реальные секунды на тест и всё равно флейкает под нагрузкой CI. Фейковые таймеры делают время управляемым входом: vi.useFakeTimers() подменяет глобальные функции таймеров виртуальными часами, а vi.advanceTimersByTime(800) выстреливает ровно те колбэки, что подошли в этом окне, — детерминированно, мгновенно, независимо от нагрузки. Дисциплина вокруг них:

it("сохраняет один раз после 800 мс тишины", async () => {
  vi.useFakeTimers();
  const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
  render(<Editor />);

  await user.type(screen.getByRole("textbox"), "draft");

  act(() => { vi.advanceTimersByTime(799); });
  expect(saveSpy).not.toHaveBeenCalled();      // граница: ничего на 799

  act(() => { vi.advanceTimersByTime(1); });
  expect(saveSpy).toHaveBeenCalledTimes(1);    // срабатывает ровно на 800

  vi.useRealTimers();                          // всегда восстанавливайте — надёжнее в afterEach
});

Опция advanceTimers — разрушитель дедлока из Hook: user-event темпирует свои цепочки событий через setTimeout, поэтому под замороженными фейковыми часами каждый await user.type(...) ждёт вечно, пока user-event не научат продвигать часы самому. Прописывайте advanceTimers: vi.advanceTimersByTime при setup — каждая команда, взявшая фейковые таймеры без этого, теряет день на повисшем тесте. Паттерн граничных проверок (799, затем 1) и делает дебаунс-тест стоящим: он пиннит контракт — срабатывает один раз, срабатывает в дедлайн, не срабатывает раньше, — а не «когда-нибудь вызвался». Два честных предостережения не дают технике укусить в ответ. Фейковые таймеры подделывают таймеры, а не микротаски: продолжения зарезолвленных промисов по-прежнему идут через реальную очередь микротасок, поэтому advanceTimersByTime внутри act плюс await после — форма, дающая стечь обеим очередям. И утёкшие фейковые часы — заразитель всего сьюта: восстанавливайте в afterEach(() => vi.useRealTimers()), потому что waitFor следующего теста опрашивает через тот самый setTimeout, который вы заморозили, и его секундный бюджет даже не начинает тикать.

Викторина

После vi.useFakeTimers() тест навсегда виснет на: await user.type(input, 'abc'). Ни одна проверка ещё не выполнилась. Каков механизм?

Почему это работает

Зачем user-event вообще планирует таймеры — не проще ли события с нулевой задержкой? Задержки не декорация. Между событиями цепочки user-event уступает управление, чтобы React обработал каждое событие как браузер: keydown коммитит состояние до того, как выстрелит keyup, эффекты фокуса отрабатывают до приземления клика. Схлопните эту разрядку — и вы сфабрикуете чередования событий, которых не порождает ни один браузер: обработчики ввода видят несфлашенное состояние предыдущего нажатия, ровно тот класс ложных сигналов, для устранения которого user-event существует. Уступка реализована через setTimeout, поэтому взаимодействие с фейковыми таймерами структурно, а не случайно: любой честный симулятор событий обязан ждать, а всё, что ждёт на замороженных часах, дедлочится, пока ему не вручат рукоятку. advanceTimers — эта рукоятка: user-event крутит её вместо ожидания, и симулированный темп с виртуальным временем движутся вместе.

Вспомните перед уходом
  1. 01
    Объясните, что строит renderHook, семантику result.current и два способа, которыми тесты читают его неправильно.
  2. 02
    Пройдите дисциплину фейковых таймеров для дебаунс-хука, включая дедлок user-event и оговорку про микротаски.
Итог

Тестирование хуков — две дисциплины стопкой: идентичность и время. Половина про идентичность начинается с того, чем renderHook является на самом деле, — минимальным компонентом-хостом, который вызывает ваш хук и переприсваивает result.current после каждого коммита. Это делает result.current живым свойством, и каждое злоупотребление следует из забывания этого факта: деструктурированный const current — один замороженный рендер, вечно читающий историю; меняющий состояние вызов вне act — несфлашенное обновление; чтение после unmount показывает последнее закоммиченное значение, что доказывает отработку cleanup, а не жизнь хука. Опция wrapper внедряет контекст, и сеньорский выбор там — стабовое значение провайдера вместо мока модуля: прогоняется настоящий путь useContext и переживаются рефакторинги провайдера, — плюс свежий инстанс на тест для всего стейтфулного, иначе кеш фабрикует зависимость от порядка. Тестируйте хуки напрямую, только когда хук — общий публичный контракт; хук одного потребителя лучше покрыт через его компонент, где живёт видимая пользователю интеграция. Половина про время: фейковые таймеры превращают часы в управляемый вход — advanceTimersByTime выстреливает ровно подошедшее, и дебаунс-тест проверяет свой настоящий контракт на границе (ничего на 799 мс, ровно один вызов на 800) мгновенно и детерминированно. Структурная ловушка — дедлок user-event из Hook: user-event темпирует настоящие цепочки событий через setTimeout, замороженные часы их никогда не выстреливают, и тест ждёт user-event, пока user-event ждёт часы, — разрывается только проводкой advanceTimers: vi.advanceTimersByTime в setup. Две оговорки держат технику честной: микротаски не подделываются, поэтому продвинуть-затем-await даёт стечь обеим очередям; а фейковые часы, утёкшие мимо afterEach, замораживают бюджет waitFor следующего теста, порождая флейки, которые выглядят посторонними. Обе половины награждают одну привычку — точно моделируйте, чем управляет харнесс, и повисшие тесты с устаревшими чтениями исчезают. Теперь, когда видите vi.useFakeTimers() в файле теста, первый рефлекс — проверить, прописан ли advanceTimers в userEvent.setup; а когда видите const { current } = result — флагуете до мержа PR.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 6 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources4
expand
  1. 01
  2. 02
  3. 03
  4. 04

Trademarks belong to their respective owners. Editorial reference only.