When testing a React app built with Redux it's important to test it using both unit and integration testing. This article will explain which parts of a Redux app should be integration tested, which parts should be unit tested, and why you should be using both testing approaches instead of just one.
Example application
This article uses code from a simple example app I've built specifically for this article. The app allows you to fetch facts about random numbers and save those facts to a list. The app includes an API call to fetch the random facts in order to demonstrate how to mock API calls in tests.
React Component
The app is made up of a single React component named App
:
1const App = ({2 getRandomNumberFact,3 savedFacts,4 currentFact,5 isLoading,6 saveRandomNumberFact7}) => {8 const handleSubmit = e => {9 e.preventDefault();10 saveRandomNumberFact();11 };1213 return (14 <div className="App">15 <header className="App-header">16 <button onClick={getRandomNumberFact}>Get new fact!</button>17 <form onSubmit={handleSubmit}>18 {isLoading && <p>Loading...</p>}19 {currentFact && (20 <>21 <p aria-label="Currently displayed random fact">{currentFact}</p>22 <button type="submit">23 Save that fact{" "}24 <span role="img" aria-label="thumbs-up">25 👍🏼26 </span>27 </button>28 </>29 )}30 </form>31 <h3>Saved random number facts:</h3>32 <ul>33 {savedFacts.map(fact => (34 <li key={fact}>{fact}</li>35 ))}36 </ul>37 </header>38 </div>39 );40};
The App
component is of course connected to Redux:
1const mapStateToProps = state => ({2 savedFacts: state.randomNumberFacts.savedFacts,3 currentFact: state.randomNumberFacts.currentFact,4 isLoading: state.randomNumberFacts.isLoading5});67const mapDispatchToProps = dispatch => ({8 getRandomNumberFact: () => dispatch(getRandomNumberFact()),9 saveRandomNumberFact: () => dispatch(saveRandomNumberFact())10});1112export default connect(13 mapStateToProps,14 mapDispatchToProps15)(App);
Redux action creators
This app uses redux-thunk to simplify the handling of asynchronous operations. If you're not familiar with redux-thunk, it's a middleware that allows you to write action creators that return a functions instead of action objects. This permits the delay of dispatching actions or conditional dispatching of actions based on conditions being met.
Here are the action creators used in the app:
1export function getRandomNumberFactStarted() {2 return { type: actionTypes.GET_RANDOM_NUMBER_FACT_STARTED };3}45export function getRandomNumberFactSuccess(randomNumberFact) {6 return { type: actionTypes.GET_RANDOM_NUMBER_FACT_SUCCESS, randomNumberFact };7}89export function getRandomNumberFactFailure(error) {10 return { type: actionTypes.GET_RANDOM_NUMBER_FACT_FAILURE, error };11}1213// Thunk14export function saveRandomNumberFact() {15 return (dispatch, getState) =>16 dispatch({17 type: actionTypes.SAVE_RANDOM_NUMBER_FACT,18 fact: getState().randomNumberFacts.currentFact19 });20}2122// Thunk23export function getRandomNumberFact() {24 return (dispatch) => {25 dispatch(getRandomNumberFactStarted());26 return axios27 .get(`http://numbersapi.com/random/math`)28 .then(res => {29 dispatch(getRandomNumberFactSuccess(res.data));30 })31 .catch(e => {32 console.error(e.message);33 dispatch(getRandomNumberFactFailure("Failed to load random error"));34 });35 };36}
Notice how the last two action creators are thunks because they both return functions. The getRandomNumberFact
action creator is where the API call is made.
Redux reducers
1function randomNumberFacts(2 state = {3 currentFact: "",4 savedFacts: [],5 isLoading: false,6 error: ""7 },8 action9) {10 switch (action.type) {11 case actionTypes.GET_RANDOM_NUMBER_FACT_STARTED:12 return {13 ...state,14 isLoading: true,15 error: ""16 };17 case actionTypes.GET_RANDOM_NUMBER_FACT_SUCCESS:18 return {19 ...state,20 currentFact: action.randomNumberFact,21 savedFacts: [...state.savedFacts],22 isLoading: false23 };24 case actionTypes.GET_RANDOM_NUMBER_FACT_FAILURE:25 return {26 ...state,27 savedFacts: [...state.savedFacts],28 isLoading: false,29 error: action.error30 };31 case actionTypes.SAVE_RANDOM_NUMBER_FACT:32 return {33 ...state,34 currentFact: "",35 savedFacts: [...state.savedFacts, action.fact],36 isLoading: false37 };38 default:39 return state;40 }41}4243const reducer = combineReducers({44 randomNumberFacts45});4647export default reducer;
The application's Redux store has the following shape:
1{2 randomNumberFacts: {3 currentFact: string,4 savedFacts: Array<string>,5 isLoading: boolean,6 error: string,7 }8}
Integration testing
The idea behind integration testing a Redux app is to make sure that you're testing all the different parts of Redux connected together. This more closely mimics how the application is being used.
We will be using React Testing Library to test our App
component which is connected to Redux. React Testing Library deeply renders React components, which resembles how the component is actually rendered in an app. There are also many other advantages to using React Testing Library for testing React components, which I've covered in this article.
In our tests, we will be rendering the App
component wrapped in a Redux Provider component where we can inject our own initial Redux store. Here's our custom render function we will be using to facilitate the rendering of the App
component with an initial store:
1import { render as rtlRender } from "@testing-library/react";2import { createStore, applyMiddleware } from "redux";3import rootReducer from "./store/reducers";4import thunk from "redux-thunk";56const render = (ui: any, initialStore = {}, options = {}) => {7 const store = createStore(rootReducer, initialStore, applyMiddleware(thunk));8 const Providers = ({ children }: any) => (9 <Provider store={store}>{children}</Provider>10 );1112 return rtlRender(ui, { wrapper: Providers, ...options });13};
For the example application I want to have four test cases:
- should display a random fact when clicking the generate button
- should replace the current random fact with a new random fact
- should save a random fact when clicking the save button
- should be able to save multiple random facts
The above test cases will be tested by simulating DOM events (e.g. click events), mocking API return values, and making assertions on what gets displayed on the screen. It's important for the assertions to test actual DOM markup as that is what the end user will be seeing.
In these integration tests on connected Redux components, you should not be making assertions that check if particular actions have been dispatched or whether the Redux store updates with the correct values. What we are doing is firing DOM events which will trigger the Redux operations that need to happen, and then assert that the DOM has changed appropriately. This way of testing makes sure to test the complete flow of Redux operations, while avoiding to test implementation details.
It should be pointed out that we are mocking the axios
module in our tests in order to mock API responses. Therefore, you'll see the following at the top of our test file:
1import axios from 'axios';2jest.mock('axios');
Now, let's visit each test case:
1it("should display a random fact when clicking the generate button", async () => {2 const randomFactText = "Random fact";3 axios.get.mockResolvedValue({ data: randomFactText });4 const { getByText, queryByText } = render(<App/>);56 expect(queryByText(/Save that fact/)).not.toBeInTheDocument();78 fireEvent.click(getByText(/Get new fact!/));910 expect(queryByText(/Loading.../)).toBeInTheDocument();1112 await wait(() => {13 expect(queryByText(randomFactText)).toBeInTheDocument();14 expect(queryByText(/Save that fact/)).toBeInTheDocument();15 });16});
In this first test, we are firing a click event on the button that says "Get new fact", check that we are displaying our loading state, and then assert that the random fact shows up in the DOM. We need to use the [wait](https://testing-library.com/docs/dom-testing-library/api-async#wait)
function in order to wait for the mocked API promise to resolve.
1it("should replace the current random fact with a new random fact", async () => {2 const firstRandomFactText = "First random fact";3 const secondRandomFactText = "Second random fact";45 const { getByText, queryByText } = render(<App/>);67 axios.get.mockResolvedValue({ data: firstRandomFactText });8 fireEvent.click(getByText(/Get new fact!/));910 await wait(() => {11 expect(queryByText(firstRandomFactText)).toBeInTheDocument();12 });1314 axios.get.mockResolvedValue({ data: secondRandomFactText });15 fireEvent.click(getByText(/Get new fact!/));1617 await wait(() => {18 expect(queryByText(secondRandomFactText)).toBeInTheDocument();19 expect(queryByText(firstRandomFactText)).not.toBeInTheDocument();20 });21});
In this second test, we are again firing a click event on the "Get new fact" button, but this time we are doing it twice in order to make sure that we replace the first random fact text with the text of the second random fact. Again, we've mocked API calls in this test.
1it("should save a random fact when clicking the save button", () => {2 const randomFactText = "Random fact";3 const { queryByLabelText, getByText, getByRole, queryByRole } = render(<App/>, {4 randomNumberFacts: aRandomNumberFacts({ currentFact: randomFactText })5 });67 expect(8 queryByLabelText(/Currently displayed random fact/)9 ).toBeInTheDocument();10 expect(queryByRole("listitem")).not.toBeInTheDocument();1112 fireEvent.click(getByText(/Save that fact/));1314 expect(15 queryByLabelText(/Currently displayed random fact/)16 ).not.toBeInTheDocument();17 expect(getByRole("listitem")).toHaveTextContent(randomFactText);18});
In this test, we render the component with an initial store that already contains a currentFact
. This prevents us from having to re-write the operations that would populate the store with a value for currentFact.
After rendering the component with an initialized store, we then fire a click event on the save button and then expect the fact to be part of the saved facts list.
1it("should be able to save multiple random facts", async () => {2 const firstRandomFactText = "First random fact";3 const secondRandomFactText = "Second random fact";4 const { queryByLabelText, getByText, getAllByRole, queryByRole } = render(<App/>, {5 randomNumberFacts: aRandomNumberFacts({ currentFact: firstRandomFactText })6 });78 expect(9 queryByLabelText(/Currently displayed random fact/)10 ).toBeInTheDocument();11 expect(queryByRole("listitem")).not.toBeInTheDocument();1213 fireEvent.click(getByText(/Save that fact/));1415 axios.get.mockResolvedValue({ data: secondRandomFactText });16 fireEvent.click(getByText(/Get new fact!/));1718 await wait(() => {19 expect(getByText(/Save that fact/)).toBeInTheDocument();20 });2122 fireEvent.click(getByText(/Save that fact/));2324 expect(getAllByRole("listitem").length).toBe(2);25 getAllByRole("listitem").forEach((listItem, index) => {26 if (index === 0) {27 expect(listItem).toHaveTextContent(firstRandomFactText);28 }29 if (index === 1) {30 expect(listItem).toHaveTextContent(secondRandomFactText);31 }32 });33});
This last test again initializes a Redux store when rendering the component, saves the current fact (the one initialized in the store), gets another new fact by clicking the "Get new fact" button, and then checks that we have 2 saved facts that appear in the list in the DOM.
Unit testing
When it comes to unit testing a Redux application, you'll want to unit test every part of the Redux logic in isolation. In our case, we will be testing our action creators (including thunks), and reducers.
In this article we will be covering how to unit test action creators (including thunks), and reducers, but your Redux app might use other Redux-related libraries such as reselect, redux-saga, or redux-observable (to name a few). You should find ways to unit test any other Redux-related libraries you've included in your application.
Testing action creators
Let's first take a look at the tests for our simple action creators (the ones that immediately return an action object):
1it("should create an action when a random fact fetch has started", () => {2 const expectedAction = {3 type: actionTypes.GET_RANDOM_NUMBER_FACT_STARTED4 };5 expect(actions.getRandomNumberFactStarted()).toEqual(expectedAction);6});78it("should create an action for a successful fetch of a random number fact", () => {9 const text = "random fact";10 const expectedAction = {11 type: actionTypes.GET_RANDOM_NUMBER_FACT_SUCCESS,12 randomNumberFact: text13 };14 expect(actions.getRandomNumberFactSuccess(text)).toEqual(expectedAction);15});1617it("should create an action for a failed fetch of a random number fact", () => {18 const text = "failed to fetch random fact";19 const expectedAction = {20 type: actionTypes.GET_RANDOM_NUMBER_FACT_FAILURE,21 error: text22 };23 expect(actions.getRandomNumberFactFailure(text)).toEqual(expectedAction);24});
These are fairly straightforward tests. We are calling the action creators and the asserting that they return the action we expect.
Next, let's investigate how to test our thunks (action creators that return functions). In order to test thunks, we will be using redux-mock-store
in order to have a Redux store from which we can set an initial store value, dispatch actions, get a list of dispatched actions, and subscribe to store changes.
1it("should create an action for a saved random fact", () => {2 const text = "a random fact";34 const store = mockStore({ randomNumberFacts: { currentFact: text } });56 const expectedAction = {7 type: actionTypes.SAVE_RANDOM_NUMBER_FACT,8 fact: text9 };1011 store.dispatch(actions.saveRandomNumberFact() as any);1213 expect(store.getActions()).toEqual([expectedAction]);14});1516it("should create an action to start the fetch of a random fact and another action to mark the success of the fetch", done => {17 const text = "a random fact";1819 const store = mockStore({});20 axios.get.mockResolvedValue({ data: text });2122 const expectedActions = [23 { type: actionTypes.GET_RANDOM_NUMBER_FACT_STARTED },24 { type: actionTypes.GET_RANDOM_NUMBER_FACT_SUCCESS, randomNumberFact: text }25 ];2627 store.dispatch(actions.getRandomNumberFact() as any);2829 store.subscribe(() => {30 expect(store.getActions()).toEqual(expectedActions);31 done();32 });33});3435it("should create an action to start the fetch of a random fact and another action to mark the failure of the fetch", done => {36 const store = mockStore({});37 axios.get.mockRejectedValue(new Error());3839 const expectedActions = [40 { type: actionTypes.GET_RANDOM_NUMBER_FACT_STARTED },41 {42 type: actionTypes.GET_RANDOM_NUMBER_FACT_FAILURE,43 error: "Failed to load random error"44 }45 ];4647 store.dispatch(actions.getRandomNumberFact() as any);4849 store.subscribe(() => {50 expect(store.getActions()).toEqual(expectedActions);51 done();52 });53});
The first test mocks a store with a value for a random fact, dispatches the saveRandomNumberFact
action creator, and then asserts that the expected action object was dispatched.
The second and third test are testing that the appropriate actions are dispatched for the getRandomNumberFact
action creator for the scenarios where the API resolves and rejects a value, respectively. You'll notice in both tests that we are mocking API responses, dispatching the getRandomNumberFact
action creator, and then subscribing to the store in order to assert that the expected action has been dispatched.
Testing reducers
Finally, we have the tests for our Redux reducers. Basically, we have a test condition that checks that the store is initialized as expected and then tests that check if each of the dispatched actions update the store as expected.
I won't show all the tests, but rather just the test for the store initialization and the tests for the handling of the GET_RANDOM_NUMBER_FACT_STARTED
and GET_RANDOM_NUMBER_FACT_SUCCESS
actions:
1import reducer from "./reducers";2import * as actionTypes from "./actionTypes";34it("should return the initial state", () => {5 expect(reducer(undefined, {})).toEqual({6 randomNumberFacts: {7 currentFact: "",8 savedFacts: [],9 isLoading: false,10 error: ""11 }12 });13});1415it("should handle GET_RANDOM_NUMBER_FACT_STARTED", () => {16 expect(17 reducer(undefined, {18 type: actionTypes.GET_RANDOM_NUMBER_FACT_STARTED19 })20 ).toEqual({21 randomNumberFacts: {22 currentFact: "",23 savedFacts: [],24 isLoading: true,25 error: ""26 }27 });28});2930it("should handle GET_RANDOM_NUMBER_FACT_SUCCESS", () => {31 expect(32 reducer(undefined, {33 type: actionTypes.GET_RANDOM_NUMBER_FACT_SUCCESS,34 randomNumberFact: "a random fact"35 })36 ).toEqual({37 randomNumberFacts: {38 currentFact: "a random fact",39 savedFacts: [],40 isLoading: false,41 error: ""42 }43 });4445 expect(46 reducer(47 {48 randomNumberFacts: {49 currentFact: "a random fact",50 savedFacts: [],51 isLoading: false,52 error: ""53 }54 },55 {56 type: actionTypes.GET_RANDOM_NUMBER_FACT_SUCCESS,57 randomNumberFact: "a new random fact"58 }59 )60 ).toEqual({61 randomNumberFacts: {62 currentFact: "a new random fact",63 savedFacts: [],64 isLoading: false,65 error: ""66 }67 });68});
Each of these tests are fairly straightforward since they are simply calls of the imported reducer
function and then assertions on the returned value (which is the expected final state of the store after the reducer
function has been called.
Why use both unit and integration testing?
Although integration testing will give you the most confidence in the reliability of your app, you should not solely rely on integration testing. The reason is that unit testing allows you to more concisely test all possible edge cases compared to integration testing.
If we had to rely on integration testing for all the possible edges cases found along the way in our Redux operation (i.e. test for all the possible return values from an API call and test for all the different combinations of initial Redux stores), our test files would blow up in size and it would be cumbersome to maintain such a shear volume of integration tests. This is especially true for larger applications that have a lot of things going on in the Redux flow. In fact, I'd argue that if the Redux portion of your app isn't that big, then you probably should be using simpler alternatives to Redux anyways.
Additional considerations
You'll want to move a lot of your shared test logic into a common place, like a test-utils
file. This file would contain things such as the custom render method that you use for rendering your React components in your tests.
Another thing to consider is to create helper functions that will build out mock API responses and mock Redux store states. You'll find yourself often needing to build out mocked objects and they can quickly because verbose to write if not using any sort of helper function.