Данная статья является переводом моей собственной статьи, ранее опубликованной на dev.to.

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

Если вы хотите посмотреть исходный код, то можете посетить этот репозиторий на GitHub.

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

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

Вы можете настроить инструменты тестирования самостоятельно, но для быстрого старта я рекомендую использовать утилиту Create React App, которая предоставляет возможность тестирования с использованием Jest и React Testing Library "из коробки".

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

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

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

Давайте начнем!

Приступаем к работе

Для демонстрации я буду использовать приложение, созданное с помощью Create React App.

# npx
npx create-react-app custom-react-hooks-demo --template typescript
# yarn
yarn create react-app custom-react-hooks-demo --template typescript

Далее, нам нужно установить пакет react-hooks-testing-library. Этот пакет предоставляет набор утилит, существенно упрощающих тестирование React-хуков. Узнать больше о данном пакете вы можете здесь.

# npm
npm i @testing-library/react-hooks --save-dev
# yarn
yarn add @testing-library/react-hooks -D

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

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

Вот как это выглядит:

Реализация хука:

import { useEffect, useRef } from 'react';

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

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

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

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

export default useKeydown;

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

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

Тестирование хука:

import { fireEvent } from '@testing-library/react';
import { renderHook } from '@testing-library/react-hooks';
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);
  });
});

Как вы знаете, хуки могут быть вызваны только внутри тела функционального компонента. Данный факт заставляет создавать внутри теста лишний компонент, который нужен только ради вызова тестируемого хука. Использование метода renderHook из пакета react-hooks-testing-library позволяет нам протестировать хук без написания дополнительного компонента, оборачивая тестируемый хук так, как если бы он был вызван внутри настоящего функционального компонента.

Данный тест проверяет, что:

  • обработчик события был успешно создан при монтировании и корректно удален при размонтировании;

  • переданный коллбэк был вызван, если была нажата необходимая клавиша (для имитации события нажатия клавиши используется метод fireEvent);

  • переданный коллбэк не был вызван, если была нажата любая другая клавиша.

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

Хорошо, у нас есть модальное окно, которое можно закрыть нажатием клавиши Escape. Но что, если мы хотим закрывать его посредством клика вне данного окна?

Вот как это выглядит:

Реализация хука:

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

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]);
}

export default useOutsideClick;

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

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

Тестирование хука:

import { fireEvent } from '@testing-library/react';
import { renderHook } from '@testing-library/react-hooks';
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);
  });
});

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

Данный тест проверяет, что:

  • обработчик события был успешно создан при монтировании и корректно удален при размонтировании;

  • переданный коллбэк был вызван, если событие клика произошло на элементе, который находится вне целевого элемента;

  • переданный коллбэк не был вызван, если событие клика произошло на целевом элементе.

Подписка на изменение состояния медиа-запроса

Давайте немного усложним задачу. Что, если мы хотим удалять какой-то компонент из DOM-дерева по достижению определенной ширины вьюпорта? В данном случае мы можем использовать window.matchMedia для проверки медиа-запроса и подписки на изменение состояния возвращаемого объекта MediaQueryList.

Вот как это выглядит:

Реализация хука:

import { useEffect, useState } from 'react';

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);
}

export default useMediaQuery;

Созданный обработчик внутри данного хука будет вызван при изменении состояния медиа-запроса (например, при изменении ширины вьюпорта, если отслеживается его ширина). При смене состояния значение свойства matches из объекта MediaQueryListEvent будет установлено как текущее значение и возвращено хуком.

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

Тестирование хука:

import { act } from '@testing-library/react';
import { renderHook } from '@testing-library/react-hooks';
import useMediaQuery from './useMediaQuery';

const mockImplementation = {
  handlers: [] as Array<(event: MediaQueryListEvent) => void>,

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

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

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

  describe('with "addEventListener" and "addRemoveListener"', () => {
    test('should return true if media query matches', () => {
      window.matchMedia = jest.fn().mockImplementation(() => mockImplementation.create(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(() => mockImplementation.create(false));

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

    test('should handle change event', () => {
      window.matchMedia = jest.fn().mockImplementation(() => mockImplementation.create(true));

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

      act(() => {
        mockImplementation.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(() => ({
        matches: true,
        addListener: jest.fn(),
        removeListener: jest.fn(),
      }));

      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(() => ({
        matches: false,
        addListener: jest.fn(),
        removeListener: jest.fn(),
      }));

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

Поскольку Jest не распознает метод window.matchMedia (даже при использовании JSDOM в качестве окружения), нам необходимо использовать хелпер, который создает мок экземпляра MediaQueryList с принудительно заданным свойством matches. Созданный мок содержит метод addEventListener, который сохраняет каждый переданный в него коллбэк, который должен быть вызван при изменении состояния медиа-запроса. Хелпер для создания мока также содержит метод dispatchEvent, который инициирует вызов всех сохраненных ранее коллбэков, передавая им в качестве аргумента объект, реализующий интерфейс MediaQueryListEvent.

Данный тест проверяет, что:

  • обработчик события был успешно создан при монтировании;

  • возвращаемое хуком значение корректно обновляется при изменении состояния медиа-запроса;

  • хук использует методы addListener и removeListener (вместо addEventListener и removeEventListener), если они реализованы в экземпляре MediaQueryList.

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

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

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

Результат:

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

Заключение

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

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

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

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

Комментарии (2)


  1. Maxim-Wolf
    25.02.2022 17:21
    +2

    Если хук useKeydown обслуживает события клавиатуры, почему бы не донести до клиентского кода этот факт указанием типа события?

    const useKeydown = (
      key: string, 
      callback: (event: KeyboardEvent) => void,
    ) =>{/*...*/}

    Такая сигнатура может помочь при написании callback-а - TypeScript сможет вывести типы параметров "по месту".

    При этом определение handler следует тоже нацелить на тип события. Пусть TypeScript проверяет соответствие ожиданий обработчика типа события указанному по имени

    const handler = (event: KeyboardEvent) => {
      if (event.key === key) {
        callbackRef.current(event);
      }
    }
    
    document.addEventListener('keydown',handler);


    1. kirillshvets97 Автор
      25.02.2022 17:39
      +1

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

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