如何测试仅调度其他操作的Redux操作创建者

时间:2017-01-07 19:52:13

标签: javascript reactjs redux redux-thunk

我无法测试一个动作创建器,它只是循环传递给它的数组,并为该数组中的每个项目调度一个动作。这很简单,我似乎无法弄明白。这是动作创建者:

export const fetchAllItems = (topicIds)=>{
  return (dispatch)=>{
    topicIds.forEach((topicId)=>{
      dispatch(fetchItems(topicId));
    });
  };
};

以下是我试图测试的方法:

describe('fetchAllItems', ()=>{
  it('should dispatch fetchItems actions for each topic id passed to it', ()=>{
    const store = mockStore({});
    return store.dispatch(fetchAllItems(['1']))
      .then(()=>{
        const actions = store.getActions();
        console.log(actions);
        //expect... I can figure this out once `actions` returns...
      });
  });
});

我收到此错误:TypeError: Cannot read property 'then' of undefined

1 个答案:

答案 0 :(得分:9)

编写和测试对API做出基于承诺的请求的Redux Thunk Action Creator指南

序言

此示例使用Axios这是一个基于承诺的库,用于发出HTTP请求。但是,您可以使用不同的基于承诺的请求库(例如Fetch)来运行此示例。或者只是在promise中包含一个普通的http请求。

将在此示例中使用Mocha和Chai进行测试。

使用Redux操作表示请求的有状态

来自redux docs:

  

当您调用异步API时,有两个关键时刻   时间:你开始通话的那一刻,以及你收到的那一刻   答案(或超时)。

我们首先需要定义与针对任何给定主题ID对外部资源进行异步调用相关联的操作及其创建者。

承诺的三个可能状态代表API请求:

  • 待定 (提出请求)
  • 已完成 (请求成功)
  • 拒绝请求失败 - 或超时)

代表请求承诺状态的核心动作创建者

好的,我们可以编写核心动作创建者来表示给定主题ID的请求的有状态。

const fetchPending = (topicId) => {
  return { type: 'FETCH_PENDING', topicId }
}

const fetchFulfilled = (topicId, response) => { 
  return { type: 'FETCH_FULFILLED', topicId, response }
}

const fetchRejected = (topicId, err) => {
  return { type: 'FETCH_REJECTED', topicId, err }
}

请注意,您的Reducer应该适当地处理这些操作。

单个抓取操作创建者的逻辑

Axios是基于承诺的请求库。所以axios.get方法向给定的url发出请求并返回一个将在成功时解析的promise,否则这个promise将被拒绝

 const makeAPromiseAndHandleResponse = (topicId, url, dispatch) => {
 return axios.get(url)
              .then(response => {
                dispatch(fetchFulfilled(topicId, response))
              })
              .catch(err => {
                dispatch(fetchRejected(topicId, err))
              }) 
}

如果我们的Axios请求成功,我们的承诺将得到解决, .then中的代码将被执行。这将根据我们的请求(我们的主题数据)为我们的给定主题id调度FETCH_FULFILLED操作

如果Axios请求不成功 .catch中的代码将被执行并调度FETCH_REJECTED操作,该操作将包含主题ID和请求期间发生的错误。

现在我们需要创建一个单一的动作创建器,以便为多个topicId启动抓取过程。

由于这是一个异步过程,我们可以使用 thunk action creator ,它将使用Redux-thunk中间件来允许我们在将来调度其他异步操作。

Thunk Action创建者如何运作?

我们的thunk action creator会调度与多个 topicIds的提取相关联的操作。

这个单一的thunk动作创建者是一个动作创建者,它将由我们的redux thunk中间件处理,因为它适合与thunk动作创建者相关联的签名,即它返回一个函数。

当调用store.dispatch时,我们的操作将在到达商店之前通过中间件链。 Redux Thunk是一个中间件,它会看到我们的操作是一个函数,然后让这个函数访问商店调度和获取状态。

这是Redux thunk里面的代码:

if (typeof action === 'function') {
  return action(dispatch, getState, extraArgument);
}

好的,这就是我们的thunk action creator返回一个函数的原因。因为这个函数将被中间件调用,并允许我们访问调度和获取状态,这意味着我们可以在以后调度进一步的操作。

编写我们的thunk action creator

export const fetchAllItems = (topicIds, baseUrl) => {
    return dispatch => {

    const itemPromisesArray = topicIds.map(id => fetchItem(dispatch, id, baseUrl))  

    return Promise.all(itemPromisesArray) 
  };
};

最后我们返回对promise.all的调用。

这意味着我们的thunk action creator会返回一个承诺,它会等待所有我们的子承诺,这些承诺代表要完成的每个提取(请求成功)或第一次拒绝(请求失败)

看到它返回一个接受调度的函数。这个返回的函数是将在Redux thunk中间件中调用的函数,因此反转控制并让我们在获取外部资源后调度更多动作。

暂且 - 在我们的thunk action creator中访问getState

正如我们在前面的函数中所看到的,redux-thunk使用dispatch和getState调用我们的动作创建者返回的函数。

我们可以将此定义为我们的thunk动作创建者返回的函数中的arg,如此

export const fetchAllItems = (topicIds, baseUrl) => {
   return (dispatch, getState) => {

    /* Do something with getState */
    const itemPromisesArray = topicIds.map(id => fetchItem(dispatch, id, baseUrl))

    return Promise.all(itemPromisesArray)
  };
};

记住redux-thunk不是唯一的解决方案。如果我们想要派遣承诺而不是函数,我们可以使用redux-promise。但是我建议从redux-thunk开始,因为这是最简单的解决方案。

测试我们的thunk action creator

因此,对我们的thunk动作创建者的测试将包括以下步骤:

  1. 创建一个模拟商店。
  2. 派遣thunk行动创造者 3.确保之后对于在数组中传递给thunk动作创建者的每个主题id完成所有异步提取已完成FETCH_PENDING操作已被调度。
  3. 但是,为了创建此测试,我们需要执行其他两个子步骤:

    1. 我们需要模拟HTTP响应,以便我们不会向实时服务器发出实际请求
    2. 我们还想创建一个模拟商店,让我们可以看到已发送的所有历史操作。
    3. 拦截HTTP请求

      我们想测试一次调用fetchAllItems动作创建者调度某个动作的正确数量。

      好的,现在在测试中,我们不想实际向给定的api发出请求。请记住,我们的单元测试必须快速且确定。对于我们的thunk动作创建者的给定参数集,我们的测试必须始终失败或通过。如果我们实际从我们测试中的服务器获取数据,那么它可能会传递一次然后在服务器出现故障时失败。

      模拟服务器响应的两种可能方法

      1. 模拟Axios.get函数,使其返回一个promise,我们可以强制解析我们想要的数据或拒绝我们的预定义错误。

      2. 使用像Nock这样的HTTP模拟库,它会让Axios库发出请求。但是,这个HTTP请求将由Nock而不是真实服务器拦截和处理。通过使用Nock,我们可以在测试中指定给定请求的响应。

      3. 我们的测试将从以下开始:

        describe('fetchAllItems', () => {
          it('should dispatch fetchItems actions for each topic id passed to it', () => {
            const mockedUrl = "http://www.example.com";
            nock(mockedUrl)
                // ensure all urls starting with mocked url are intercepted
                .filteringPath(function(path) { 
                    return '/';
                  })
               .get("/")
               .reply(200, 'success!');
        
        });
        

        Nock拦截对以 http://www.example.com 开头的网址发出的任何HTTP请求 并以确定的方式回应状态代码和响应。

        创建我们的Mock Redux商店

        在测试文件中,从redux-mock-store库导入配置存储功能,以创建我们的虚假商店。

        import configureStore from 'redux-mock-store';
        

        此模拟存储将在数组中调度的操作将在您的测试中使用。

        由于我们正在测试一个thunk动作创建器,我们的模拟商店需要在我们的测试中配置redux-thunk中间件

        const middlewares = [ReduxThunk];
        const mockStore = configureStore(middlewares);
        

        Out mock store有一个store.getActions方法,在调用时会给我们一个包含所有以前调度过的动作的数组。

        最后,我们调度thunk action creator,它返回一个promise,当所有单独的topicId fetch promise被解析时,它将被解析。

        然后,我们进行测试断言,以比较调度到模拟商店的实际操作与我们预期的操作。

        测试我们的thunk动作创建者在Mocha中返回的承诺

        因此,在测试结束时,我们将thunk动作创建者发送到模拟商店。我们一定不要忘记返回这个调度调用,这样当thunk动作创建者返回的promise被解析时,断言将在.then块中运行。

          return store.dispatch(fetchAllItems(fakeTopicIds, mockedUrl))
                      .then(() => {
                         const actionsLog = store.getActions();
                         expect(getPendingActionCount(actionsLog))
                                .to.equal(fakeTopicIds.length);
                      });
        

        请参阅下面的最终测试文件:

        最终测试文件

        测试/ index.js

        import configureStore from 'redux-mock-store';
        import nock from 'nock';
        import axios from 'axios';
        import ReduxThunk from 'redux-thunk'
        import { expect } from 'chai';
        
        // replace this import
        import { fetchAllItems } from '../src/index.js';
        
        
        describe('fetchAllItems', () => {
            it('should dispatch fetchItems actions for each topic id passed to it', () => {
                const mockedUrl = "http://www.example.com";
                nock(mockedUrl)
                    .filteringPath(function(path) {
                        return '/';
                    })
                    .get("/")
                    .reply(200, 'success!');
        
                const middlewares = [ReduxThunk];
                const mockStore = configureStore(middlewares);
                const store = mockStore({});
                const fakeTopicIds = ['1', '2', '3'];
                const getPendingActionCount = (actions) => actions.filter(e => e.type === 'FETCH_PENDING').length
        
                return store.dispatch(fetchAllItems(fakeTopicIds, mockedUrl))
                    .then(() => {
                        const actionsLog = store.getActions();
                        expect(getPendingActionCount(actionsLog)).to.equal(fakeTopicIds.length);
                    });
            });
        });
        

        最终动作创建者和帮助函数

        的src / index.js

        // action creators
        const fetchPending = (topicId) => {
          return { type: 'FETCH_PENDING', topicId }
        }
        
        const fetchFulfilled = (topicId, response) => { 
          return { type: 'FETCH_FULFILLED', topicId, response }
        }
        
        const fetchRejected = (topicId, err) => {
          return { type: 'FETCH_REJECTED', topicId, err }
        }
        
        const makeAPromiseAndHandleResponse = (topicId, url, dispatch) => {
         return axios.get(url)
                      .then(response => {
                        dispatch(fetchFulfilled(topicId, response))
                      })
                      .catch(err => {
                        dispatch(fetchRejected(topicId, err))
                      }) 
        }
        
        // fundamentally must return a promise
        const fetchItem = (dispatch, topicId, baseUrl) => {
          const url = baseUrl + '/' + topicId // change this to map your topicId to url 
          dispatch(fetchPending(topicId))
          return makeAPromiseAndHandleResponse(topicId, url, dispatch);
        }
        
        export const fetchAllItems = (topicIds, baseUrl) => {
           return dispatch => {
            const itemPromisesArray = topicIds.map(id => fetchItem(dispatch, id, baseUrl))
            return Promise.all(itemPromisesArray) // return a promise that waits for all fulfillments or first rejection
          };
        };