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

IT技术 javascript reactjs redux redux-thunk
2021-05-01 14:09:09

我在测试一个动作创建者时遇到了麻烦,它只是循环传递给它的数组并为该数组中的每个项目调度一个动作。这很简单,我似乎无法弄清楚。这是动作创建者:

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个回答

编写和测试向 API 发出基于 Promise 的请求的 Redux Thunk Action Creator 的指南

前言

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

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

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

从 redux 文档:

当您调用异步 API 时,有两个关键时刻:开始调用的时刻和收到应答(或超时)的时刻。

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

代表 API 请求的 promise三种可能的状态:

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

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

好的,让我们编写核心动作创建者,我们将需要表示对给定主题 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 }
}

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

单个获取动作创建者的逻辑

Axios 是一个基于 Promise 的请求库。所以 axios.get 方法向给定的 url 发出请求,并返回一个Promise,如果成功,该Promise将被解析,否则该Promise将被拒绝

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

如果我们的 Axios 请求成功,我们的 promise 将被解析,.then 中的代码将被执行这将为我们给定的主题 ID 发送一个 FETCH_FULFILLED 操作,并带有来自我们请求的响应(我们的主题数据)

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

现在我们需要创建一个单一的动作创建者来启动多个 topicId 的获取过程。

由于这是一个异步过程,我们可以使用一个 thunk 动作创建器,它将使用 Redux-thunk 中间件来允许我们在未来分派额外的异步动作。

Thunk Action 创建器是如何工作的?

我们的 thunk 动作创建者分派与对多个topicId进行提取相关的动作

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

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

这是 Redux thunk 中执行此操作的代码:

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

好的,这就是我们的 thunk 动作创建者返回一个函数的原因。因为这个函数会被中间件调用,让我们可以访问调度和获取状态,这意味着我们可以在以后调度进一步的动作。

编写我们的 thunk 动作创建器

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

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

    return Promise.all(itemPromisesArray) 
  };
};

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

这意味着我们的 thunk 动作创建者返回一个Promise,它等待我们所有代表单个获取的子Promise被完成(请求成功)或第一次拒绝(请求失败)

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

旁白 - 在我们的 thunk 动作创建器中访问 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 不是唯一的解决方案。如果我们想分发 promise 而不是函数,我们可以使用 redux-promise。但是我建议从 redux-thunk 开始,因为这是最简单的解决方案。

测试我们的 thunk 动作创建器

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

  1. 创建一个模拟商店。
  2. 分派 thunk 动作创建者 3. 确保对在数组中传递给 thunk 动作创建者的每个主题 id 的所有异步获取完成后,已经分派了 FETCH_PENDING 动作。

但是,为了创建此测试,我们还需要执行另外两个子步骤:

  1. 我们需要模拟 HTTP 响应,这样我们就不会向实时服务器发出真正的请求
  2. 我们还想创建一个模拟存储,允许我们查看已调度的所有历史操作。

拦截HTTP请求

我们要测试是否通过对 fetchAllItems 操作创建者的一次调用来分派正确数量的某个操作。

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

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

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

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

我们的测试将从以下内容开始:

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开头的 url 发出的任何 HTTP 请求, 并以确定的方式响应状态代码和响应。

创建我们的 Mock Redux 存储

在测试文件中,从 redux-mock-store 库中导入 configure store 函数来创建我们的假存储。

import configureStore from 'redux-mock-store';

这个模拟存储将在一个数组中分派的动作用于你的测试。

由于我们正在测试一个 thunk 动作创建者,我们的模拟存储需要在我们的测试中使用 redux-thunk 中间件进行配置

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

Out mock store 有一个 store.getActions 方法,当调用该方法时,它会为我们提供一个包含所有先前调度操作的数组。

最后,我们调度 thunk 动作创建者,它返回一个Promise,当所有单独的 topicId 获取Promise都被解决时,它就会解决。

然后,我们进行测试断言,以比较发送到模拟存储的实际操作与我们预期的操作。

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

所以在测试结束时,我们将我们的 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);
            });
    });
});

Final Action 创建者和辅助函数

源代码/索引.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
  };
};