Описание подхода к организации и тестированию кода с использованием Redux Thunk

в 11:54, , рубрики: javascript, react.js, ReactJS, redux, redux-thunk, Разработка веб-сайтов

Всем привет!

В этой заметке я хотел бы поделиться своим подходом к организации и тестированию кода с использованием Redux Thunk в проекте на React.

Путь к нему был долог и тернист, поэтому постараюсь продемонстрировать ход мыслей и мотивацию, которые и привели к итоговому решению.

Описание приложения и постановка проблемы

Сначала немного контекста.

На рисунке ниже представлен макет типовой страницы в нашем проекте.

Описание подхода к организации и тестированию кода с использованием Redux Thunk - 1

По порядку:

  • Таблица (#1) содержит в себе данные, которые могут быть очень разными (обычный текст, ссылки, картинки и т. д.);
  • Панель сортировки (№2) задаёт настройки сортировки данных в таблице по столбцам;
  • Панель фильтрации (№3) задаёт различные фильтры по столбцам таблицы;
  • Панель колонок (№4) позволяет задать отображение столбцов таблицы (показать/скрыть);
  • Панель шаблонов (№5) позволяет выбрать созданные ранее шаблоны настроек. Шаблоны включают в себя данные из панелей №2, №3, №4, а также некоторые другие данные, например, положение колонок, их размер и т. п.

Панели раскрываются при нажатии на соответствующие кнопки.

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

Получается, что текущее состояние таблицы и данных в ней зависит от трёх факторов:

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

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

Для того, чтобы можно было задавать однотипные наборы thunk-ов и action creator-ов и обновлять данные по конкретной сущности, используется следующий подход (своего рода фабрика):

export const actionsCreator = (prefix, getCurrentStore, entityModel) => {
  /* --- ACTIONS BLOCK --- */
  function fetchTotalCounterStart() {
    return { type: `${prefix}FETCH_TOTAL_COUNTER_START` };
  }

  function fetchTotalCounterSuccess(payload) {
    return { type: `${prefix}FETCH_TOTAL_COUNTER_SUCCESS`, payload };
  }

  function fetchTotalCounterError(error) {
    return { type: `${prefix}FETCH_TOTAL_COUNTER_ERROR`, error };
  }

  function applyFilterSuccess(payload) {
    return { type: `${prefix}APPLY_FILTER_SUCCESS`, payload };
  }

  function applyFilterError(error) {
    return { type: `${prefix}APPLY_FILTER_ERROR`, error };
  }

  /* --- THUNKS BLOCK --- */
  function fetchTotalCounter(filter) {
    return async dispatch => {
      dispatch(fetchTotalCounterStart());

      try {
        const { data: { payload } } = await entityModel.fetchTotalCounter(filter);

        dispatch(fetchTotalCounterSuccess(payload));
      } catch (error) {
        dispatch(fetchTotalCounterError(error));
      }
    };
  }

  function fetchData(filter, dispatch) {
    dispatch(fetchTotalCounter(filter));

    return entityModel.fetchData(filter);
  }

  function applyFilter(newFilter) {
    return async (dispatch, getStore) => {
      try {
        const store = getStore();
        const currentStore = getCurrentStore(store);
        // 'getFilter' comes from selectors.
        const filter = newFilter || getFilter(currentStore);
        const { data: { payload } } = await fetchData(filter, dispatch);

        dispatch(applyFilterSuccess(payload));
      } catch (error) {
        dispatch(applyFilterError(error));
      }
    };
  }

  return {
    fetchTotalCounterStart,
    fetchTotalCounterSuccess,
    fetchTotalCounterError,
    applyFilterSuccess,
    applyFilterError,

    fetchTotalCounter,
    fetchData,
    applyFilter,
  };
};

Где:

  • prefix — префикс сущности в хранилище Redux. Представляет собой строку вида "CATS_", "MICE_" и т. п.;
  • getCurrentStore — селектор, возвращающий текущие данные по сущности из хранилища Redux;
  • entityModel — экземпляр класса модели сущности. С одной стороны, через модель происходит обращение к api для создания запроса к серверу, с другой — описывается какая-нибудь сложная (или не очень) логика обработки данных.

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

Поскольку нюансов по менеджменту данной системы довольно много, а также для переиспользования кода, сложные thunk-и разбиваются на более простые и объединяются в композицию. Часто бывает так, что один thunk вызывает другой, который уже может dispatch-ить обычные action-ы (наподобие связки applyFilter-fetchTotalCounter из примера выше). И когда все основные моменты были учтены, и все необходимые thunk-и и action creator-ы были описаны, файл, содержащий функцию actionsCreator, имел ~1200 строк кода и тестировался с большим скрипом. Файл тестов также имел порядка 1200 строк, но при этом покрытие составляло в лучшем случае 40-50%.

Здесь пример, конечно, очень сильно упрощён, как по количеству thunk-ов, так и по их внутренней логике, но для демонстрации проблемы этого будет вполне достаточно.

Обратите внимание на 2 вида thunk-ов в примере выше:

  • fetchTotalCounter — dispatch-ит только action-ы;
  • applyFilter — помимо dispatch-а принадлежащих ему action-ов (applyFilterSuccess, applyFilterError) dispatch-ит также другой thunk (fetchTotalCounter).
    Мы вернёмся к ним чуть позже.

Тестировалось всё это следующим образом (использовался фреймворк для тестирования Jest):

import configureMockStore from 'redux-mock-store';
import thunk from 'redux-thunk';

import { actionsCreator } from '../actions';

describe('actionsCreator', () => {
  const defaultState = {};
  const middlewares = [thunk];
  const mockStore = configureMockStore(middlewares);

  const prefix = 'TEST_';
  const getCurrentStore = () => defaultState;
  const entityModel = {
    fetchTotalCounter: jest.fn(),
    fetchData: jest.fn(),
  };
  let actions;

  beforeEach(() => {
    actions = actionsCreator(prefix, getCurrentStore, entityModel);
  });

  describe('fetchTotalCounter', () => {
    it('should dispatch correct actions on success', () => {
      const filter = {};
      const payload = 0;
      const store = mockStore(defaultState);
      entityModel.fetchTotalCounter.mockResolvedValueOnce({
        data: { payload },
      });

      const expectedActions = [
        { type: `${prefix}FETCH_TOTAL_COUNTER_START` },
        {
          type: `${prefix}FETCH_TOTAL_COUNTER_SUCCESS`,
          payload,
        }
      ];

      return store.dispatch(actions.fetchTotalCounter(filter)).then(() => {
        expect(store.getActions()).toEqual(expectedActions);
      });
    });

    it('should dispatch correct actions on error', () => {
      const filter = {};
      const error = {};
      const store = mockStore(defaultState);
      entityModel.fetchTotalCounter.mockRejectedValueOnce(error);

      const expectedActions = [
        { type: `${prefix}FETCH_TOTAL_COUNTER_START` },
        {
          type: `${prefix}FETCH_TOTAL_COUNTER_ERROR`,
          error,
        }
      ];

      return store.dispatch(actions.fetchTotalCounter(filter)).then(() => {
        expect(store.getActions()).toEqual(expectedActions);
      });
    });
  });

  describe('applyFilter', () => {
    it('should dispatch correct actions on success', () => {
      const payload = {};
      const counter = 0;
      const newFilter = {};
      const store = mockStore(defaultState);
      entityModel.fetchData.mockResolvedValueOnce({ data: { payload } });
      entityModel.fetchTotalCounter.mockResolvedValueOnce({
        data: { payload: counter },
      });

      const expectedActions = [
        // fetchTotalCounter actions
        { type: `${prefix}FETCH_TOTAL_COUNTER_START` },
        {
          type: `${prefix}FETCH_TOTAL_COUNTER_SUCCESS`,
          payload: counter,
        },
        // applyFilter actions
        {
          type: `${prefix}APPLY_FILTER_SUCCESS`,
          payload,
        }
      ];

      return store.dispatch(actions.applyFilter(newFilter)).then(() => {
        expect(store.getActions()).toEqual(expectedActions);
      });
    });

    it('should dispatch correct actions on error', () => {
      const error = {};
      const counter = 0;
      const newFilter = {};
      const store = mockStore(defaultState);
      entityModel.fetchData.mockRejectedValueOnce(error);
      entityModel.fetchTotalCounter.mockResolvedValueOnce({
        data: { payload: counter },
      });

      const expectedActions = [
        // fetchTotalCounter actions
        { type: `${prefix}FETCH_TOTAL_COUNTER_START` },
        {
          type: `${prefix}FETCH_TOTAL_COUNTER_SUCCESS`,
          payload: counter,
        },
        // applyFilter actions
        {
          type: `${prefix}APPLY_FILTER_ERROR`,
          error,
        }
      ];

      return store.dispatch(actions.applyFilter(newFilter)).then(() => {
        expect(store.getActions()).toEqual(expectedActions);
      });
    });
  });
});

Как видно, с тестированием первого вида thunk-ов не возникает никаких проблем — необходимо только замокать метод модели entityModel, однако со вторым видом дело обстоит сложнее — приходится мокать данные для всей цепочки вызываемых thunk-ов и соответствующие методы модели. В противном случае тест будет падать на деструктуризации данных ({ data: { payload } }), причём это может происходить как явно, так и неявно (было такое, что тест успешно проходился, но при тщательном исследовании было замечено, что во втором/третьем звене этой цепочки происходило падение теста из-за отсутствия замоканных данных). Также это плохо тем, что unit-тесты отдельных функций превращаются в своего рода интеграционные, и становятся тесно связанными.

Встаёт вопрос: зачем в функции applyFilter проверять, как ведёт себя функция fetchTotalCounter, если для неё уже написаны отдельные подробные тесты? Как можно сделать тестирование второго типа thunk-ов более независимым? Было бы здорово добиться возможности протестировать, что thunk (в данном случае fetchTotalCounter) просто вызывается с нужными параметрами, при этом не было бы необходимости заботиться о моках для его корректной работы.

Но как это сделать? В голову приходит очевидное решение: замокать функцию fetchData, которая вызывается в applyFilter, либо замокать fetchTotalCounter (поскольку зачастую другой thunk вызывается напрямую, а не через какую-то другую функцию типа fetchData).

Попробуем. Для примера будем изменять только успешный сценарий.

  • Вариант №1: мок функции fetchData:

describe('applyFilter', () => {
  it('should dispatch correct actions on success', () => {
    const payload = {};
-   const counter = 0;
    const newFilter = {};
    const store = mockStore(defaultState);
-   entityModel.fetchData.mockResolvedValueOnce({ data: { payload } });
-   entityModel.fetchTotalCounter.mockResolvedValueOnce({
-     data: { payload: counter },
-   });
+   const fetchData = jest.spyOn(actions, 'fetchData');
+   // or fetchData.mockImplementationOnce(Promise.resolve({ data: { payload } }));
+   fetchData.mockResolvedValueOnce({ data: { payload } });

    const expectedActions = [
-     // Total counter actions
-     { type: `${prefix}FETCH_TOTAL_COUNTER_START` },
-     {
-       type: `${prefix}FETCH_TOTAL_COUNTER_SUCCESS`,
-       payload: counter,
-     },
-     // apply filter actions
      {
        type: `${prefix}APPLY_FILTER_SUCCESS`,
        payload,
      }
    ];

    return store.dispatch(actions.applyFilter(newFilter)).then(() => {
      expect(store.getActions()).toEqual(expectedActions);
+     expect(actions.fetchTotalCounter).toBeCalledWith(newFilter);
    });
  });
});

Здесь метод jest.spyOn заменяет примерно (а может быть, и точно) такую реализацию:

actions.fetchData = jest.fn(actions.fetchData);

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

Получаем следующую ошибку:

Difference:

- Expected
+ Received

Array [
    Object {
-     "payload": Object {},
-     "type": "TEST_APPLY_FILTER_SUCCESS",
+     "type": "TEST_FETCH_TOTAL_COUNTER_START",
    },
+   Object {
+     "error": [TypeError: Cannot read property 'data' of undefined],
+     "type": "TEST_FETCH_TOTAL_COUNTER_ERROR",
+   },
+   Object {
+     "error": [TypeError: Cannot read property 'data' of undefined],
+     "type": "TEST_APPLY_FILTER_ERROR",
+   },
  ]

Странно, мы вроде замокали функцию fetchData, подложив свою реализацию

fetchData.mockResolvedValueOnce({ data: { payload } })

но функция работает точно так же, как и до этого, то есть мок не сработал! Попробуем по-другому.

describe('applyFilter', () => {
  it('should dispatch correct actions on success', () => {
    const payload = {};
-   const counter = 0;
    const newFilter = {};
    const store = mockStore(defaultState);
    entityModel.fetchData.mockResolvedValueOnce({ data: { payload } });
-   entityModel.fetchTotalCounter.mockResolvedValueOnce({
-     data: { payload: counter },
-   });
+   const fetchTotalCounter = jest.spyOn(actions, 'fetchTotalCounter';
+   fetchTotalCounter.mockImplementation(() => {});

    const expectedActions = [
-     // Total counter actions
-     { type: `${prefix}FETCH_TOTAL_COUNTER_START` },
-     {
-       type: `${prefix}FETCH_TOTAL_COUNTER_SUCCESS`,
-       payload: counter,
-     },
-     // apply filter actions
      {
        type: `${prefix}APPLY_FILTER_SUCCESS`,
        payload,
      }
    ];

    return store.dispatch(actions.applyFilter(newFilter)).then(() => {
      expect(store.getActions()).toEqual(expectedActions);
+     expect(actions.fetchTotalCounter).toBeCalledWith(newFilter);
    });
  });
});

Получаем точно такую же ошибку. По какой-то причине наши моки не заменяют исходную реализацию функций.

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

На нашем проекте в Jenkins pipline стоит проверка кода от SonarQube, который требует покрытия изменённых файлов (которые в merge/pull request-е) > 60%. Поскольку покрытие данной фабрики, как было сказано ранее, было неудовлетворительным, а сама необходимость покрытия такого файла вызывала только депрессию, нужно было что-то с этим делать, иначе поставка новой функциональности могла со временем замедлиться. Спасало только покрытие тестами других файлов (компонентов, функций) в том же merge/pull request-е, чтобы дотянуть % покрытия до заветной отметки, но, по сути, это был некий обходной путь, а не решение проблемы. И в один прекрасный момент, выделив немного времени в спринте, я начал думать, как эту проблему можно решить.

Попытка решения проблемы №1. Я что-то слышал про Redux-Saga...

… и мне говорили, что тестирование сильно упрощается при использовании данного middleware.

Действительно, если взглянуть на документацию, то удивляешься, как просто тестируется код. Самый сок заключается в том, что при таком подходе вообще нет проблемы с тем, что какая-то сага может вызывать другую сагу — мы можем мокать и "слушать" функции, предоставляемые самим middleware (put, take и т. п.), и проверять, что они вызывались (и вызывались с корректными параметрами). То есть в данном случае функция не обращается к другой функции напрямую, а обращается к функции из библиотеки, которая уже потом вызывает другие необходимые функции/саги.

"Почему бы тогда не попробовать этот middleware?" — подумал я и взялся за работу. Завёл техническую историю в Jira, создал в ней несколько задач (от исследования до реализации и описания архитектуры всей этой системы), получил "добро" и начал делать минимальную копию текущей системы с новым подходом.

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

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

Попытка решения проблемы №2. Thunk-модули

Можно разнести все thunk-и по разным файлам, и в тех файлах, где один thunk вызывает другой (импортированный), можно данный импорт мокать либо с помощью метода jest.mock, либо с помощью того же jest.spyOn. Таким образом, мы добьёмся поставленной выше задачи проверки того, что какой-то внешний thunk вызывался с нужными параметрами, не заботясь о моках для него. Помимо этого, было бы лучше разбить все thunk-и по их функциональному назначению, чтобы не держать их все в одной куче. Так были выделены три таких вида:

  • Относящиеся к работе с шаблонами — templates;
  • Относящиеся к работе с фильтром (сортировкой, отображением колонок) — filter;
  • Относящиеся к работе с таблицей (загрузке новых данных при прокрутке, поскольку в таблице работает виртуальный скролл, загрузке мета-данных, загрузке данных по счётчику записей в таблице и т. п.) — table.

Была предложена следующая структура папок и файлов:

src/
  |-- store/
  |  |-- filter/
  |  |  |-- actions/
  |  |  |  |-- thunks/
  |  |  |  |  |-- __tests__/
  |  |  |  |  |  |-- applyFilter.test.js
  |  |  |  |  |-- applyFilter.js
  |  |  |  |-- actionCreators.js
  |  |  |  |-- index.js
  |  |-- table/
  |  |  |-- actions/
  |  |  |  |-- thunks/
  |  |  |  |  |-- __tests__/
  |  |  |  |  |  |-- fetchData.test.js
  |  |  |  |  |  |-- fetchTotalCounter.test.js
  |  |  |  |  |-- fetchData.js
  |  |  |  |  |-- fetchTotalCounter.js
  |  |  |  |-- actionCreators.js
  |  |  |  |-- index.js (main file with actionsCreator)

Пример с данной архитектурой находится здесь.

В файле тестов для applyFilter можно увидеть, что мы достигли той цели, к которой стремились — можно не писать моки для поддержания корректной работы fetchData/fetchTotalCounter. Но какой ценой...

import { applyFilterSuccess, applyFilterError } from '../';

import { fetchData } from '../../../table/actions';

// selector
const getFilter = store => store.filter;

export function applyFilter(prefix, getCurrentStore, entityModel) {
  return newFilter => {
    return async (dispatch, getStore) => {
      try {
        const store = getStore();
        const currentStore = getCurrentStore(store);
        const filter = newFilter || getFilter(currentStore);
        const { data: { payload } } = await fetchData(prefix, entityModel)(filter, dispatch);

        dispatch(applyFilterSuccess(prefix)(payload));
      } catch (error) {
        dispatch(applyFilterError(prefix)(error));
      }
    };
  };
}

import * as filterActions from './filter/actions';
import * as tableActions from './table/actions';

export const actionsCreator = (prefix, getCurrentStore, entityModel) => {
  return {
    fetchTotalCounterStart: tableActions.fetchTotalCounterStart(prefix),
    fetchTotalCounterSuccess: tableActions.fetchTotalCounterSuccess(prefix),
    fetchTotalCounterError: tableActions.fetchTotalCounterError(prefix),
    applyFilterSuccess: filterActions.applyFilterSuccess(prefix),
    applyFilterError: filterActions.applyFilterError(prefix),

    fetchTotalCounter: tableActions.fetchTotalCounter(prefix, entityModel),
    fetchData: tableActions.fetchData(prefix, entityModel),
    applyFilter: filterActions.applyFilter(prefix, getCurrentStore, entityModel)
  };
};

За модульность тестов пришлось заплатить дублированием кода и очень сильной зависимостью thunk-ов друг от друга. Малейшее изменение в цепочке вызовов приведёт к тяжёлому рефакторингу.

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

После возвращения из отпуска я с удивлением обнаружил, что мои изменения откатили. Оказалось, что появилась-таки страница, на которой может быть несколько независимых таблиц, то есть сделанное ранее допущение всё ломало. Так что вся работа была выполнена зря...

Ну почти. На самом деле можно было бы повторно сделать все те же действия (благо merge/pull request никуда не пропал, а остался в истории), оставив подход к архитектуре шаблонов неизменным, а изменив лишь подход к организации thunk-ов. Но такой подход всё же не внушал доверия из-за своей связанности и сложности. Возвращаться к нему не было никакого желания, хотя это и решало обозначенную проблему с тестированием. Нужно было придумать что-то другое, более простое и более надёжное.

Попытка решения проблемы №3. Кто ищет, тот найдёт

Взглянув глобально на то, как пишутся тесты для thunk-ов, я обратил внимание на то, как легко и без каких-либо проблем мокаются методы (по сути, поля объекта) entityModel.

Тогда возникла идея: а почему бы не создать класс, методами которого будут thunk-и и action creator-ы? Параметры, передаваемые в фабрику, будут передаваться в конструктор такого класса и будут доступны через this. Сразу можно сделать небольшую оптимизацию, сделав отдельный класс для action creator-ов и отдельный — для thunk-ов, а потом наследовать один от другого. Таким образом, данные классы будут работать как один (при создании экземпляра класса наследника), но при этом каждый класс по отдельности будет проще читать, понимать и тестировать.

Здесь находится код с демонстрацией такого подхода.

Рассмотрим более детально каждый из появившихся и изменившихся файлов.

  • В файле FilterActionCreators.js объявляем класс, в котором методы представляют собой action creator-ы:

export class FilterActionCreators {
  constructor(config) {
    this.prefix = config.prefix;
  }

  applyFilterSuccess = payload => ({
    type: `${this.prefix}APPLY_FILTER_SUCCESS`,
    payload,
  });

  applyFilterError = error => ({
    type: `${this.prefix}APPLY_FILTER_ERROR`,
    error,
  });
}

  • В файле FilterActions.js производим наследование от класса FilterActionCreators и определяем thunk applyFilter как метод этого класса. При этом action creator-ы applyFilterSuccess и applyFilterError будут доступны в нём через this:

import { FilterActionCreators } from '/FilterActionCreators';

// selector
const getFilter = store => store.filter;

export class FilterActions extends FilterActionCreators {
  constructor(config) {
    super(config);

    this.getCurrentStore = config.getCurrentStore;
    this.entityModel = config.entityModel;
  }

  applyFilter = ({ fetchData }) => {
    return newFilter => {
      return async (dispatch, getStore) => {
        try {
          const store = getStore();
          const currentStore = this.getCurrentStore(store);
          const filter = newFilter || getFilter(currentStore);
          const { data: { payload } } = await fetchData(filter, dispatch);

          // Comes from FilterActionCreators
          dispatch(this.applyFilterSuccess(payload));
        } catch (error) {
          // Comes from FilterActionCreators
          dispatch(this.applyFilterError(error));
        }
      };
    };
  };
}

  • В основном файле со всеми thunk-ами и action creator-ами создаём экземпляр класса FilterActions, передав ему необходимый объект конфигурации. При экспорте функций (в самом конце функции actionsCreator) не забываем переопределить метод applyFilter, чтобы передать в него зависимость fetchData:

+ import { FilterActions } from './filter/actions/FilterActions';
- // selector
- const getFilter = store => store.filter;

export const actionsCreator = (prefix, getCurrentStore, entityModel) => {
+ const config = { prefix, getCurrentStore, entityModel };
+ const filterActions = new FilterActions(config);
  /* --- ACTIONS BLOCK --- */
  function fetchTotalCounterStart() {
    return { type: `${prefix}FETCH_TOTAL_COUNTER_START` };
  }

  function fetchTotalCounterSuccess(payload) {
    return { type: `${prefix}FETCH_TOTAL_COUNTER_SUCCESS`, payload };
  }

  function fetchTotalCounterError(error) {
    return { type: `${prefix}FETCH_TOTAL_COUNTER_ERROR`, error };
  }

- function applyFilterSuccess(payload) {
-   return { type: `${prefix}APPLY_FILTER_SUCCESS`, payload };
- }
-
- function applyFilterError(error) {
-   return { type: `${prefix}APPLY_FILTER_ERROR`, error };
- }

  /* --- THUNKS BLOCK --- */
  function fetchTotalCounter(filter) {
    return async dispatch => {
      dispatch(fetchTotalCounterStart());

      try {
        const { data: { payload } } = await entityModel.fetchTotalCounter(filter);

        dispatch(fetchTotalCounterSuccess(payload));
      } catch (error) {
        dispatch(fetchTotalCounterError(error));
      }
    };
  }

  function fetchData(filter, dispatch) {
    dispatch(fetchTotalCounter(filter));

    return entityModel.fetchData(filter);
  }

- function applyFilter(newFilter) {
-   return async (dispatch, getStore) => {
-     try {
-       const store = getStore();
-       const currentStore = getCurrentStore(store);
-       // 'getFilter' comes from selectors.
-       const filter = newFilter || getFilter(currentStore);
-       const { data: { payload } } = await fetchData(filter, dispatch);
-
-       dispatch(applyFilterSuccess(payload));
-     } catch (error) {
-       dispatch(applyFilterError(error));
-     }
-   };
- }

  return {
    fetchTotalCounterStart,
    fetchTotalCounterSuccess,
    fetchTotalCounterError,
-   applyFilterSuccess,
-   applyFilterError,

    fetchTotalCounter,
    fetchData,
-   applyFilter
+   ...filterActions,
+   applyFilter: filterActions.applyFilter({ fetchData }),
  };
};

  • Тесты стали немного проще и в реализации, и в чтении:

import { FilterActions } from '../FilterActions';

describe('FilterActions', () => {
  const prefix = 'TEST_';
  const getCurrentStore = store => store;
  const entityModel = {};
  const config = { prefix, getCurrentStore, entityModel };
  const actions = new FilterActions(config);
  const dispatch = jest.fn();

  beforeEach(() => {
    dispatch.mockClear();
  });

  describe('applyFilter', () => {
    const getStore = () => ({});
    const newFilter = {};

    it('should dispatch correct actions on success', async () => {
      const payload = {};
      const fetchData = jest.fn().mockResolvedValueOnce({ data: { payload } });
      const applyFilterSuccess = jest.spyOn(actions, 'applyFilterSuccess');

      await actions.applyFilter({ fetchData })(newFilter)(dispatch, getStore);

      expect(fetchData).toBeCalledWith(newFilter, dispatch);
      expect(applyFilterSuccess).toBeCalledWith(payload);
    });

    it('should dispatch correct actions on error', async () => {
      const error = {};
      const fetchData = jest.fn().mockRejectedValueOnce(error);
      const applyFilterError = jest.spyOn(actions, 'applyFilterError');

      await actions.applyFilter({ fetchData })(newFilter)(dispatch, getStore);

      expect(fetchData).toBeCalledWith(newFilter, dispatch);
      expect(applyFilterError).toBeCalledWith(error);
    });
  });
});

В принципе, в тестах можно было бы заменить последнюю проверку таким образом:

- expect(applyFilterSuccess).toBeCalledWith(payload);
+ expect(dispatch).toBeCalledWith(applyFilterSuccess(payload));

- expect(applyFilterError).toBeCalledWith(error);
+ expect(dispatch).toBeCalledWith(applyFilterError(error));

Тогда и мокать их с помощью jest.spyOn не было бы нужды. Это было сделано намеренно, чтобы продемонстрировать, как легко мокаются методы класса и как легко их при этом тестировать. Таким же образом можно замокать и другой thunk, если он является методом того же класса. Выглядит уже довольно прилично, но есть одна проблема...

Предыдущая попытка помимо того, что показала, как НЕ стоит делать, всё же дала ещё кое-что: понимание, что thunk-и и action creator-ы необходимо разделить по их функциональному назначению, чтобы упростить их тестирование, чтение и понимание. В данном случае у нас отдельно вынесена функциональность, связанная с фильтрами. Если пойти дальше и перевести все функции внутри actionsCreator-а на данный подход, то получится следующее:

import { FilterActions } from './filter/actions/FilterActions';
import { TemplatesActions } from './templates/actions/TemplatesActions';
import { TableActions } from './table/actions/TableActions';

export const actionsCreator = (prefix, getCurrentStore, entityModel) => {
  const config = { prefix, getCurrentStore, entityModel };
  const filterActions = new FilterActions(config);
  const templatesActions = new TemplatesActions(config);
  const tableActions = new TableActions(config);

  return {
    ...filterActions,
    ...templatesActions,
    ...tableActions,
  };
};

Всё получается красиво и модульно. Но что если в filterActions в одном или нескольких методах нужны будут методы из templatesActions или tableActions, а в них, в свою очередь, будут нужны методы из filterActions? В таком случае мы сталкиваемся с классической проблемой курицы и яйца, с проблемой циклических зависимостей. И это стало серьёзной проблемой на пути реализации данного подхода. Не хотелось бы отказываться от такого привлекательного подхода из-за этого, и при этом не понятно было, как преодолеть эту проблему.

Посоветовавшись с тех. лидом и не найдя очевидного решения, мы решили узнать у back-end разработчиков (пишущих на Java), как у них решается эта проблема. Насколько я понял, в Java/Spring инициализация сущностей производится в несколько этапов, а не сразу. Почему бы тогда и нам не попробовать сделать что-то подобное?

В итоге пришли к такому решению:

  • В классе с thunk-ами описывается метод setDependencies, которой при вызове инициализирует новое поле класса — dependencies:

export class FilterActions extends FilterActionCreators {
  constructor(config) {
    super(config);

    this.getCurrentStore = config.getCurrentStore;
    this.entityModel = config.entityModel;
  }

+ setDependencies = dependencies => {
+   this.dependencies = dependencies;
+ };

  • В фабрике добавляются этапы сбора всех методов в одном месте и установки их в качестве зависимостей для каждого из экземпляров классов:

import { FilterActions } from './filter/actions/FilterActions';
import { TemplatesActions } from './templates/actions/TemplatesActions';
import { TableActions } from './table/actions/TableActions';

export const actionsCreator = (prefix, getCurrentStore, entityModel) => {
  const config = { prefix, getCurrentStore, entityModel };
  const filterActions = new FilterActions(config);
  const templatesActions = new TemplatesActions(config);
  const tableActions = new TableActions(config);
+ const actions = {
+   ...filterActions,
+   ...templatesActions,
+   ...tableActions,
+ };
+
+ filterActions.setDependencies(actions);
+ templatesActions.setDependencies(actions);
+ tableActions.setDependencies(actions);

  return actions;
};

  • Теперь зависимости доступны через this.dependencies:

applyFilter = newFilter => {
  const { fetchData } = this.dependencies;

  return async (dispatch, getStore) => {
    try {
      const store = getStore();
      const currentStore = this.getCurrentStore(store);
      const filter = newFilter || getFilter(currentStore);
      const { data: { payload } } = await fetchData(filter, dispatch);

      // Comes from FilterActionCreators
      dispatch(this.applyFilterSuccess(payload));
    } catch (error) {
      // Comes from FilterActionCreators
      dispatch(this.applyFilterError(error));
    }
  };
};

Данный подход работает потому, что во время создания экземпляра класса методу applyFilter нет необходимости знать, что есть какое-то поле this.dependencies и чему оно равно. Это необходимо знать только в момент вызова этого метода, а к этому моменту данное поле уже будет определено.

  • Немного меняем тесты:

import { FilterActions } from '../FilterActions';

describe('FilterActions', () => {
  const prefix = 'TEST_';
  const getCurrentStore = store => store;
  const entityModel = {};
+ const dependencies = {
+   fetchData: jest.fn(),
+ };
  const config = { prefix, getCurrentStore, entityModel };
  const actions = new FilterActions(config);
+ actions.setDependencies(dependencies);
  const dispatch = jest.fn();

  beforeEach(() => {
    dispatch.mockClear();
  });

  describe('applyFilter', () => {
    const getStore = () => ({});
    const newFilter = {};

    it('should dispatch correct actions on success', async () => {
      const payload = {};
-     const fetchData = jest.fn().mockResolvedValueOnce({ data: { payload } });
+     dependencies.fetchData.mockResolvedValueOnce({ data: { payload } });
      const applyFilterSuccess = jest.spyOn(actions, 'applyFilterSuccess');

-      await actions.applyFilter({ fetchData })(newFilter)(dispatch, getStore);
+      await actions.applyFilter(newFilter)(dispatch, getStore);

-     expect(fetchData).toBeCalledWith(newFilter, dispatch);
+     expect(dependencies.fetchData).toBeCalledWith(newFilter, dispatch);
      expect(applyFilterSuccess).toBeCalledWith(payload);
    });

    it('should dispatch correct actions on error', async () => {
      const error = {};
-     const fetchData = jest.fn().mockRejectedValueOnce(error);
+     dependencies.fetchData.mockRejectedValueOnce(error);
      const applyFilterError = jest.spyOn(actions, 'applyFilterError');

-      await actions.applyFilter({ fetchData })(newFilter)(dispatch, getStore);
+      await actions.applyFilter(newFilter)(dispatch, getStore);

-     expect(fetchData).toBeCalledWith(newFilter, dispatch);
+     expect(dependencies.fetchData).toBeCalledWith(newFilter, dispatch);
      expect(applyFilterError).toBeCalledWith(error);
    });
  });
});

Итоговый код доступен здесь.

Если появится необходимость переопределять некоторые из методов, которые возвращает фабрика, то при описанном выше подходе понадобится сделать всего лишь пару простых шагов:

  • Немного расширить фабрику:

import { FilterActions } from './filter/actions/FilterActions';
import { TemplatesActions } from './templates/actions/TemplatesActions';
import { TableActions } from './table/actions/TableActions';

- export const actionsCreator = (prefix, getCurrentStore, entityModel) => {
+ export const actionsCreator = (prefix, getCurrentStore, entityModel, ExtendedActions) => {
  const config = { prefix, getCurrentStore, entityModel };
  const filterActions = new FilterActions(config);
  const templatesActions = new TemplatesActions(config);
  const tableActions = new TableActions(config);
+ const extendedActions = ExtendedActions ? new ExtendedActions(config) : undefined;
  const actions = {
    ...filterActions,
    ...templatesActions,
    ...tableActions,
+   ...extendedActions,
  };

  filterActions.setDependencies(actions);
  templatesActions.setDependencies(actions);
  tableActions.setDependencies(actions);

+ if (extendedActions) {
+   extendedActions.setDependencies(actions);
+ }

  return actions;
};

  • В передаваемом ExtendedActions определить такой же интерфейс, как и в остальных классах:

export class ExtendedActions {
  constructor(config) {
    this.getCurrentStore = config.getCurrentStore;
    this.entityModel = config.entityModel;
  }

  setDependencies = dependencies => {
    this.dependencies = dependencies;
  };

  // methods to re-define
}

Таким образом, мы решили следующие проблемы, применив данный подход:

  • Код стал лучше организован, в нём стало проще разбираться;
  • Архитектура стала более гибкой и расширяемой;
  • Тесты стало гораздо легче писать, решилась изначальная проблема, когда при вызове одного thunk-а другим необходимо было писать моки для всей цепочки вызовов;
  • В нашем случае, поскольку писать тесты стало значительно проще, покрытие всех thunk-ов/action creator-ов в этой фабрике теперь составляет 99-100%.

Бонус

Поскольку все action creator-ы были разбиты на части (filter, templates, table), то и reducer-ы необходимо было как-то удобно организовать, поскольку основной файл с ними так же, как и код actionsCreator-а ранее, содержал в себе одну функцию со всеми возможными reducer-ами на ~400-500 строк кода.

В итоге пришли к такому решению:

  • Общий файл reducer-ов:

import isNull from 'lodash/isNull';

import { getDefaultState } from '../getDefaultState';
import { templatesReducerConfigurator } from 'src/store/templates/reducers/templatesReducerConfigurator';
import { filterReducerConfigurator } from 'src/store/filter/reducers/filterReducerConfigurator';
import { tableReducerConfigurator } from 'src/store/table/reducers/tableReducerConfigurator';

export const createTableReducer = (
  prefix,
  initialState = getDefaultState(),
  entityModel,
) => {
  const config = { prefix, initialState, entityModel };
  const templatesReducer = templatesReducerConfigurator(config);
  const filterReducer = filterReducerConfigurator(config);
  const tableReducer = tableReducerConfigurator(config);

  return (state = initialState, action) => {
    const templatesState = templatesReducer(state, action);

    if (!isNull(templatesState)) {
      return templatesState;
    }

    const filterState = filterReducer(state, action);

    if (!isNull(filterState)) {
      return filterState;
    }

    const tableState = tableReducer(state, action);

    if (!isNull(tableState)) {
      return tableState;
    }

    return state;
  };
};

  • tableReducerConfigurator (для примера):

export const tableReducerConfigurator = ({ prefix, entityModel }) => {
  return (state, action) => {
    switch (action.type) {
      case `${prefix}FETCH_TOTAL_COUNTER_START`: {
        return {
          ...state,
          isLoading: true,
          error: null,
        };
      }

      case `${prefix}FETCH_TOTAL_COUNTER_SUCCESS`: {
        return {
          ...state,
          isLoading: false,
          counter: action.payload,
        };
      }

      case `${prefix}FETCH_TOTAL_COUNTER_ERROR`: {
        return {
          ...state,
          isLoading: false,
          error: action.error,
        };
      }

      default: {
        return null;
      }
    }
  };
};

Получается следующее:

  1. В каждом reducerConfigurator-е описывается свой набор action type-ов, которые он "слушает". Если action type не подходит ни под один case, то он возвращает null (например).
  2. Поскольку из reducerConfigurator-а может вернуться либо объект нового состояния, либо null, мы проверяем результаты работы reducerConfigurator-а на !null. Если проверка пройдена успешно, значит в данном reducerConfigurator-е обнаружился подходящий case, и нет надобности проверять остальные reducerConfigurator-ы.
  3. В том случае, если ни в одном из reducerConfigurator-ов не нашлось подходящего case-а для переданного action type-а, возвращаем состояние без изменений (стандартная реализация reducer-ов).

То есть так же, как и в actionsCreator-е, код разбивается на мелкие части и его становится легче и читать, и понимать, и тестировать.

На этом всё, спасибо за внимание!
Надеюсь, это поможет вам улучшить организацию и покрытие вашего кода при работе с Redux Thunk.

Пишите в комментариях, пользуетесь ли вы Redux Thunk и как тестируете описанные выше случаи. Будет интересно узнать, какие ещё подходы существуют.

Автор: Василий Ковалев

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js