Реидентификация и cleanup хук-ов

React Testing Library строится вокруг идеи тестирования поведения, а не реализации. Однако на уровне тестового раннера остаётся важная техническая деталь — жизненный цикл смонтированных компонентов. Каждый вызов render создаёт новый экземпляр дерева React, но без корректной очистки предыдущее состояние может сохраниться в DOM и повлиять на последующие проверки.

Реидентификация в контексте тестирования — это ситуация, когда React или тестовый код ошибочно воспринимает старый экземпляр компонента как актуальный, либо когда тест обращается к DOM, содержащему остатки предыдущего рендера. Это особенно критично при:

  • использовании глобального document.body
  • тестировании порталов (createPortal)
  • работе с таймерами, подписками, эффектами
  • повторном использовании container

React Testing Library решает эту проблему через централизованный механизм очистки (cleanup).


Механизм cleanup в React Testing Library

Cleanup — это процесс размонтирования всех React-компонентов, созданных в тесте, и удаления их DOM-узлов из документа.

Внутренне cleanup выполняет следующие действия:

  • вызывает unmount() для каждого дерева React
  • удаляет контейнеры из document.body
  • сбрасывает ссылки, используемые RTL для отслеживания рендеров

Пример базового сценария:

import { render, screen } from '@testing-library/react';

test('отображается заголовок', () => {
  render(<h1>Заголовок</h1>);
  expect(screen.getByText('Заголовок')).toBeInTheDocument();
});

После завершения теста RTL автоматически выполнит cleanup, если среда настроена корректно.


Автоматический cleanup и роль afterEach

Начиная с версии @testing-library/react 9+, cleanup подключается автоматически через afterEach, если используется Jest, Vitest или аналогичный раннер с глобальным API.

Эквивалент внутренней логики:

afterEach(() => {
  cleanup();
});

Это означает:

  • каждый тест начинается с чистого DOM
  • предыдущие эффекты useEffect будут корректно уничтожены
  • componentWillUnmount будет вызван
  • подписки и таймеры должны быть очищены (при корректной реализации компонента)

Ручное добавление cleanup в afterEach требуется только в нестандартных окружениях или при отключённой автоинициализации.


Когда автоматический cleanup не срабатывает

Существуют сценарии, где очистка может быть пропущена или недостаточна:

1. Кастомная среда тестирования

При использовании нестандартного раннера или отключённого @testing-library/jest-dom автоматический cleanup может не подключиться.

2. Ручное управление контейнером

const container = document.createElement('div');
document.body.appendChild(container);

render(<App />, { container });

Если контейнер создаётся вручную и не передаётся под контроль RTL, он не будет удалён автоматически.

3. Использование render вне тестов

Рендеринг компонентов в beforeAll или на уровне модуля приводит к утечкам состояния:

const utils = render(<App />); // анти-паттерн

В этом случае cleanup не привязан к жизненному циклу теста.


Ручной cleanup и управление жизненным циклом

В сложных тестах допустимо вызывать cleanup вручную:

import { render, cleanup } from '@testing-library/react';

afterEach(() => {
  cleanup();
});

Это полезно, когда:

  • тест управляет несколькими рендерами
  • используются порталы вне document.body
  • требуется точный контроль над размонтированием

Важно понимать, что cleanup() удаляет все смонтированные деревья, а не только последнее.


Реидентификация и повторный render

RTL позволяет вызывать render несколько раз в рамках одного теста. При этом повторный вызов:

const { rerender } = render(<Component a={1} />);
rerender(<Component a={2} />);

не создаёт новый экземпляр, а обновляет существующий. Это контролируемая реидентификация — React сохраняет состояние, хуки не пересоздаются, useEffect отрабатывает по правилам зависимостей.

В отличие от этого, повторный render без rerender:

render(<Component />);
render(<Component />);

создаёт два независимых дерева, оба будут удалены при cleanup.


Cleanup и хуки React

Очистка напрямую влияет на корректность работы хуков.

useEffect

При cleanup вызывается функция возврата эффекта:

useEffect(() => {
  const id = setInterval(fn, 1000);
  return () => clearInterval(id);
}, []);

Если cleanup не произошёл, таймер продолжит работать между тестами.

useLayoutEffect

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

Пользовательские хуки

Если кастомный хук использует:

  • подписки
  • глобальные слушатели
  • сторонние SDK

то отсутствие cleanup приведёт к накоплению состояния и неустойчивым тестам.


Cleanup и асинхронные операции

Асинхронные тесты требуют особого внимания. Cleanup запускается после завершения теста, но если внутри остаются незавершённые промисы или таймеры, возможны побочные эффекты.

Типичный источник проблем:

test('асинхронный запрос', async () => {
  render(<Component />);
  await screen.findByText('Готово');
});

Если компонент инициирует асинхронный эффект без корректной отмены, React может попытаться обновить состояние уже после cleanup, вызывая warning.

Корректный паттерн — отмена асинхронных операций внутри useEffect:

useEffect(() => {
  let active = true;
  fetchData().then(data => {
    if (active) setState(data);
  });
  return () => { active = false; };
}, []);

Cleanup и порталы

Компоненты, использующие createPortal, часто рендерятся вне основного контейнера. RTL отслеживает порталы, созданные через render, но при ручном управлении DOM возможны утечки:

const modalRoot = document.getElementById('modal-root');

Если modal-root не удаляется между тестами, контент портала может сохраниться. Рекомендуется:

  • создавать modal-root в beforeEach
  • удалять в afterEach
  • не использовать глобальный DOM без очистки

Отладка проблем с cleanup

Признаки отсутствия корректной очистки:

  • тесты проходят по отдельности, но падают вместе
  • в DOM присутствуют элементы, не относящиеся к текущему тесту
  • warnings о state update on unmounted component
  • нестабильные асинхронные ожидания

Практики диагностики:

  • screen.debug() перед и после теста
  • проверка document.body.innerHTML
  • временное отключение auto cleanup для анализа

Осознанное управление реидентификацией

Чистые тесты опираются на жёсткую изоляцию:

  • один тест — один сценарий
  • отсутствие разделяемого DOM
  • отсутствие общего состояния между тестами
  • предсказуемый жизненный цикл компонентов

Cleanup в React Testing Library — не вспомогательная утилита, а ключевой механизм, обеспечивающий корректную реидентификацию компонентов и надёжность всей тестовой системы.