使用Redux Thunk组织和测试代码的方法的描述

大家好!


在本文中,我想分享我在React项目中使用Redux Thunk组织和测试代码的方法。


通往之路漫长而棘手,所以我将尝试证明导致最终决定的思路和动力。


应用说明和问题陈述


首先,有一点背景。


下图显示了我们项目中典型页面的布局。



为了:


  • 该表(第1号)包含的数据可能非常不同(纯文本,链接,图片等)。
  • 排序面板(第2号)按列设置表中的数据排序设置。
  • 过滤面板(第3号)按表格列设置各种过滤器。
  • 列面板(第4号)允许您设置表列的显示(显示/隐藏)。
  • 模板面板(编号5)允许您选择以前创建的设置模板。 模板包括第2号,第3号,第4号面板的数据,以及其他一些数据,例如列的位置,其大小等。

单击相应按钮可打开面板。


表的元数据中包含有关表中哪些列可以是什么,数据中可以包含哪些数据,应如何显示它们,过滤器可以包含哪些值以及其他信息的数据,这些信息在页面加载开始时就与数据本身分开请求。


事实证明,表及其中的数据的当前状态取决于三个因素:


  • 来自表元数据的数据。
  • 当前所选模板的设置。
  • 自定义设置(有关所选模板的任何更改都以“草稿”的形式保存,可以将其转换为新模板,或者使用新设置更新当前模板,或者删除它们并将模板恢复为原始状态)。

如上所述,这样的页面是典型的。 对于每个这样的页面(或更确切地说,对于其中的表),为了方便使用其数据和参数在Redux存储库中创建一个单独的实体。


为了能够设置同质的动作创建者和动作创建者集合并更新特定实体上的数据,使用了以下方法 (一种工厂):


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分解为更简单的thunk,并组合为一个组合。 结果,现在可能发现一个thunk调用了另一个thunk,后者已经可以分派常规applyFilter (例如上例中的applyFilter捆绑包fetchTotalCounter )。 当考虑到所有要点并描述了所有必要的重击和动作创建者后,包含actionsCreator函数的文件将包含约1200行代码,并经过了非常出色的测试。 测试文件也有大约1200行,但覆盖率最多为40-50%。


当然,在此示例中,无论从重击的数量还是其内部逻辑上都大大简化了示例,但这足以说明问题。


在上面的示例中,请注意两种类型的thunk:


  • fetchTotalCounter分派fetchTotalCounter
  • applyFilter除了分配属于它的applyFilterapplyFilterSuccessapplyFilterError )外,dispatch-it也是另一个fetchTotalCounterfetchTotalCounter )。
    我们稍后再返回。

所有这些都按以下方式进行了测试(该框架用于测试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}} )上,并且可以显式地或隐式地进行(测试成功通过,但是经过仔细研究,我们注意到在第二/第三部分)由于缺少锁定数据,测试链下降了)。 单个功能的单元测试变成一种集成,并且变得紧密相关也是不好的。


出现了一个问题:如果已经为它编写了单独的详细测试,为什么在applyFilter函数中检查fetchTotalCounter函数的fetchTotalCounter ? 如何使第二种类型的thunk测试更加独立? 有机会测试一下thunk (在本例中为fetchTotalCounter )是使用正确的参数调用的,这将是非常棒的,并且不需要担心moks才能正常工作。


但是怎么做呢? 一个显而易见的决定浮现在脑海:挂接到applyFilter中调用的fetchData函数,或者锁定fetchTotalCounter (因为通常直接调用另一个thunk,而不是通过诸如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我们的实现括起来


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

我们得到完全相同的错误。 由于某些原因,我们的mokas并不能替代功能的原始实现。


我自己研究了这个问题并在Internet上找到了一些 信息后 ,我意识到这个问题不仅存在于我自己,而且(在我看来)已被完全解决。 此外,这些资料中描述的示例都是好的,直到它们成为将它们连接到单个系统中的一部分(在我们的情况下,这是一个有参数的工厂)。


在我们位于Jenkins pipline的项目中,SonarQube进行了代码检查,该检查要求覆盖修改后的文件(在合并/提取请求中) > 60% 。 如前所述,由于该工厂的覆盖范围不令人满意,并且非常需要覆盖此类文件才导致沮丧,因此我们必须对此做些事情,否则新功能的交付可能会随着时间的流逝而变慢。 为了使覆盖率达到所需的标记,仅保存了同一合并/提取请求中其他文件(组件,功能)的测试覆盖率,但是,实际上,这是一种解决方法,而不是问题的解决方案。 好一会儿,在冲刺中花了一点时间,我开始思考如何解决这个问题。


尝试解决问题编号1。 我听说过Redux-Saga的事...


...并且他们告诉我使用此中间件时,测试会大大简化。


确实,如果您查看文档 ,您会对代码的测试如此简单感到惊讶。 果汁本身的事实是,使用这种方法根本没有问题,因为某些传奇可以调用另一个传奇-我们可以弄湿并“监听”中间件提供的功能( puttake等),并且验证它们是否被调用(并使用正确的参数调用)。 也就是说,在这种情况下,该函数不会直接访问另一个函数,而是从库中引用一个函数,然后该库将调用其他必需的函数/ sagas。


“为什么不尝试这种中间件?” 我想,就去上班。 他在吉拉(Jira)开始了一个技术历史,在其中创建了几个任务(从研究到整个系统的体系结构的实现和描述),获得了“反超”,并开始使用新方法对当前系统进行最小化复制。


一开始,一切进展顺利。 在一位开发人员的建议下,甚至有可能创建一个全局传奇,以一种新方法加载数据并进行错误处理 。 但是,在某些时候测试存在问题 (顺便说一下,到目前为止还没有解决)。 我认为这可能会破坏当前可用的所有测试并产生大量错误,因此我决定推迟完成此任务的工作,直到找到解决问题的办法,然后再执行产品任务。


一两个月过去了,没有找到解决方案,在某个时候,与他们进行了讨论。 由于领导(缺少)该任务的进度,他们决定放弃Redux-Saga在该项目中的实施,因为到那时,它在人工成本和可能的bug数量上已经变得太昂贵了。 因此,我们最终决定使用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测试文件中,您可以看到我们已经达到了我们追求的目标-您无法编写mokas来维护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彼此非常依赖的代价。 调用链中的任何细微变化都会导致繁重的重构。


在上面的示例中,演示了tablefilter示例,以保持给定示例的一致性。 实际上,重构是从templates开始的(事实证明它很简单),并且除了上面的重构之外,使用模板的概念也有所变化。 作为一种假设,已经接受了一个页面(如表格)上只能有一个模板面板。 当时就是这样 遗漏 该假设使我们可以通过删除prefix来简化代码。
将更改注入主开发分支并进行测试后,我带着镇定的心去度假,以便在返回后继续将其余代码转移到新方法中。


休假回来后,我惊讶地发现我的更改被撤消了。 原来,出现了一个页面,上面可能有几个独立的表,也就是说,先前所做的假设破坏了所有内容。 所以所有的工作都是徒劳的...


好吧,差不多。 实际上,可以重新执行所有相同的操作(合并/拉取请求的好处并没有在任何地方消失,而是保留在历史中),使模板体系结构的方法保持不变,并且仅更改组织thunk-s的方法。 但是,由于这种方法的连贯性和复杂性,它仍然没有激发人们的信心。 尽管这解决了测试中指出的问题,但并不想返回它。 有必要提出一些更简单,更可靠的建议。


尝试解决问题编号3。 寻求的人会发现


全局地看一下如何编写针对thunk的测试,我注意到entityModel方法(实际上是对象字段) entityModel容易并且没有任何问题。


然后这个想法浮出水面:为什么不创建一个类,该类的方法很笨拙,而动作创建者呢? 传递给工厂的参数将传递给此类的构造函数,并且可以通过this进行访问。 您可以立即为动作创建者创建一个单独的类,为thunk创建一个单独的类,然后从另一个继承一个,从而立即进行小的优化。 因此,这些类将作为一个类工作(在创建继承人类的实例时),但同时每个类都将更易于阅读,理解和测试。


是演示此方法的代码。


让我们更详细地考虑出现和更改的每个文件。



 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文件中, FilterActions.jsFilterActionCreators类继承,并将thunk applyFilter定义为applyFilter的方法。 在这种情况下,动作applyFilterSuccessapplyFilterError将通过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 FilterActions主文件中 ,我们创建FilterActions类的实例,并向FilterActions传递必要的配置对象。 导出函数时(在applyFilter函数的最后),不要忘记重写applyFilter方法以将fetchData依赖项传递给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; - return { - ...filterActions, - ...templatesActions, - ...tableActions, - }; }; 

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

Source: https://habr.com/ru/post/zh-CN469371/


All Articles