Тестирование useEffect и его зависимостей

В React хук useEffect используется для управления побочными эффектами, такими как запросы к API, подписки, таймеры и изменение DOM вне реактивного цикла. Тестирование компонентов с useEffect требует понимания того, как этот хук реагирует на зависимости и как асинхронные операции влияют на рендер.

Основы работы useEffect

Хук useEffect принимает два аргумента:

useEffect(() => {
  // побочный эффект
  return () => {
    // очистка эффекта (необязательная)
  };
}, [dependencies]);
  • Функция эффекта выполняется после рендера компонента.

  • Массив зависимостей определяет, когда эффект будет повторно вызываться.

    • Пустой массив ([]) → эффект выполняется один раз при монтировании.
    • Массив с зависимостями ([dep1, dep2]) → эффект вызывается при изменении любой зависимости.
    • Без массива → эффект выполняется после каждого рендера.

При тестировании важно учитывать, что асинхронные эффекты могут завершаться позже, чем основной рендер, и это необходимо обрабатывать через waitFor, findBy и другие утилиты React Testing Library.


Тестирование эффекта с пустым массивом зависимостей

Пример компонента, который делает один запрос к API при монтировании:

import { useEffect, useState } from 'react';

export function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(data => setUser(data));
  }, [userId]);

  if (!user) return <div>Loading...</div>;
  return <div>{user.name}</div>;
}

Тестирование с помощью React Testing Library:

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

global.fetch = jest.fn(() =>
  Promise.resolve({
    json: () => Promise.resolve({ name: 'Alice' }),
  })
);

test('загружает и отображает пользователя', async () => {
  render(<UserProfile userId={1} />);

  expect(screen.getByText(/loading/i)).toBeInTheDocument();

  await waitFor(() => expect(screen.getByText('Alice')).toBeInTheDocument());
});

Ключевые моменты:

  • Используется мок fetch, чтобы не обращаться к реальному API.
  • Асинхронность обрабатывается через waitFor, который ждёт изменения DOM.
  • Проверка эффекта происходит через конечный результат (Alice), а не через внутренние вызовы хука.

Тестирование эффектов с зависимостями

Если массив зависимостей содержит значения, тест должен проверять поведение при их изменении.

Пример компонента, реагирующего на изменение userId:

export function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    setUser(null);
    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(data => setUser(data));
  }, [userId]);

  if (!user) return <div>Loading...</div>;
  return <div>{user.name}</div>;
}

Тестирование изменения зависимости:

import { render, screen, waitFor } from '@testing-library/react';
import { UserProfile } from './UserProfile';
import { act } from 'react-dom/test-utils';

test('обновляет данные при изменении userId', async () => {
  const { rerender } = render(<UserProfile userId={1} />);

  await waitFor(() => expect(screen.getByText('Alice')).toBeInTheDocument());

  global.fetch.mockImplementationOnce(() =>
    Promise.resolve({
      json: () => Promise.resolve({ name: 'Bob' }),
    })
  );

  rerender(<UserProfile userId={2} />);

  expect(screen.getByText(/loading/i)).toBeInTheDocument();

  await waitFor(() => expect(screen.getByText('Bob')).toBeInTheDocument());
});

Особенности:

  • Используется rerender, чтобы симулировать изменение пропсов.
  • Для нового эффекта создаётся отдельный мок fetch.
  • Сначала проверяется состояние загрузки, затем конечный результат.

Очистка эффекта и её проверка

Если эффект возвращает функцию очистки (cleanup), важно убедиться, что она вызывается при размонтировании или при изменении зависимостей.

Пример с подпиской:

import { useEffect, useState } from 'react';

export function Timer() {
  const [seconds, setSeconds] = useState(0);

  useEffect(() => {
    const id = setInterval(() => setSeconds(s => s + 1), 1000);
    return () => clearInterval(id);
  }, []);

  return <div>{seconds}</div>;
}

Тест с таймерами:

import { render, screen } from '@testing-library/react';
import { Timer } from './Timer';
import { act } from 'react-dom/test-utils';

jest.useFakeTimers();

test('таймер увеличивает секунды', () => {
  render(<Timer />);
  expect(screen.getByText('0')).toBeInTheDocument();

  act(() => {
    jest.advanceTimersByTime(1000);
  });
  expect(screen.getByText('1')).toBeInTheDocument();

  act(() => {
    jest.advanceTimersByTime(2000);
  });
  expect(screen.getByText('3')).toBeInTheDocument();
});

Особенности:

  • Используется jest.useFakeTimers(), чтобы управлять временем в тестах.
  • act() оборачивает изменения состояния, связанные с таймерами, чтобы правильно отразить их в DOM.

Проверка зависимости useEffect на конкретные изменения

В сложных компонентах важно тестировать, что эффект не вызывается лишний раз, если зависимости не изменились.

const effectFn = jest.fn();

function Component({ value }) {
  useEffect(() => {
    effectFn();
  }, [value]);

  return <div>{value}</div>;
}

test('эффект вызывается только при изменении value', () => {
  const { rerender } = render(<Component value={1} />);
  expect(effectFn).toHaveBeenCalledTimes(1);

  rerender(<Component value={1} />);
  expect(effectFn).toHaveBeenCalledTimes(1);

  rerender(<Component value={2} />);
  expect(effectFn).toHaveBeenCalledTimes(2);
});

Ключевые моменты:

  • Проверка количества вызовов эффекта через jest.fn().
  • Использование rerender для имитации изменений зависимостей.
  • Подчёркивается разница между изменением значения и неизменным пропсом.

Асинхронные эффекты и ошибки

Для асинхронных эффектов важно обрабатывать возможные ошибки, чтобы тесты не зависали:

useEffect(() => {
  let mounted = true;
  fetch(`/api/users/${userId}`)
    .then(res => res.json())
    .then(data => {
      if (mounted) setUser(data);
    })
    .catch(err => console.error(err));
  return () => { mounted = false; };
}, [userId]);

В тестах проверка обработки ошибок:

global.fetch.mockImplementationOnce(() => Promise.reject('API Error'));

render(<UserProfile userId={3} />);
await waitFor(() => expect(screen.getByText(/loading/i)).toBeInTheDocument());

Это позволяет предотвратить обновление состояния после размонтирования компонента.


Рекомендации по тестированию useEffect

  • Всегда мокать внешние зависимости (API, таймеры, подписки).
  • Асинхронные эффекты проверять через waitFor, findBy, act().
  • Для эффектов с зависимостями использовать rerender и проверять конечное состояние.
  • Проверять очистку эффекта, особенно при таймерах и подписках.
  • Следить за тем, чтобы эффект не вызывался лишний раз, тестируя через jest.fn().
  • Асинхронные ошибки и отмену эффектов нужно учитывать, чтобы избежать “memory leaks” в тестах.

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