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:
- create a mock store.
- 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:
- We need to mock HTTP responses so we don't make real requests to a live Server
- 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
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.
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
};
};