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 table (No. 1) contains data that can be very different (plain text, links, pictures, etc.).
- The sorting panel (No. 2) sets the data sorting parameters in the table by columns.
- The filtering panel (No. 3) sets various filters according to the columns of the table.
- The column panel (No. 4) allows you to set the display of the table columns (show / hide).
- The panel of templates (No. 5) allows you to select previously created settings templates. Templates include data from panels No. 2, No. 3, No. 4, as well as some other data, for example, the position of the columns, their size, etc.
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:
- Data from the meta data of the table.
- Settings for the currently selected template.
- User settings (any changes regarding the selected template are saved in a kind of “draft”, which can be either converted into a new template, or update the current one with new settings, or delete them and return the template to its original state).
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) => { 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 }; } 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);
Where:
prefix
- entity prefix in Redux repository. It is a string of the form "CATS_", "MICE_", etc.getCurrentStore
- a selector that returns the current data on the entity from the Redux repository.entityModel
- an instance of the entity model class. On the one hand, an api is accessed through the model to create a request to the server, on the other hand, some complex (or not so) data processing logic is described.
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:
fetchTotalCounter
- dispatch-it only fetchTotalCounter
.applyFilter
- in addition to the dispatch of the applyFilter
belonging to it ( applyFilterSuccess
, applyFilterError
), dispatch-it is also another thunk ( fetchTotalCounter
).
We will return to them a little later.
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 = [
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:
- Related to working with templates -
templates
. - Related to working with the filter (sorting, displaying columns) -
filter
. - Related to working with the table (loading new data when scrolling, since the table has a virtual scroll, loading meta-data, loading data by the counter of records in the table, etc.) -
table
.
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';
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, }); }
- In the
FilterActions.js
file, FilterActions.js
inherit from the FilterActionCreators
class and define thunk applyFilter
as a method of this class. In this case, the action applyFilterSuccess
and applyFilterError
will be available in it through this
:
import { FilterActionCreators } from '/FilterActionCreators';
- In the main file with all the thunk and action
FilterActions
, we create an instance of the FilterActions
class, passing it the necessary configuration object. When exporting functions (at the very end of the actionsCreator
function), do not forget to override the applyFilter
method to pass the fetchData
dependency to 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 }), }; };
- Tests have become a little easier both in implementation and in reading:
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 , . - ?
:
- 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; - 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);
, 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; };
, , :
- , .
- .
- , , thunk- .
- , , thunk-/action creator- 99-100%.
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; }; };
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; } } }; };
:
reducerConfigurator
- action type-, «». action type case, null ().reducerConfigurator
- , null , reducerConfigurator
- !null . , reducerConfigurator
- case, reducerConfigurator
-.- ,
reducerConfigurator
- case- action type-, ( reducer-).
, actionsCreator
-, , , , .
, !
, Redux Thunk.
, Redux Thunk . , .