В этой статье показана базовая настройка приложения React, включая конфигурацию управления состоянием Redux и Redux Saga для асинхронных побочных эффектов состояния. В дополнение к этому будет объяснена установка для тестирования. Обратите внимание, что вы можете сделать это точно так же в приложении React Native!

Итак, эта статья будет о:

  • Настройка базового приложения React
  • Настройка Redux с вашим приложением
  • Добавление промежуточного программного обеспечения Redux Saga в вашу конфигурацию Redux
  • Показано, как работать с Redux + Saga, включая постановку вызовов в очередь без их распараллеливания (например, загрузка файлов один за другим, но не одновременно)
  • Тестирование вашего пользовательского интерфейса (компонентов)
  • Blackbox тестирует ваши функции Redux Saga с помощью Mock API

Сценарий

В качестве воображаемого сценария у нас есть простой интерфейс (приложение React или React Native) и сервер. Будет один вызов GET, который выполнит приложение. Этот запрос должен быть отправлен против https://example-api.com/test. Ответ представляет собой JSON, содержащий значение, например { increment: 42 }.

На снимке экрана показано определение Swagger этого примера запроса. Swagger позволяет вам определять свой API, следуя спецификации OpenAPI. Вы можете легко оформлять свои запросы, ответы и показывать примеры. Это может помочь при разработке вашего приложения, тем более что вы можете сгенерировать HTTP-клиент (ы) для многих языков программирования.

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

При нажатии кнопки запускают именованный запрос, связанный с Redux Saga. Call запускает запрос, который должен выполняться только один раз без параллелизма. Это может быть полезно, если вы хотите выполнять загрузку в определенном порядке (первый пришел - первый ушел) и только по одной за раз (плохая мобильная связь).

Базовое приложение React

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

В корне нашего приложения находится компонент <App />. В более крупном приложении он может содержать Router, чтобы вы могли переключаться между несколькими экранами (например, такими местами, как / profile). В этом примере будет просто размещен <Home />component, включающий кнопки и текст, который будет добавлен позже:

Как видите, это самые важные строки кода для этого базового примера без какой-либо конфигурации Redux. Приложение React будет отображаться в элементе html с идентификатором root.

Затем мы должны определить функциональные возможности компонента <Home />. props (или «вход») этого компонента - это счетчик (число) и функции, которые что-то делают с этим счетчиком. На данный момент у нас нет реализации этих функций:

Кроме того, мы решили, что state этого компонента находится не внутри самого компонента, а централизованно управляется хранилищем Redux. Это дает преимущество разделения кода (кодом легче управлять), лучшей тестируемости (вы увидите это позже) и использования counter состояния в различных компонентах. Вы можете найти мою статью о Redux здесь.

Настройка Redux + Redux Saga

Наверное, самая сложная часть Redux (на мой взгляд) - это начальная настройка. Это может стать проблемой, особенно если вы не знакомы с JavaScript. На самом деле это довольно простая вещь. Вы создаете функцию, которая принимает начальное состояние и возвращает хранилище Redux. В некоторых приложениях может иметь смысл (но это не самый чистый способ) сделать это хранилище глобально доступным.

Очень важно, что вы настраиваете свой магазин только один раз. Другие вещи, которые есть в хранилище Redux, помимо состояния, - это корневой редуктор, который управляет состоянием, и, возможно, промежуточное ПО поверх, такое как Redux Saga, Redux Logger или Redux Persist. В этом примере магазин настроен следующим образом:

Это также показывает, что после того, как мы создали магазин, мы должны запустить промежуточное программное обеспечение Redux Saga. При желании вы можете привязать функцию run() к хранилищу, чтобы запустить ее позже.

Редуктор

Редуктор - это функция, которая принимает текущее состояние Redux плюс самоопределяемое действие и возвращает новое состояние, основанное на этом действии, если применимо. В этом примере необходим только один редуктор. Более крупные приложения могут разделить свое хранилище Redux на разные части, так что не каждый редуктор имеет доступ ко всему состоянию. Тогда станет проще делать такие вещи, как сохранение целых частей магазина. Это также может привести к повышению производительности, поскольку каждый раз, когда вы меняете хранилище, подписанные компоненты могут нуждаться в повторном рендеринге.

Действия и типы действий обычно можно определить по своему усмотрению. В основном типы действий представляют собой строки, и действия могут принимать объект в качестве входных данных и возвращать тип действия плюс входные данные функции. Когда я впервые работал с Redux, для меня это не имело смысла. Но через некоторое время я понял, что разделение кода может быть полезно, а также возможность автоматического импорта этих функций действия (я использую Visual Studio Code в качестве IDE).

Корневой редуктор может состоять из нескольких других редукторов. У каждого есть своя часть государства. В этом примере есть только один редуктор с именем exampleReducer, который определен в exampleReducer и экспортируется с помощью функции combineReducers. Сам редуктор просто проверяет отправку конкретного действия и, соответственно, возвращает новое хранилище.

Сага

Redux Saga - это промежуточное ПО поверх Redux, которое может обрабатывать за вас побочные эффекты, такие как запросы. Таким образом, можно запускать сагу так же, как действие Redux, имея при этом возможность вызывать эффекты, такие как отправка действий Redux для управления состоянием или получение значений из состояния. Это объясняет, почему его нужно настраивать при настройке хранилища Redux.

С точки зрения JavaScript, функции Saga - это генераторы. Функция генератора, записанная как function *, может приостановить свое выполнение и продолжить до следующего yield оператора. Это дает возможность писать функции, которые никогда не останавливаются (while (true)), чтобы вы всегда могли делать что-то в определенном повторяющемся порядке. Еще одно преимущество использования итераторов - обработка больших наборов данных или наборов данных неизвестного размера. С итераторами вы просто выполняете итерацию / шаг, пока что-то доступно. Похожее в мире Java - Streams.

Redux Saga предоставляет некоторые высокоуровневые функции, которые могут помочь вам в обработке асинхронных задач. Эти помощники построены на низкоуровневых эффектах take и fork. Формирование саги в основном означает, что вы вызываете функцию неблокирующим образом и в результате получаете Task. Эта Задача может быть проверена на выполнение, отменена или более. В этой статье будет показано индивидуальное использование этих функций. Большинство ваших сценариев использования, вероятно, будут полагаться на fork, а не на spawn, поскольку вам обычно не нужен отдельный код в вашем приложении.

Функция sendMediaSaga saga выполняет ранее описанный запрос, чтобы увеличить счетчик в нашем состоянии. Для этого мы используем функцию Redux Saga call, которая вызывает заданную функцию (генератор). После получения результата мы put (отправляем) действие Redux, чтобы изменить состояние. Если вы хотите, чтобы это происходило синхронно, вы можете использовать putResolve.

Организация очередей непараллелизованных задач

Очередь в Redux Saga может быть достигнута с помощью каналов. Обычно он обеспечивает способ связи (асинхронно) между сагами. Вы можете, например, отслеживать ход загрузки и передавать значения через обратный вызов и через канал в хранилище Redux (действия по отправке).

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

Прежде всего, нам нужно создать сагу, которая отслеживает (take) выполнение определенного типа действия, а затем вызывает сагу sendMedia (call). Этот вызов происходит блокирующим образом, поэтому мы ждем завершения вызова, прежде чем ждать следующего. Обратите внимание: чтобы не терять запросы, нужно take на канале.

Далее нам нужно создать общий канал Saga. Затем мы fork определенное количество Saga, которые действуют как «рабочие потоки». Таким образом, каждая handleRequest Сага постоянно отслеживает на этом канале определенное действие. В последней части mediaQueueSaga нам нужно следить за тем, чтобы конкретное действие было отправлено промежуточному программному обеспечению, за которым следует put на этот канал.

В целом channel действует как элемент связи между отправкой и выполнением этих действий. Вот как может выглядеть код:

Наконец, нам нужно настроить root Saga. В нашем случае мы просто будем использовать эффект all с массивом наших использованных саг, чтобы они были разделены параллельно. Мы можем получить массив дескрипторов задач, которые мы здесь не будем использовать. Есть несколько разных способов настройки root Saga, но в нашем случае подходит именно этот:

Обработка других задач

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

takeLatest всегда поддерживает выполнение последних действий. Это отменит любое действие, начатое ранее. В нашем примере быстрое нажатие может привести к отмене предыдущего запроса (ов).

takeLeading по коду похож на handleRequest Saga. Таким образом, требуется только действие, которое в настоящее время не выполняется. В нашем примере быстрое нажатие может привести к потере запросов.

Подключите Redux к пользовательскому интерфейсу

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

Обратите внимание, что мы изменили начальный <Home /> на <HomeContainer />. Причина тому - подключение к Redux. Это часто встречающийся и хороший подход (на мой взгляд) для отдельного экспорта чистого компонента / функции и связанной с редукцией функции. Это очень полезно при тестировании, что вы увидите позже.

Компонент <HomeContainer /> создается функцией response-redux connect, также называемой компонентом / функцией высокого порядка (функция, которая принимает компонент и возвращает новую функцию). Эта функция принимает ровно два параметра (также функции):

  • mapStateToProps: функция, которая имеет state хранилища Redux в качестве входных данных и должна возвращать объект со значениями состояния, которое вы хотите для этого компонента как props
  • mapDispatchToProps: функция, которая имеет функцию dispatch хранилища Redux в качестве входных данных и должна возвращать объект с функциями, которые вы хотите отправить в хранилище (действия Redux или Saga) для этого компонента как props

Можно сказать, что Redux connect отображает (подключает) хранилище Redux к компоненту.

Тест пользовательского интерфейса

Когда дело доходит до тестирования во внешних приложениях, пользовательские интерфейсы часто не тестируются автоматически. Проблема в том, что после изменения пользовательского интерфейса вам может потребоваться настроить множество тестов. В частности, сквозные тесты, в которых вы определяете идентификаторы для вашего элемента, чтобы его можно было обнаружить. Но особенно для критически важных приложений или их частей вам потребуются тесты.

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

Первый тест должен проверить, действительно ли компонент отображает заданное значение счетчика. Во-первых, нам нужно получить результат рендеринга из нашего тестового компонента. Для этого мы используем функцию shallow в Enzyme, которая приводит к рендерингу только этого компонента. Альтернативой может быть mount, который также будет отображать дочерние компоненты. Мы также передаем 42 через свойства компонентов и ожидаем, что конкретный текстовый элемент будет иметь этот номер в виде текста. Обратите внимание, что мы не используем компонент <HomeContainer />, поскольку тестирование компонента с помощью Redux может быть довольно сложным и здесь не обязательно.

Второй тест представляет новое поведение пользовательского интерфейса: проверяется, что нажатие кнопки вызывает функцию, которая передается через свойства <Home /> компонента. Это требует, чтобы набор тестов проверял, вызывалась ли переданная функция после нажатия кнопки. Для этого мы используем Sinon, который позволяет нам просто шпионить за функцией, которая будет вызвана один раз:

Функциональный тест черного ящика

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

Когда дело доходит до тестирования Redux Saga, у вас может быть один и тот же выбор между тестированием черного или белого ящика. Белый квадрат будет означать, что вы тестируете свою сагу таким образом, чтобы вы проходили через сагу (функция генератора) и, таким образом, проверяли каждый из ее yield результатов. По моему опыту, это может стать довольно сложным и трудным в обслуживании. Как только вы измените порядок yield саги, тесты необходимо будет скорректировать. Тесты должны быть написаны просто, иначе у вас может быть меньше мотивации их писать.

Тестирование черного ящика - отличный способ проверить саги. Для этого используется redux-saga-tester. Это позволяет нам проверять состояние магазина, отправлять действия и ждать, пока произойдут определенные действия. В нашем случае этого достаточно, чтобы проверить работоспособность саги. Этот тест также будет включать редуктор:

Как видите, настроить SagaTester и отправить ему действия довольно просто. Самое интересное - это произвольный тайм-аут. Это связано с тем, что мы выполняем асинхронные запросы.

Получить Mock API

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

Мы будем использовать библиотеку fetch-mock, имитирующую HTTP-запросы. Наш API довольно прост, поскольку нам нужен только один запрос GET для /test. Макет API настраивается с помощью следующего кода. Вам необходимо импортировать эту конфигурацию на ранней стадии вашего приложения (если вы хотите запустить развернутое приложение против него) поверх тестовой установки.

Используя фиктивный API, вы также можете протестировать, как ваше приложение реагирует на определенные ответы, такие как HTTP 403, 502 или просто долго ожидающие запросы.

Вывод

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