Search code examples
reactjsreduxreact-reduxjestjsredux-thunk

How to test a redux-thunk action that contains multiple API requests and array transformations?


I have a redux-thunk action that contains multiple API-requests that take data fetched from one endpoint to fetch other relevant data from a different endpoint and I also have a couple of array transformations to merge some of the data together.

Although I'm not sure if this is the best practice, for now, it does what I need. However, it has been difficult to test as I'm not sure what the correct approach is to test it. I have scoured the internet and looked at many different variations of "thunk" tests but mine is failing with every approach so far.

I will really appreciate some guidance on how to test a thunk action such as mine or perhaps better practices in implementing what I have if it makes testing easier.

My thunk-Action...

export const fetchTopStreamsStartAsync = () => {
  return async dispatch => {
    try {
      const headers = {
        'Client-ID': process.env.CLIENT_ID
      };
      const url = 'https://api.twitch.tv/helix/streams?first=5';
      const userUrl = 'https://api.twitch.tv/helix/users?';
      let userIds = '';
      dispatch(fetchTopStreamsStart());

      const response = await axios.get(url, { headers });
      const topStreams = response.data.data;

      topStreams.forEach(stream => (userIds += `id=${stream.user_id}&`));
      userIds = userIds.slice(0, -1);

      const userResponse = await axios.get(userUrl + userIds, { headers });
      const users = userResponse.data.data;

      const completeStreams = topStreams.map(stream => {
        stream.avatar = users.find(
          user => user.id === stream.user_id
        ).profile_image_url;
        return stream;
      });

      const mappedStreams = completeStreams.map(
        ({ thumbnail_url, ...rest }) => ({
          ...rest,
          thumbnail: thumbnail_url.replace(/{width}x{height}/gi, '1280x720')
        })
      );

      dispatch(fetchTopStreamsSuccess(mappedStreams));
    } catch (error) {
      dispatch(fetchTopStreamsFail(error.message));
    }
  };
};

One of the many test approaches that have failed...

import configureMockStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import axios from 'axios';
import moxios from 'moxios';

import {
  fetchTopStreamsStart,
  fetchTopStreamsSuccess,
  fetchTopStreamsStartAsync
} from './streams.actions';

const mockStore = configureMockStore([thunk]);

describe('thunks', () => {
  describe('fetchTopStreamsStartAsync', () => {
    beforeEach(() => {
      moxios.install();
    });

    afterEach(() => {
      moxios.uninstall();
    });
    it('creates both fetchTopStreamsStart and fetchTopStreamsSuccess when api call succeeds', () => {
      const responsePayload = [{ id: 1 }, { id: 2 }, { id: 3 }];

      moxios.wait(() => {
        const request = moxios.requests.mostRecent();
        request.respondWith({
          status: 200,
          response: responsePayload
        });
      });

      const store = mockStore();

      const expectedActions = [
        fetchTopStreamsStart(),
        fetchTopStreamsSuccess(responsePayload)
      ];

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

This is the error i'm getting in the failed test for the received value...

+     "payload": "Cannot read property 'forEach' of undefined",
    +     "type": "FETCH_TOP_STREAMS_FAIL",

UPDATE: As @mgarcia suggested i changed the format of my responsePayload from [{ id: 1 }, { id: 2 }, { id: 3 }] to { data: [{ id: 1 }, { id: 2 }, { id: 3 }] } and now I'm not getting the initial error but now I'm receiving the following error:

: Timeout - Async callback was not invoked within the 5000ms timeout specified by jest.setTimeout.Timeout - Async callback was not invoked within the 5000ms timeout specified by jest.setTimeout.Error:

What I still don't understand is does the test have to replicate the exact structure of the multiple API calls or that just mocking one response is enough? I'm still trying to figure out the cause of the Async callback... error.


Solution

  • You are mocking the axios request through moxios, but it seems that you are not returning the data in the expected format.

    In your action creator you read the response data as:

    const topStreams = response.data.data;
    const users = userResponse.data.data;
    

    But you are mocking the response so that it returns:

    const responsePayload = [{ id: 1 }, { id: 2 }, { id: 3 }];
    

    Instead, it seems that you should be returning:

    const responsePayload = { data: [{ id: 1 }, { id: 2 }, { id: 3 }] };
    

    Aside from the mock response, your code presents some further problems. First, as you have noticed yourself, you are only mocking the first request. You should mock the second request as well returning the desired data. Second, in your assertion you are expecting to have the actions created in:

    const expectedActions = [
        fetchTopStreamsStart(),
        fetchTopStreamsSuccess(responsePayload)
    ];
    

    This will not be true, as you are processing the responsePayload in the action creator, so that the payload with which you are calling fetchTopStreamsSuccess in the action creator will be different from responsePayload.

    Taking all this into account, your test code could look like:

    it('creates both fetchTopStreamsStart and fetchTopStreamsSuccess when api call succeeds', () => {
        const streamsResponse = [
            { user_id: 1, thumbnail_url: 'thumbnail-1-{width}x{height}' },
            { user_id: 2, thumbnail_url: 'thumbnail-2-{width}x{height}' },
            { user_id: 3, thumbnail_url: 'thumbnail-3-{width}x{height}' }
        ];
        const usersResponse = [
            { id: 1, profile_image_url: 'image-1' },
            { id: 2, profile_image_url: 'image-2' },
            { id: 3, profile_image_url: 'image-3' }
        ];
        const store = mockStore();
    
        // Mock the first request by URL.
        moxios.stubRequest('https://api.twitch.tv/helix/streams?first=5', {
            status: 200,
            response: { data: streamsResponse }
        });
    
        // Mock the second request.
        moxios.stubRequest('https://api.twitch.tv/helix/users?id=1&id=2&id=3', {
            status: 200,
            response: { data: usersResponse }
        });
    
        return store.dispatch(fetchTopStreamsStartAsync()).then(() => {
            expect(store.getActions()).toEqual([
                fetchTopStreamsStart(),
                {
                    "type": "TOP_STREAMS_SUCCESS",
                    "payload": [
                        { "avatar": "image-1", "thumbnail": "thumbnail-1-1280x720", "user_id": 1 },
                        { "avatar": "image-2", "thumbnail": "thumbnail-2-1280x720", "user_id": 2 },
                        { "avatar": "image-3", "thumbnail": "thumbnail-3-1280x720", "user_id": 3 },
                    ]
                }
            ]);
        });
    });
    

    Note that I have made up the structure of the fetchTopStreamsSuccess action to have a type attribute equal to TOP_STREAMS_SUCCESS and to have an attribute payload with the completeStreams data. You will probably have to accommodate that to the real structure of the fetchTopStreamsSuccess action you are creating for the test to pass.