Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How do I test a Redux action creator that only dispatches other actions

I'm having trouble testing an action creator that just loops through the array passed to it and dispatches an action for each item in that array. It's simple enough I just can't seem to figure it out. Here's the action creator:

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

And here's how I'm attempting to test it:

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

I'm getting this error: TypeError: Cannot read property 'then' of undefined.

like image 477
Andrew Samuelsen Avatar asked Jan 07 '17 19:01

Andrew Samuelsen


1 Answers

A Guide to Writing and Testing Redux Thunk Action Creators that make a Promise Based Request to an API

Preamble

This example uses Axios which is a promise based library for making HTTP requests. However you can run this example using a different promise based request library such as Fetch. Alternatively just wrap a normal http request in a promise.

Mocha and Chai will be used in this example for testing.

Representing the statefulness of a request with Redux actions

From the redux docs:

When you call an asynchronous API, there are two crucial moments in time: the moment you start the call, and the moment when you receive an answer (or a timeout).

We first need to define actions and their creators that are associated with making an asynchronous call to an external resource for any given topic id.

There are three possible states of a promise which represents an API request:

  • Pending (request made)
  • Fulfilled (request successful)
  • Rejected (request failed - or timeout)

Core Action Creators which represent state of request promise

Okay lets write the core action creators we will need to represent the statefulness of a request for a given topic 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 }
}

Note that your reducers should handle these actions appropriately.

Logic for a single fetch action creator

Axios is a promise based request library. So the axios.get method makes a request to the given url and returns a promise that will be resolved if successful otherwise this promise will be rejected

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

If our Axios request is successful our promise will be resolved and the code in .then will be executed. This will dispatch a FETCH_FULFILLED action for our given topic id with a the response from our request (our topic data)

If the Axios request is unsuccessful our code in .catch will be executed and dispatch a FETCH_REJECTED action which will contain the topic ID and the error which occurred during the request.

Now we need to create a single action creator to that will start the fetching process for multiple topicIds.

Since this is an asynchronous process we can use a thunk action creator that will use Redux-thunk middleware to allow us to dispatch additional async actions in the future.

How does a Thunk Action creator work?

Our thunk action creator dispatches actions associated with making fetches for multiple topicIds.

This single thunk action creator is an action creator that will be handled by our redux thunk middleware since it fits the signature associated with thunk action creators, that is it returns a function.

When store.dispatch is called our actions will go through the middleware chain before they reach the store. Redux Thunk is a piece of middleware that will see our action is a function and then give this function access to the stores dispatch and get state.

Here is the code inside Redux thunk that does this:

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

Okay so that is why our thunk action creator returns a function. because this function will be called by middleware and give us access to dispatch and get state meaning we can dispatch further actions at a later date.

Writing our thunk action creator

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

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

    return Promise.all(itemPromisesArray) 
  };
};

At the end we return a call to promise.all.

This means that our thunk action creator returns one promise which waits for all our sub promises which represent individual fetches to be fulfilled (request success) or for the first rejection (request failure)

See it returns a function that accepts dispatch. This returned function is the function which will be called inside the Redux thunk middleware, therefore inverting control and letting us dispatch more actions after our fetches to external resources are made.

An aside - accessing getState in our thunk action creator

As we saw in the previous function redux-thunk calls the function returned by our action creator with dispatch and getState.

We could define this as an arg inside the function returned by our thunk action creator like so

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

Remember redux-thunk is not the only solution. if we wanted to dispatch promises instead of functions we could use redux-promise. However I would recommend starting with redux-thunk as this is the simplest solution.

Testing our thunk action creator

So the test for our thunk action creator will comprise of the following steps:

  1. create a mock store.
  2. dispatch the thunk action creator 3.Ensure that after all the async fetches complete for every topic id that was passed in an array to the thunk action creator a FETCH_PENDING action has been dispatched.

However we need to do two other sub steps we need to carry out in order to create this test:

  1. We need to mock HTTP responses so we don't make real requests to a live Server
  2. we also want to create a mock store that allows us to see all the historical actions that have been dispatched.

Intercepting the HTTP request

We want to test that the correct number of a certain action are dispatched by a single call to the fetchAllItems action creator.

Okay now in the test we don't want to actually make a request to a given api. Remember our unit tests must be fast and deterministic. For a given set of arguments to our thunk action creator our test must always either fail or pass. If we actually fetched data from a server inside our tests then it may pass once and then fail if the server goes down.

Two possible ways of mocking the response from the server

  1. Mock the Axios.get function so that it returns a promise that we can force to resolve with the data we want or reject with our predefined error.

  2. Use an HTTP mocking library like Nock which will let the Axios library make a request. However this HTTP request will be intercepted and handled by Nock instead of a real server. By using Nock we can specify the response for a given request within our tests.

Our test will start with:

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 intercepts any HTTP request made to a url starting with http://www.example.com and responds in a deterministic manner with the status code and response.

Creating our Mock Redux store

In the test file import the configure store function from the redux-mock-store library to create our fake store.

import configureStore from 'redux-mock-store';

This mock store will the dispatched actions in an array to be used in your tests.

Since we are testing a thunk action creator our mock store needs to be configured with the redux-thunk middleware in our test

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

Out mock store has a store.getActions method which when called gives us an array of all previously dispatched actions.

Finally we dispatch the thunk action creator which returns a promise which resolves when all of the individual topicId fetch promise are resolved.

We then make our test assertions to compare the actual actions that were to dispatched to the mock store versus our expected actions.

Testing the promise returned by our thunk action creator in Mocha

So at the end of the test we dispatch our thunk action creator to the mock store. We must not forget to return this dispatch call so that the assertions will be run in the .then block when the promise returned by the thunk action creator is resolved.

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

See the final test file below:

Final test file

test/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 creators and helper functions

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
  };
};
like image 135
therewillbecode Avatar answered Oct 17 '22 13:10

therewillbecode