Michal Zalecki
Michal Zalecki
software development, testing, JavaScript,
TypeScript, Node.js, React, and other stuff

Testing redux-thunk like you always want it

Redux Thunk is one of the most if not the most popular Redux middleware with over 2 million downloads a month. If you compare this number to the Redux 4 million downloads a month, it is easy to figure out that over half of Redux projects are using Redux Thunk. As the name "thunk" suggests, the main goal of Redux Tunk is to allow for lazy evaluation (dispatching) of actions. While this makes it possible to dispatch actions in an asynchronous manner, it also makes it harder to test.

Despite that many different Redux middleware libraries are available on npm Redux Thunk is still one of more versatile thanks to leveraging a simple idea of thunks.

Mocking a dispatch

Let's imagine a case in which we have a list of purchases. We can manually change their status. It might be impossible to update the state of a purchases list just based on the information we have. One of the examples of such situation would be when the server is adding some specific information connected with a purchases state transition like assigning a parcel tracking code. What we need to do it to fetch a list of purchases again.

function fetchAllPurchases() {
  return dispatch => {
    dispatch({ type: "FETCH_ALL_PURCHASES_STARTED" });
    fetchAllPurchasesRequest()
      .then(response => {
        dispatch({ type: "FETCH_ALL_PURCHASES_SUCCESS", payload: response.data });
      })
      .then(error => {
        dispatch({ type: "FETCH_ALL_PURCHASES_FAILURE", payload: error });
      });
  };
}

function changePurchaseStatus(id, status) {
  return dispatch => {
    dispatch({ type: "CHANGE_PURCHASE_STATE_STARTED" });
    changePurchaseStatusRequest(id, status)
      .then(() => {
        dispatch({ type: "CHANGE_PURCHASE_STATE_SUCCESS", meta: { id, status } });
        dispatch(fetchAllPurchases());
      })
      .then(error => {
        dispatch({ type: "CHANGE_PURCHASE_STATE_FAILURE", payload: error });
      });
  };
}

The question arises, how to test it? Well, we can replace a dispatch and a getState with mock functions. Soon we discover a problem with this approach.

describe("changePurchaseStatus", () => {
  it("handles changing a purchase status and fetches all purchases", async () => {
    const dispatch = jest.fn();
    const getState = jest.fn();
    await changePurchaseStatus("rylauNS2GG", "sent")(dispatch, getState);
    expect(dispatch).toBeCalledWith({type: "CHANGE_PURCHASE_STATE_STARTED"});
    expect(dispatch).toBeCalledWith({type: "CHANGE_PURCHASE_STATE_SUCCESS", meta: { id: "rylauNS2GG", status: "sent" }});
    expect(dispatch).toBeCalledWith({type: "FETCH_ALL_PURCHASES_STARTED"});
  });
});

When we try to assert dispatching of FETCHALLPURCHASES_STARTED action, we can see that the latest dispatch has been called with an anonymous function instead of an action. That is not good. It was working just fine but broke during a test as actual dispatch implementation does more than just piping actions to a reducer.

Mocking a store

The other approach to testing Redux Thunk involves mocking a store. A store provided by redux-mock-store is better suited for testing thunks in isolation.

// test/utils/mockStore.js
import configureMockStore from "redux-mock-store";
import thunk from "redux-thunk";

export const mockStore = configureMockStore([thunk]);
describe("changePurchaseStatus", () => {
  it("handles changing a purchase status and fetches all purchases", async () => {
    const store = mockStore();
    await store.dispatch(changePurchaseStatus("rylauNS2GG", "sent"));
    const actions = store.getActions();
    expect(actions[0]).toEqual({type: "CHANGE_PURCHASE_STATE_STARTED"});
    expect(actions[1]).toEqual({type: "CHANGE_PURCHASE_STATE_SUCCESS", meta: { id: "rylauNS2GG", status: "sent" }});
    expect(actions[2]).toEqual({type: "FETCH_ALL_PURCHASES_STARTED"});
  });
});

Now, we are able to test whether FETCHALLPURCHASES_STARTED is dispatched. Although it works in this case, it is not guaranteed to work when promises do not resolve instantly. Promises should always resolve instantly in your unit tests. That said, you may come across a situation in which it is not worth the hassle. It results in actions array not containing all actions dispatched by a given thunk.

The first thing we can do is to return a promise from the thunk.

function changePurchaseStatus(id, status) {
  return dispatch => {
    dispatch({ type: "CHANGE_PURCHASE_STATE_STARTED" });
    return changePurchaseStatusRequest(id, status)
      .then(() => {
        dispatch({ type: "CHANGE_PURCHASE_STATE_SUCCESS", meta: { id, status } });
        dispatch(fetchAllPurchases());
      })
      .then(error => {
        dispatch({ type: "CHANGE_PURCHASE_STATE_FAILURE", payload: error });
      });
  };
}

For a majority of cases, it is a "good enough" solution.

Less imperative, more declarative

I find referring to actions array indexes inconvenient, especially in cases when an order does not matter. If you, like me, do not feel like changing an implementation just so it is easier to test you might be interested in the more sophisticated approach.

// test/utils/getAction.js
function findAction(store, type) {
  return store.getActions().find(action => action.type === type);
}

export function getAction(store, type) {
  const action = findAction(store, type);
  if (action) return Promise.resolve(action);

  return new Promise(resolve => {
    store.subscribe(() => {
      const action = findAction(store, type);
      if (action) resolve(action);
    });
  });
}
describe("changePurchaseStatus", () => {
  it("handles changing a purchase status and fetches all purchases", async () => {
    const store = mockStore();
    store.dispatch(changePurchaseStatus("rylauNS2GG", "sent"));
    expect(await getAction(store, "CHANGE_PURCHASE_STATE_STARTED")).toEqual({type: "CHANGE_PURCHASE_STATE_STARTED"});
    expect(await getAction(store, "CHANGE_PURCHASE_STATE_SUCCESS")).toEqual({type: "CHANGE_PURCHASE_STATE_SUCCESS", meta: { id: "rylauNS2GG", status: "sent" }});
    expect(await getAction(store, "FETCH_ALL_PURCHASES_STARTED")).toEqual({type: "FETCH_ALL_PURCHASES_STARTED"});
  });
});

We are able to abstract away defining and then accessing actions array. We only care whether an action of a specified type has been dispatched and with what payload.

Wrap up

The ease of testing is important as it determines how much of developer's effort is going to be put into making sure whether everything works. After all, we understand the importance of well-tested code. At the same time, we want a cognitive cost of writing them to be as low as possible.

If you are using thunks to dispatch other thunks, like in my example, you may also find Tal Kol's article an interesting read.

Photo by rawpixel on Unsplash.