Testing dispatched actions in Redux thunk with Jest
Asked Answered
F

5

28

I'm quite new to Jest and admittedly am no expert at testing async code...

I have a simple Fetch helper I use:

export function fetchHelper(url, opts) {
    return fetch(url, options)
        .then((response) => {
            if (response.ok) {
                return Promise.resolve(response);
            }

            const error = new Error(response.statusText || response.status);
            error.response = response;

            return Promise.reject(error);
        });
    }

And implement it like so:

export function getSomeData() {
    return (dispatch) => {
        return fetchHelper('http://datasource.com/').then((res) => {
            dispatch(setLoading(true));
            return res.json();
        }).then((data) => {
            dispatch(setData(data));
            dispatch(setLoading(false));
        }).catch(() => {
            dispatch(setFail());
            dispatch(setLoading(false));
        });
    };
}

However I want to test that the correct dispatches are fired in the correct circumstances and in the correct order.

This used to be quite easy with a sinon.spy(), but I can't quite figure out how to replicate this in Jest. Ideally I'd like my test to look something like this:

expect(spy.args[0][0]).toBe({
  type: SET_LOADING_STATE,
  value: true,
});


expect(spy.args[1][0]).toBe({
  type: SET_DATA,
  value: {...},
});

Thanks in advance for any help or advice!

Flashboard answered 9/1, 2018 at 16:40 Comment(1)
Dispatching multiple actions in a row should be avoided with redux. You have dispatch(setData(data)); dispatch(setLoading(false)); which will trigger 2 store changes and 2 renders. If you combine that into a single action, and set the loading state to false for that action, then you'll only have 1 re-render in your app.Gilmour
H
25

Answer as of January 2018

The redux docs have a great article on testing async action creators*:

For async action creators using Redux Thunk or other middleware, it's best to completely mock the Redux store for tests. You can apply the middleware to a mock store using redux-mock-store. You can also use fetch-mock to mock the HTTP requests.

import configureMockStore from 'redux-mock-store'
import thunk from 'redux-thunk'
import * as actions from '../../actions/TodoActions'
import * as types from '../../constants/ActionTypes'
import fetchMock from 'fetch-mock'
import expect from 'expect' // You can use any testing library

const middlewares = [thunk]
const mockStore = configureMockStore(middlewares)

describe('async actions', () => {
  afterEach(() => {
    fetchMock.reset()
    fetchMock.restore()
  })

  it('creates FETCH_TODOS_SUCCESS when fetching todos has been done', () => {
    fetchMock
      .getOnce('/todos', { body: { todos: ['do something'] }, headers: { 'content-type': 'application/json' } })


    const expectedActions = [
      { type: types.FETCH_TODOS_REQUEST },
      { type: types.FETCH_TODOS_SUCCESS, body: { todos: ['do something'] } }
    ]
    const store = mockStore({ todos: [] })

    return store.dispatch(actions.fetchTodos()).then(() => {
      // return of async actions
      expect(store.getActions()).toEqual(expectedActions)
    })
  })
})

Their approach is not to use jest (or sinon) to spy, but to use a mock store and assert the dispatched actions. This has the advantage of being able to handle thunks dispatching thunks, which can be very difficult to do with spies.

This is all straight from the docs, but let me know if you want me to create an example for your thunk.


* (this quote is no longer in the article as of January 2023 and the recommendations have changed dramatically, see comments on this answer for further info)

Hocus answered 12/1, 2018 at 12:31 Comment(5)
Do you have a link to any documentation on the thunks dispatching thunks? I can't seem to see if they get dispatched.Extracellular
Not on hand. A common issue I've seen when testing thunks is that the expect calls are made before an asynchronous call completes (E.g they don't return or wait for a promise), so double check that sort of thing. If that's not it, it might be better to open your own question with more details.Hocus
Can I get some help on this #61949082Heinie
And for those of us who want to do an actual integration test by integrating with actual API calls, and not mocking them in any way?Han
This was a great answer in 2018, but as of 2023 is no longer recommended. redux-mock-store has not had any updates in over 2 years (and no issues raised in a year), and the documentation linked to above has been updated to state: Prefer writing integration tests with everything working together. For a React app using Redux, render a <Provider> with a real store instance wrapping the components being tested. Interactions with the page being tested should use real Redux logic, with API calls mocked out so app code doesn't have to change, and assert that the UI is updated appropriately.Inadvertence
L
12

Answer as of January 2018

For async action creators using Redux Thunk or other middleware, it's best to completely mock the Redux store for tests. You can apply the middleware to a mock store using redux-mock-store. In order to mock the HTTP request, you can make use of nock.

According to redux-mock-store documentation, you will need to call store.getActions() at the end of the request to test asynchronous actions, you can configure your test like

mockStore(getState?: Object,Function) => store: Function Returns an instance of the configured mock store. If you want to reset your store after every test, you should call this function.

store.dispatch(action) => action Dispatches an action through the mock store. The action will be stored in an array inside the instance and executed.

store.getState() => state: Object Returns the state of the mock store

store.getActions() => actions: Array Returns the actions of the mock store

store.clearActions() Clears the stored actions

You can write the test action like

import nock from 'nock';
import configureMockStore from 'redux-mock-store';
import thunk from 'redux-thunk';

//Configuring a mockStore
const middlewares = [thunk];
const mockStore = configureMockStore(middlewares);

//Import your actions here
import {setLoading, setData, setFail} from '/path/to/actions';

test('test getSomeData', () => {
    const store = mockStore({});

    nock('http://datasource.com/', {
       reqheaders // you can optionally pass the headers here
    }).reply(200, yourMockResponseHere);

    const expectedActions = [
        setLoading(true),
        setData(yourMockResponseHere),
        setLoading(false)
    ];

    const dispatchedStore = store.dispatch(
        getSomeData()
    );
    return dispatchedStore.then(() => {
        expect(store.getActions()).toEqual(expectedActions);
    });
});

P.S. Keep in ming that the mock-store does't update itself when the mocked action are fired and if you are depending on the updated data after the previous action to be used in the next action then you need to write your own instance of it like

const getMockStore = (actions) => {
    //action returns the sequence of actions fired and 
    // hence you can return the store values based the action
    if(typeof action[0] === 'undefined') {
         return {
             reducer: {isLoading: true}
         }
    } else {
        // loop over the actions here and implement what you need just like reducer
       
    }
}

and then configure the mockStore like

 const store = mockStore(getMockStore);

Hope it helps. Also check this in redux documentation on testing async action creators

Lowis answered 12/1, 2018 at 13:41 Comment(0)
I
8

Answer relevant as of January 2023

Many helpful answers here from 2018 are now outdated, the answer as of 2023 is to avoid mocking the store and instead use the real store, preferring integration tests (still using jest) over unit tests.

Some highlights from the updated, official Redux testing documentation:

Prefer writing integration tests with everything working together. For a React app using Redux, render a with a real store instance wrapping the components being tested. Interactions with the page being tested should use real Redux logic, with API calls mocked out so app code doesn't have to change, and assert that the UI is updated appropriately.

Do not try to mock selector functions or the React-Redux hooks! Mocking imports from libraries is fragile, and doesn't give you confidence that your actual app code is working.

It goes on to state how to achieve this, with the renderWithProvider function detailed here.

The article it links to for reasoning on this, includes the following quote, explaining the evolution of the thinking of redux testing best practices:

Our docs have always taught the "isolation" approach, and that does especially make sense for reducers and selectors. The "integration" approach was in a minority.

But, RTL and Kent C Dodds have drastically changed the mindset and approach for testing in the React ecosystem. The patterns I see now are about "integration"-style tests - large chunks of code, working together, as they'd be used in a real app.

Inadvertence answered 6/1, 2023 at 17:31 Comment(0)
I
4

If you're mocking the dispatch function with jest.fn(), you can just access dispatch.mock.calls to get all the calls made to your stub.

  const dispatch = jest.fn();
  actions.yourAction()(dispatch);

  expect(dispatch.mock.calls.length).toBe(1);

  expect(dispatch.mock.calls[0]).toBe({
    type: SET_DATA,
    value: {...},
  });
Illnatured answered 9/1, 2018 at 16:49 Comment(2)
Thanks @Canastro, but this doesn't seem to work for me. When I format the dispatch like you have actions.yourAction()(dispatch); it throws an error. I tried doing it this way dispatch(actions.urnSearch(id)); and when I log dispatch.mock.calls[0] it just gives me [ [Function] ]Flashboard
this doesn't work if you dispatch other actions from your action creatorsHarrisharrisburg
H
4

In my answer I am using axios instead of fetch as I don't have much experience on fetch promises, that should not matter to your question. I personally feel very comfortable with axios.
Look at the code sample that I am providing below:

// apiCalls.js
const fetchHelper = (url) => {
  return axios.get(url);
}


import * as apiCalls from './apiCalls'
describe('getSomeData', () => {
  it('should dispatch SET_LOADING_STATE on start of call', async () => {
    spyOn(apiCalls, 'fetchHelper').and.returnValue(Promise.resolve());
    const mockDispatch = jest.fn();

    await getSomeData()(mockDispatch);

    expect(mockDispatch).toHaveBeenCalledWith({
      type: SET_LOADING_STATE,
      value: true,
    });
  });

  it('should dispatch SET_DATA action on successful api call', async () => {
    spyOn(apiCalls, 'fetchHelper').and.returnValue(Promise.resolve());
    const mockDispatch = jest.fn();

    await getSomeData()(mockDispatch);

    expect(mockDispatch).toHaveBeenCalledWith({
      type: SET_DATA,
      value: { ...},
    });
  });

  it('should dispatch SET_FAIL action on failed api call', async () => {
    spyOn(apiCalls, 'fetchHelper').and.returnValue(Promise.reject());
    const mockDispatch = jest.fn();

    await getSomeData()(mockDispatch);

    expect(mockDispatch).toHaveBeenCalledWith({
      type: SET_FAIL,
    });
  });
});

Here I am mocking the fetch helper to return Resolved promise to test success part and reject promise to test failed api call. You can pass arguments to them to validate on response also.
You can implement getSomeData like this:

const getSomeData = () => {
  return (dispatch) => {
    dispatch(setLoading(true));
    return fetchHelper('http://datasource.com/')
      .then(response => {
        dispatch(setData(response.data));
        dispatch(setLoading(false));
      })
      .catch(error => {
        dispatch(setFail());
        dispatch(setLoading(false));
      })
  }
}

I hope this solves your problem. Please comment, if you need any clarification.
P.S You can see by looking at above code why I prefer axios over fetch, saves you from lot of promise resolves!
For further reading on it you can refer: https://medium.com/@thejasonfile/fetch-vs-axios-js-for-making-http-requests-2b261cdd3af5

Hanhana answered 15/1, 2018 at 7:6 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.