Description of the approach to organizing and testing code using Redux Thunk

Hello!







In this post, I would like to share my approach to organizing and testing code using Redux Thunk in a React project.







The path to it was long and thorny, so I will try to demonstrate the train of thought and motivation that led to the final decision.







Description of the application and problem statement



First, a little context.







The figure below shows the layout of a typical page in our project.













In order:









The panels are opened by clicking on the corresponding buttons.







Data on what columns in the table can be, what data can be in them, how they should be displayed, what values ​​filters can contain, and other information is contained in the meta-data of the table, which is requested separately from the data itself at the beginning of the page loading.







It turns out that the current state of the table and the data in it depends on three factors:









As mentioned above, such a page is typical. For each such page (or more precisely, for the table in it), a separate entity is created in the Redux repository for the convenience of operating with its data and parameters.







In order to be able to set homogeneous sets of thunk and action creators and update data on a specific entity, the following approach is used (a kind of factory):







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





Where:









Thus, this factory allows you to flexibly describe the management of data and parameters of a specific entity in the Redux repository and associate this with the table corresponding to this entity.







Since there are a lot of nuances in managing this system, thunk can be complex, voluminous, confusing and have repeating parts. To simplify them, as well as to reuse the code, complex thunks are broken down into simpler ones and combined into a composition. As a result of this, now it may fetchTotalCounter



out that one thunk calls another, which can already dispatch ordinary applyFilter



(like the applyFilter



bundle - fetchTotalCounter



from the example above). And when all the main points were taken into account, and all the necessary thunk and action creators were described, the file containing the actionsCreator



function had ~ 1200 lines of code and was tested with great squeak. The test file also had about 1200 lines, but the coverage was at best 40-50%.







Here, the example, of course, is greatly simplified, both in terms of the number of thunk and their internal logic, but this will be quite enough to demonstrate the problem.







Pay attention to 2 types of thunk in the example above:









All this was tested as follows (the framework was used to test 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); }); }); }); });
      
      





As you can see, there are no problems with testing the first type of thunk - you just need to hook the entityModel model entityModel



, but the second type is more complicated - you have to wipe the data for the entire chain of called thunk and the corresponding model methods. Otherwise, the test will fall on the destructuring of the data ( {data: {payload}} ), and this can happen either explicitly or implicitly (it was such that the test passed successfully, but with careful research it was noticed that in the second / third link of this chain there was a drop in the test due to the lack of locked data). It is also bad that unit tests of individual functions turn into a kind of integration, and become closely related.







The question arises: why in the applyFilter



function check how the fetchTotalCounter



function fetchTotalCounter



if separate detailed tests have already been written for it? How can I make testing the second type of thunk more independent? It would be great to be able to test that thunk (in this case fetchTotalCounter



) is just called with the right parameters , and there would be no need to take care of the mokas for it to work correctly.







But how to do that? The obvious decision comes to mind: to hook the fetchData function, which is called in applyFilter



, or to lock the fetchTotalCounter



(since often another thunk is called directly, and not through some other function like fetchData



).







Let's try it. For example, we will change only a successful script.









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





Here, the jest.spyOn



method replaces roughly (and maybe exactly) the following implementation:







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





This allows us to "monitor" the function and understand whether it was called and with what parameters.







We get the following error:







 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", + }, ]
      
      





Strange, we kind of hid the fetchData function, fetchData



our implementation







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





but the function works exactly the same as before, that is, the mock did not work! Let's try it differently.









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





We get exactly the same error. For some reason, our mokas do not replace the original implementation of functions.







Having researched this problem on my own and finding some information on the Internet, I realized that this problem exists not only with me, and it is solved (in my opinion) rather crutally. Moreover, the examples described in these sources are good until they become part of something that connects them into a single system (in our case, this is a factory with parameters).







On our project in Jenkins pipline there is a code check from SonarQube, which requires covering modified files (which are in the merge / pull request) > 60%



. Since the coverage of this factory, as mentioned earlier, was unsatisfactory, and the very need to cover such a file caused only depression, something had to be done with it, otherwise the delivery of new functionality could slow down over time. Only the test coverage of other files (components, functions) in the same merge / pull request saved, in order to reach the% coverage to the desired mark, but, in fact, it was a workaround, not a solution to the problem. And one fine moment, having allocated some time in the sprint, I began to think how this problem can be solved.







An attempt to solve the problem number 1. I heard something about Redux-Saga ...



... and they told me that testing is greatly simplified when using this middleware.







Indeed, if you look at the documentation , you are surprised at how simple the code is tested. The juice itself lies in the fact that with this approach there is no problem at all with the fact that some saga can cause another saga - we can get wet and “listen” to the functions provided by middleware ( put



, take



, etc.), and verify that they were called (and called with the correct parameters). That is, in this case, the function does not access another function directly, but refers to a function from the library, which then calls other necessary functions / sagas.







“Why not try this middleware?” I thought, and set to work. He started a technical history in Jira, created several tasks in it (from research to implementation and description of the architecture of this whole system), received the “go-ahead” and began making a minimal copy of the current system with a new approach.







In the beginning, everything went well. On the advice of one of the developers, it was even possible to create a global saga for loading data and error handling on a new approach. However, at some point there were problems with testing (which, incidentally, have not been resolved so far). I thought that this could destroy all the tests currently available and produce a bunch of bugs, so I decided to postpone the work on this task until there was some solution to the problem, and got down to product tasks.







A month or two passed, no solution was found, and at some point, having discussed with those. leading (absent) progress on this task, they decided to abandon the implementation of Redux-Saga in the project, since by that time it had become too expensive in terms of labor costs and the possible number of bugs. So we finally settled on using Redux Thunk.







An attempt to solve the problem number 2. Thunk modules



You can sort all thunk into different files, and in those files where one thunk calls another (imported), you can wipe this import either using the jest.mock



method or using the same jest.spyOn



. Thus, we will achieve the above task of verifying that some external thunk was called with the necessary parameters, without worrying about moks for it. In addition, it would be better to break all the thunk according to their functional purpose, so as not to keep them all in one heap. So three such species were distinguished:









The following folder and file structure was proposed:







 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)
      
      





An example of this architecture is here .







In the test file for applyFilter, you can see that we have reached the goal we were striving for - you can skip writing moki to maintain the correct operation of fetchData



/ fetchTotalCounter



. But at what cost ...









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





We had to pay for the modularity of the tests with duplication of code and a very strong dependence of thunk on each other. The slightest change in the call chain will lead to heavy refactoring.







In the example above, the example for table



and filter



was demonstrated in order to maintain consistency of the given examples. In fact, refactoring was started with templates



(since it turned out to be simpler), and there, in addition to the refactoring above, the concept of working with templates was slightly changed. As an assumption, it was accepted that there can only be one panel of templates on a page (like a table). It was exactly like that at that time, and this omission the assumption allowed us to simplify the code a bit by getting rid of prefix



.

After the changes were poured into the main development branch and tested, I went on vacation with a calm soul in order to continue transferring the rest of the code to a new approach after returning.







After returning from vacation, I was surprised to find that my changes were rolled back. It turned out that a page appeared on which there could be several independent tables, that is, the earlier assumption broke everything. So all the work was done in vain ...







Almost. In fact, it would be possible to re-do all the same actions (the benefit of merge / pull request did not disappear, but remained in history), leaving the approach to the template architecture unchanged, and changing only the approach to organizing thunk-s. But this approach still did not inspire confidence because of its coherence and complexity. There was no desire to return to it, although this solved the indicated problem with testing. It was necessary to come up with something else, simpler and more reliable.







An attempt to solve the problem number 3. He who seeks will find



Looking globally at how tests are written for thunk, I noticed how easily and without any problems methods (in fact, object fields) of entityModel



.







Then the idea came up: why not create a class whose methods are thunk and action creators? Parameters passed to the factory will be passed to the constructor of this class and will be available through this



. You can immediately make a small optimization by making a separate class for action creators and a separate one for thunk, and then inherit one from another. Thus, these classes will work as one (when creating an instance of the heir class), but at the same time each class individually will be easier to read, understand and test.







Here is a code demonstrating this approach.







Let's consider in more detail each of the appeared and changed files.









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







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







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





In principle, in tests, you could replace the last check in this way:







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





Then there would be no need to dab them with jest.spyOn



. This was done intentionally to demonstrate how easily class methods get wet and how easy it is to test them. 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 , . - ?







:









 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; - return { - ...filterActions, - ...templatesActions, - ...tableActions, - }; };
      
      







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







 export class ExtendedActions { constructor(config) { this.getCurrentStore = config.getCurrentStore; this.entityModel = config.entityModel; } setDependencies = dependencies => { this.dependencies = dependencies; }; // methods to re-define }
      
      





, , :









Bonus



action creator- ( filter



, templates



, table



), reducer- - , , actionsCreator



- , reducer- ~400-500 .







:









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







 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 . , .








All Articles