Тестирование useCallback и мемоизации

В React useCallback используется для мемоизации функций, чтобы предотвратить их пересоздание при каждом рендере компонента. Это особенно важно, когда функции передаются в дочерние компоненты или используются в эффектах (useEffect, useMemo). Тестирование таких функций требует понимания, что ключевой аспект здесь — поведение компонентов в ответ на изменения зависимостей, а не сама функция как объект.

Проверка мемоизации useCallback

useCallback возвращает мемоизированную версию функции, которая пересоздаётся только при изменении указанных зависимостей. Прямо протестировать внутреннюю ссылку функции обычно нет необходимости. Важнее убедиться, что:

  1. Компонент не перерендеривается лишний раз.
  2. Дочерние компоненты, зависящие от функции, ведут себя корректно при её передаче.

Пример:

import { render, screen, fireEvent } from '@testing-library/react';
import React, { useState, useCallback } from 'react';

function Counter({ onIncrement }) {
  return <button onCl ick={onIncrement}>Increment</button>;
}

function App() {
  const [count, setCount] = useState(0);

  const increment = useCallback(() => {
    setCount(prev => prev + 1);
  }, []);

  return (
    <>
      <div>Count: {count}</div>
      <Counter onIncrem ent={increment} />
    </>
  );
}

test('увеличивает счетчик при клике', () => {
  render(<App />);
  const button = screen.getByText('Increment');
  fireEvent.click(button);
  expect(screen.getByText('Count: 1')).toBeInTheDocument();
});

В данном примере тест проверяет эффект действия функции, а не её идентичность. Это ключевая стратегия: проверка поведения вместо прямой проверки объекта функции.

Проверка передачи функции в дочерние компоненты

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

Пример:

import React, { memo, useCallback, useState } from 'react';
import { render, fireEvent } from '@testing-library/react';

const Child = memo(({ onClick }) => {
  return <button onCl ick={onClick}>Click</button>;
});

function Parent() {
  const [count, setCount] = useState(0);

  const handleClick = useCallback(() => {
    setCount(prev => prev + 1);
  }, []);

  return (
    <>
      <div>Count: {count}</div>
      <Child onCl ick={handleClick} />
    </>
  );
}

test('Child не ререндерится без изменения callback', () => {
  const { rerender } = render(<Parent />);
  const button = document.querySelector('button');

  const initialRenderSpy = jest.fn();
  initialRenderSpy();
  fireEvent.click(button);

  rerender(<Parent />);
  expect(initialRenderSpy).toHaveBeenCalledTimes(1);
});

Использование memo позволяет проверить, что компонент не перерисовывается без необходимости, что является прямым следствием правильной работы useCallback.

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

Частой ошибкой является неверная настройка зависимостей в useCallback. Тестирование должно покрывать сценарии, когда:

  • Зависимость изменяется, и функция пересоздается.
  • Зависимость не изменяется, и функция остается прежней.

Пример:

function Component({ value }) {
  const handleClick = useCallback(() => {
    console.log(value);
  }, [value]);

  return <button onCl ick={handleClick}>Click</button>;
}

test('callback пересоздается при изменении value', () => {
  const { rerender } = render(<Component value={1} />);
  const firstButton = document.querySelector('button');
  const firstHandler = firstButton.onclick;

  rerender(<Component value={2} />);
  const secondButton = document.querySelector('button');
  const secondHandler = secondButton.onclick;

  expect(firstHandler).not.toBe(secondHandler);
});

Здесь проверяется смена функции при изменении зависимости, что критично для правильной работы мемоизации и предотвращения багов.

Мемоизация сложных вычислений с useMemo и useCallback

useCallback часто сочетается с useMemo, когда функции используют сложные вычисления. Тестирование должно охватывать:

  • Корректное вычисление результата.
  • Сохранение мемоизации при неизменных зависимостях.
  • Обновление вычислений при изменении зависимостей.

Пример:

import React, { useState, useCallback, useMemo } from 'react';
import { render, fireEvent } from '@testing-library/react';

function ExpensiveComponent({ factor }) {
  const [count, setCount] = useState(0);

  const multiply = useCallback(
    (num) => num * factor,
    [factor]
  );

  const result = useMemo(() => multiply(count), [count, multiply]);

  return (
    <>
      <div>Result: {result}</div>
      <button onCl ick={() => setCount(prev => prev + 1)}>Increase</button>
    </>
  );
}

test('результат пересчитывается при изменении count', () => {
  const { getByText } = render(<ExpensiveComponent factor={2} />);
  const button = getByText('Increase');
  fireEvent.click(button);
  expect(getByText('Result: 2')).toBeInTheDocument();
});

Тест проверяет корректность вычислений, а мемоизация гарантирует, что функция multiply не пересоздается без изменения factor.

Практические рекомендации

  • Проверять эффекты вызова функции, а не её ссылку напрямую.
  • Использовать jest.fn() для контроля вызовов дочерних компонентов.
  • Проверять пересоздание функции при изменении зависимостей.
  • Комбинировать useCallback с memo для оптимизации рендеров.
  • В сложных вычислениях применять useMemo совместно с useCallback и покрывать тестами изменения зависимостей.

Эти методы позволяют покрывать ключевые сценарии мемоизации, обеспечивая надёжность компонентов и предотвращая лишние перерендеры в React-приложениях.