Думаю, React-хуки не нуждаются в особом представлении, поэтому можно пропустить их описание и приступить сразу к делу.

Для тех, кто хочет сразу «пощупать» код, я подготовил репозиторий на GitHub с примерами из данной статьи.

ДИСКЛЕЙМЕР

Статья была обновлена по состоянию на октябрь 2023-го года, так как часть материала потеряла актуальность, а также дополнена примерами с использованием Vitest для тестирования, поскольку данный инструмент активно набирает популярность среди сообщества.

Минимальные требования

Данная статья предполагает, что у вас уже есть хотя бы минимальный опыт работы с React-хуками. Например, с такими как useEffect, useState и useRef. Также желателен опыт работы с TypeScript, Jest или Vitest и React Testing Library.

Быстрая настройка

Jest

Вы можете настроить инструменты тестирования самостоятельно, но для быстрого старта можно использовать утилиту create-react-app, которая предоставляет возможность тестирования с использованием Jest и React Testing Library «из коробки». Данная утилита имеет встроенную поддержку TypeScript.

Если вы хотите использовать Next.js, то вы можете узнать как настроить Jest и React Testing Library для Next.js здесь.

Vitest

Также вы можете использовать утилиту create-vite, если хотите использовать Vite в качестве сборщика для вашего приложения и писать тесты на Vitest. Однако, это требует дополнительного шага в виде настройки Vitest и React Testing Library.

Чтобы создать проект с помощью create-vite с поддержкой TypeScript вам необходимо указать соответствующий шаблон (react-ts или react-swc-ts).

Узнать подробнее о том, как настроить Vitest вы можете здесь. На той же странице есть раздел с примерами, среди которых есть пример с React Testing Library.

Для Next.js тоже есть готовый пример с настроенным Vitest.

Что мы будем делать

Наша главная цель — написать собственные React-хуки на TypeScript и протестировать их, получив 100%-е покрытие тестами.

Здесь сразу стоит оговориться, что не всегда 100%-е покрытие действительно необходимо в вашем приложении. Но в данном материале мы все равно попытаемся достичь его, чтобы поближе познакомиться с тестированием хуков.

ВАЖНОЕ ОБНОВЛЕНИЕ

На момент написания оригинальной статьи React 18 еще не был выпущен. В изначальном варианте статьи использовались React 17 и пакет @testing-library/react-hooks для тестирования хуков.

Но даже после релиза React 18 пакет @testing-library/react-hooks не поддерживал его — сейчас же он объединен с пакетом @testing-library/react. Теперь вы можете просто использовать пакет @testing-library/react начиная с версии 13.1.0.

Подписка на клик вне элемента

Давайте представим, что у нас на странице есть модальное окно (или что-нибудь похожее), которое мы хотим закрывать не только по «крестику», но и по клику вне этого самого модального окна. Попробуем решить задачу с помощью хуков.

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

import { RefObject, useEffect, useRef } from "react";

export const useOutsideClick = (
  ref: RefObject<HTMLElement | null>,
  callback: (event: Event) => void
) => {
  const callbackRef = useRef(callback);

  useEffect(() => {
    callbackRef.current = callback;
  }, [callback]);

  useEffect(() => {
    const handler: EventListener = (event) => {
      const { current: target } = ref;

      if (target && !target.contains(event.target as HTMLElement)) {
        callbackRef.current(event);
      }
    };

    document.addEventListener("click", handler);
    return () => document.removeEventListener("click", handler);
  }, [ref]);
};

Внутри хука useOutsideClick создается функция handler, используемая в качестве обработчика события. Данный обработчик проверяет, что элемент event.target не является потомком элемента ref.current (также проверяется, что event.target и ref.current не являются одним и тем же элементом) и вызывает переданный в хук коллбэк, если событие клика произошло действительно вне элемента.

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

Теперь можно переходить к тестам. Рассмотрим тест для хука useOutsideClick:

import { fireEvent, renderHook } from "@testing-library/react";
import { useOutsideClick } from "../useOutsideClick";

describe("useOutsideClick", () => {
  test("should handle outside click", () => {
    const target = document.createElement("div");
    document.body.appendChild(target);

    const outside = document.createElement("div");
    document.body.appendChild(outside);

    const ref = {
      current: target,
    };
    const callback = jest.fn();

    const view = renderHook(() => useOutsideClick(ref, callback));

    expect(callback).toHaveBeenCalledTimes(0);
    fireEvent.click(outside);
    expect(callback).toHaveBeenCalledTimes(1);

    // Тестируем, что "removeEventListener" работает корректно,
    // проверяя после размонтирования, что коллбэк вызывался только один раз.
    jest.spyOn(document, "removeEventListener");

    view.unmount();
    expect(document.removeEventListener).toHaveBeenCalledTimes(1);

    fireEvent.click(outside);
    expect(callback).toHaveBeenCalledTimes(1);
  });

  test("should do nothing after click on the target element", () => {
    const target = document.createElement("div");
    document.body.appendChild(target);

    const ref = {
      current: target,
    };
    const callback = jest.fn();

    renderHook(() => useOutsideClick(ref, callback));

    expect(callback).toHaveBeenCalledTimes(0);
    fireEvent.click(target);
    expect(callback).toHaveBeenCalledTimes(0);
  });
});
Версия для Vitest
import { fireEvent, renderHook } from "@testing-library/react";
import { useOutsideClick } from "../useOutsideClick";

describe("useOutsideClick", () => {
  test("should handle outside click", () => {
    const target = document.createElement("div");
    document.body.appendChild(target);

    const outside = document.createElement("div");
    document.body.appendChild(outside);

    const ref = {
      current: target,
    };
    const callback = vi.fn();

    const view = renderHook(() => useOutsideClick(ref, callback));

    expect(callback).toHaveBeenCalledTimes(0);
    fireEvent.click(outside);
    expect(callback).toHaveBeenCalledTimes(1);

    // Тестируем, что "removeEventListener" работает корректно,
    // проверяя после размонтирования, что коллбэк вызывался только один раз.
    vi.spyOn(document, "removeEventListener");

    view.unmount();
    expect(document.removeEventListener).toHaveBeenCalledTimes(1);

    fireEvent.click(outside);
    expect(callback).toHaveBeenCalledTimes(1);
  });

  test("should do nothing after click on the target element", () => {
    const target = document.createElement("div");
    document.body.appendChild(target);

    const ref = {
      current: target,
    };
    const callback = vi.fn();

    renderHook(() => useOutsideClick(ref, callback));

    expect(callback).toHaveBeenCalledTimes(0);
    fireEvent.click(target);
    expect(callback).toHaveBeenCalledTimes(0);
  });
});

Как видите, версия для Vitest практически не отличается от версии для Jest. Отличия заключаются в использовании vi.fn() вместо jest.fn() для создания мок-функции и vi.spyOn() вместо jest.spyOn() для отслеживания вызова document.removeEventListener во время выполнения.

Как вы знаете, хуки могут быть вызваны только внутри функционального компонента. Данный факт заставлял бы нас создавать внутри теста дополнительный компонент-обертку, который нужен только ради вызова тестируемого хука.

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

Для тестирования хука useOutsideClick потребуется создать два HTML-элемента — первым будет сам целевой элемент, сохраненный как target (клик по нему не должен инициировать вызов коллбэка), вторым будет элемент, который находится за пределами первого, сохраненный как outside.

В первом кейсе проверяется, что клик по элементу outside инициирует вызов коллбэка. Для этого достаточно сымитировать событие клика на элементе outside и проверить, что коллбэк был вызван верное количество раз.

Хорошим тоном будет также проверить, что зарегистрированный обработчик события был удален при размонтировании. Для этого необходимо проверить, что метод document.removeEventListener был вызван при размонтировании, а затем повторно сымитировать клик по элементу outside и убедиться, что количество вызовов коллбэка не изменилось.

Во втором же кейсе проверяется, что клик по целевому элементу не инициирует вызов коллбэка. Здесь все еще проще, чем в первом кейсе — необходимо лишь проверить, что количество вызовов коллбэка до и после клика равно нулю, что означает, что коллбэк не вызывался.

Подписка на нажатие клавиши

Едем дальше. Давайте представим, что нам необходимо закрывать все то же самое модальное окно, но теперь по нажатию клавиши Escape.

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

import { useEffect, useRef } from "react";

export const useKeydown = (
  key: string,
  callback: (event: KeyboardEvent) => void
) => {
  const callbackRef = useRef(callback);

  useEffect(() => {
    callbackRef.current = callback;
  }, [callback]);

  useEffect(() => {
    const handler = (event: KeyboardEvent) => {
      if (event.key === key) {
        callbackRef.current(event);
      }
    };

    document.addEventListener("keydown", handler);
    return () => document.removeEventListener("keydown", handler);
  }, [key]);
};

Внутри этого хука мы снова видим функцию handler, которая используется в качестве обработчика события. Однако в этот раз внутри обработчика происходит сравнение не элементов, а ключей события (подразумевается наименование нажатой клавиши) — сравнивается свойство event.key с параметром key, переданным в сам хук. В случае если нажата верная клавиша (когда event.key совпадает с key) будет вызван переданный в хук коллбэк.

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

Больше ничего примечательного в коде хука нет, так как он достаточно простой, поэтому можно переходить к тестам. Рассмотрим тест для хука useKeydown:

import { fireEvent, renderHook } from "@testing-library/react";
import { useKeydown } from "../useKeydown";

describe("useKeydown", () => {
  test("should handle keydown event", () => {
    const callback = jest.fn();
    const event = new KeyboardEvent("keydown", {
      key: "Escape",
    });

    const view = renderHook(() => useKeydown("Escape", callback));

    expect(callback).toHaveBeenCalledTimes(0);
    fireEvent(document, event);
    expect(callback).toHaveBeenCalledTimes(1);

    // Тестируем, что "removeEventListener" работает корректно,
    // проверяя после размонтирования, что коллбэк вызывался только один раз.
    jest.spyOn(document, "removeEventListener");

    view.unmount();
    expect(document.removeEventListener).toHaveBeenCalledTimes(1);

    fireEvent(document, event);
    expect(callback).toHaveBeenCalledTimes(1);
  });

  test("shouldn`t handle unnecessary keydown event", () => {
    const callback = jest.fn();
    const event = new KeyboardEvent("keydown", {
      key: "Enter",
    });

    renderHook(() => useKeydown("Escape", callback));

    expect(callback).toHaveBeenCalledTimes(0);
    fireEvent(document, event);
    expect(callback).toHaveBeenCalledTimes(0);
  });
});
Версия для Vitest
import { fireEvent, renderHook } from "@testing-library/react";
import { useKeydown } from "../useKeydown";

describe("useKeydown", () => {
  test("should handle keydown event", () => {
    const callback = vi.fn();
    const event = new KeyboardEvent("keydown", {
      key: "Escape",
    });

    const view = renderHook(() => useKeydown("Escape", callback));

    expect(callback).toHaveBeenCalledTimes(0);
    fireEvent(document, event);
    expect(callback).toHaveBeenCalledTimes(1);

    // Тестируем, что "removeEventListener" работает корректно,
    // проверяя после размонтирования, что коллбэк вызывался только один раз.
    vi.spyOn(document, "removeEventListener");

    view.unmount();
    expect(document.removeEventListener).toHaveBeenCalledTimes(1);

    fireEvent(document, event);
    expect(callback).toHaveBeenCalledTimes(1);
  });

  test("shouldn`t handle unnecessary keydown event", () => {
    const callback = vi.fn();
    const event = new KeyboardEvent("keydown", {
      key: "Enter",
    });

    renderHook(() => useKeydown("Escape", callback));

    expect(callback).toHaveBeenCalledTimes(0);
    fireEvent(document, event);
    expect(callback).toHaveBeenCalledTimes(0);
  });
});

И снова отличия между версиями для Vitest и для Jest минимальны. Те же vi.fn() и vi.spyOn() вместо jest.fn() и jest.spyOn() соответственно.

Как и сам хук, так и тесты для него также получились очень похожи на тесты для предыдущего хука.

Для тестирования хука useKeydown потребуется сымитировать событие нажатия клавиши. Для этого можно создать объект такого события с помощью конструктора KeyboardEvent, указав клавишу, нажатие который мы хотим сымитировать.

В первом кейсе проверяется, что нажатие клавиши Escape действительно приведет к вызову коллбэка — с помощью конструкции fireEvent(document, event) выполняется вызов созданного нами события на объекте document, а также проверяется количество вызовов коллбэка.

Во втором кейсе проверяется уже ситуация когда была нажата не та клавиша, которую мы указали при создании объекта события KeyboardEvent. Здесь снова все очень похоже на аналогичный кейс из первого теста, где проверяется количество вызовов до и после события, поэтому особо останавливаться на этом не будем.

Подписка на изменение состояния медиавыражения

Давайте немного усложним задачу. А что, если мы хотим удалять какой-то компонент из DOM-дерева по достижению определенной ширины нашего вьюпорта? Например, в случае когда иметь два разных компонента — один для десктопных устройств, а второй для мобильных устройств — удобнее, чем манипулировать стилями через медиавыражение.

На такой случай мы можем использовать window.matchMedia внутри хука для проверки медиавыражения и подписки на изменение состояния возвращаемого объекта MediaQueryList. Благодаря свойству matches из объекта MediaQueryList мы можем определить удовлетворяет ли страница нашему медиавыражению и использовать значение данного свойства в качестве возвращаемого хуком значения.

Также нам понадобится отслеживать изменение состояния медиавыражения, чтобы сообщить об этом компоненту, использующему данный хук. Давайте посмотрим на код:

import { useEffect, useState } from "react";

export const useMediaQuery = (query: string) => {
  const [matches, setMatches] = useState(false);

  useEffect(() => {
    let mounted = true;

    const mediaQueryList = window.matchMedia(query);
    setMatches(mediaQueryList.matches);

    const handler = (event: MediaQueryListEvent) => {
      if (!mounted) {
        return;
      }
      setMatches(event.matches);
    };

    if (mediaQueryList.addListener) {
      // Методы "addListener" и "removeListener" помечены как устраевшие на MDN,
      // но их необходимо использовать, т.к. в Safari < 14 методы
      // "addEventListener" и "removeEventListener" не поддерживаются.
      // https://caniuse.com/mdn-api_mediaquerylist
      mediaQueryList.addListener(handler);
    } else {
      mediaQueryList.addEventListener("change", handler);
    }

    return () => {
      mounted = false;
      if (mediaQueryList.removeListener) {
        mediaQueryList.removeListener(handler);
      } else {
        mediaQueryList.removeEventListener("change", handler);
      }
    };
  }, [query]);

  return Boolean(matches);
};

Для подписки на изменение состояния медиавыражения нам понадобится обработчик, который будет сохранять значение matches из объекта MediaQueryListEvent. Это значение будет установлено как текущее и возвращено хуком.

Отслеживать изменение состояния медиавыражения можно с помощью методов addEventListener и removeEventListener и события change. Но здесь есть важный нюанс — в Safari ниже 14-й версии данные методы не поддерживаются, поэтому необходимо использовать addListener и removeListener.

Важно обновлять текущее значение внутри хука только если компонент, использующий данный хук, смонтирован. В противном случае React сообщит об ошибке при попытке обновления состояния внутри размонтированного компонента. Сообщение о такой ошибки будет выглядеть следующим образом:

Warning: Can't perform a React state update on an unmounted component. This is a no-op, but it indicates a memory leak in your application. To fix, cancel all subscriptions and asynchronous tasks in a useEffect cleanup function.

Теперь можно переходить к тестированию. Однако стоит отметить, что тесты для данного хука сложнее предыдущих двух из-за более сложной логики внутри самого хука, а также из-за более сложного мока метода window.matchMedia в тестах. Рассмотрим тест для хука useMediaQuery:

import { act, renderHook } from "@testing-library/react";
import { MatchMediaMock } from "../../mocks/MatchMediaMock";
import { useMediaQuery } from "../useMediaQuery";

const matchMediaMock = new MatchMediaMock();

describe("useMediaQuery", () => {
  afterAll(() => {
    jest.clearAllMocks();
  });

  describe('with "addEventListener" and "addRemoveListener"', () => {
    test("should return true if media query matches", () => {
      window.matchMedia = jest
        .fn()
        .mockImplementation(() => matchMediaMock.mock(true));

      const view = renderHook(() => useMediaQuery("(min-width: 1024px)"));
      expect(view.result.current).toEqual(true);
    });

    test("should return false if media query doesn`t match", () => {
      window.matchMedia = jest
        .fn()
        .mockImplementation(() => matchMediaMock.mock(false));

      const view = renderHook(() => useMediaQuery("(min-width: 1024px)"));
      expect(view.result.current).toEqual(false);
    });

    test("should handle change event", () => {
      window.matchMedia = jest
        .fn()
        .mockImplementation(() => matchMediaMock.mock(true));

      const view = renderHook(() => useMediaQuery("(min-width: 1024px)"));
      expect(view.result.current).toEqual(true);

      act(() => {
        matchMediaMock.dispatchEvent({ matches: false } as MediaQueryListEvent);
      });
      expect(view.result.current).toEqual(false);
    });
  });

  describe('with "addListener" and "removeListener"', () => {
    test("should return true if media query matches", () => {
      window.matchMedia = jest
        .fn()
        .mockImplementation(() => matchMediaMock.mockLegacy(true));

      const view = renderHook(() => useMediaQuery("(min-width: 768px)"));
      expect(view.result.current).toEqual(true);
    });

    test("should return false if media query doesn`t match", () => {
      window.matchMedia = jest
        .fn()
        .mockImplementation(() => matchMediaMock.mockLegacy(false));

      const view = renderHook(() => useMediaQuery("(min-width: 1024px)"));
      expect(view.result.current).toEqual(false);
    });
  });
});
Версия для Vitest
import { act, renderHook } from "@testing-library/react";
import { MatchMediaMock } from "../../mocks/MatchMediaMock";
import { useMediaQuery } from "../useMediaQuery";

const matchMediaMock = new MatchMediaMock();

describe("useMediaQuery", () => {
  afterAll(() => {
    vi.clearAllMocks();
  });

  describe('with "addEventListener" and "addRemoveListener"', () => {
    test("should return true if media query matches", () => {
      window.matchMedia = vi
        .fn()
        .mockImplementation(() => matchMediaMock.mock(true));

      const view = renderHook(() => useMediaQuery("(min-width: 1024px)"));
      expect(view.result.current).toEqual(true);
    });

    test("should return false if media query doesn`t match", () => {
      window.matchMedia = vi
        .fn()
        .mockImplementation(() => matchMediaMock.mock(false));

      const view = renderHook(() => useMediaQuery("(min-width: 1024px)"));
      expect(view.result.current).toEqual(false);
    });

    test("should handle change event", () => {
      window.matchMedia = vi
        .fn()
        .mockImplementation(() => matchMediaMock.mock(true));

      const view = renderHook(() => useMediaQuery("(min-width: 1024px)"));
      expect(view.result.current).toEqual(true);

      act(() => {
        matchMediaMock.dispatchEvent({ matches: false } as MediaQueryListEvent);
      });
      expect(view.result.current).toEqual(false);
    });
  });

  describe('with "addListener" and "removeListener"', () => {
    test("should return true if media query matches", () => {
      window.matchMedia = vi
        .fn()
        .mockImplementation(() => matchMediaMock.mockLegacy(true));

      const view = renderHook(() => useMediaQuery("(min-width: 768px)"));
      expect(view.result.current).toEqual(true);
    });

    test("should return false if media query doesn`t match", () => {
      window.matchMedia = vi
        .fn()
        .mockImplementation(() => matchMediaMock.mockLegacy(false));

      const view = renderHook(() => useMediaQuery("(min-width: 1024px)"));
      expect(view.result.current).toEqual(false);
    });
  });
});

И снова отличия минимальны: vi.clearAllMocks() и vi.fn() вместо jest.clearAllMocks() и jest.fn() соответственно.

Поскольку Jest и Vitest не распознают метод window.matchMedia (даже при использовании JSDOM в качестве окружения), мы будем использовать вспомогательный класс MatchMediaMock с методом mock, который создает мок объекта MediaQueryList с принудительно заданным свойством matches. Код класса MatchMediaMock приведен ниже:

export class MatchMediaMock {
  private handlers: Array<(event: MediaQueryListEvent) => void> = [];

  mock(matches: boolean) {
    return {
      matches,
      addEventListener: (
        _event: string,
        handler: (event: MediaQueryListEvent) => void
      ) => {
        this.handlers.push(handler);
      },
      removeEventListener: jest.fn(),
    };
  }

  mockLegacy(matches: boolean) {
    return {
      matches,
      addListener: jest.fn(),
      removeListener: jest.fn(),
    };
  }

  dispatchEvent(event: MediaQueryListEvent) {
    this.handlers.forEach((handler) => handler(event));
  }
}
Версия для Vitest
export class MatchMediaMock {
  private handlers: Array<(event: MediaQueryListEvent) => void> = [];

  mock(matches: boolean) {
    return {
      matches,
      addEventListener: (
        _event: string,
        handler: (event: MediaQueryListEvent) => void
      ) => {
        this.handlers.push(handler);
      },
      removeEventListener: vi.fn(),
    };
  }

  mockLegacy(matches: boolean) {
    return {
      matches,
      addListener: vi.fn(),
      removeListener: vi.fn(),
    };
  }

  dispatchEvent(event: MediaQueryListEvent) {
    this.handlers.forEach((handler) => handler(event));
  }
}

Созданный мок содержит метод addEventListener, который сохраняет каждый переданный в него коллбэк, который должен быть вызван при изменении состояния медиавыражения. Сам хелпер для создания мока также содержит метод dispatchEvent, который инициирует вызов всех сохраненных ранее коллбэков, передавая им в качестве аргумента объект MediaQueryListEvent — значение свойства matches из данного объекта будет использовано внутри хука useMediaQuery при обновлении состояния.

Первые два кейса просто проверяют, что возвращаемое хуком значение совпадает со значением свойства matches из объекта MediaQueryList при монтировании .

В третьем кейсе происходит самое интересное — выполняется проверка, что хук правильно реагирует на изменение состояния медиавыражения и возвращает корректное текущее значение. Данная проверка выполняется с помощью метода matchMediaMock.dispatchEvent, который инициирует вызов сохраненного ранее обработчика для обновления текущего значения внутри самого хука.

В четвертом и пятом кейсах выполняется проверка того, что внутри хука используются методы addListener и removeListener (вместо addEventListener и removeEventListener) для подписки на состояние медиавыражения, если они реализованы в объекте MediaQueryList.

Считаем покрытие

Теперь осталось посчитать покрытие тестами для данных хуков, запустив Jest или Vitest с опцией --coverage. Как правило, вы можете сделать это запустив NPM-скрипт с именем test в вашем package.json.

# npm
npm run test -- --coverage
# yarn
yarn test --coverage

Результат выглядит достойно. Слева результаты Jest, справа — Vitest.

Слева результаты Jest, справа — Vitest. Кстати, здесь вы можете заметить разницу в скорости работы инструментов, если обратите внимание на время выполнения тестов.

Цель достигнута — мы получили 100%-е покрытие тестами, как и было задумано.

Заключение

В данной статье я постарался продемонстрировать как писать собственные React-хуки на TypeScript, тестировать их и получить 100%-е покрытие. Как вы можете видеть, это не так уж сложно, как кажется на первый взгляд.

В следующих статьях данной серии будет рассмотрено еще несколько новых хуков с их тестированием.

Спасибо всем за внимание.

P.S: Спасибо @Maxim-Wolfза дельные комментарии.