Nikita Vlasenko
Nikita Vlasenko

Reputation: 4352

How to test complex async reducers with Jest

I have such kinds of reducers that use fetch API as its base ultimately:

export const fetchRelatedFamilies = () => {
  return (dispatch, getState) => {
    if (isEmpty(getState().relatedFamiliesById)) {
      dispatch({ type: REQUEST_RELATED_FAMILIES_BY_ID })
      new HttpRequestHelper('/api/related_families',
        (responseJson) => {
          dispatch({ type: RECEIVE_RELATED_FAMILIES_BY_ID, updates: responseJson.relatedFamiliesById })
        },  
        e => dispatch({ type: RECEIVE_RELATED_FAMILIES_BY_ID, error: e.message, updates: {} }), 
      ).get()
    }   
  }
}

Code for HttpRequestHelper is here: https://github.com/broadinstitute/seqr/blob/master/ui/shared/utils/httpRequestHelper.js

Here is how I am trying to test it (but its not working):

import configureStore from 'redux-mock-store'
import fetchMock from 'fetch-mock'
import thunk from 'redux-thunk'
import { cloneDeep } from 'lodash'
import { fetchRelatedFamilies, REQUEST_RELATED_FAMILIES_BY_ID, RECEIVE_RELATED_FAMILIES_BY_ID } from 'redux/rootReducer'

import { STATE1 } from '/shared/components/panel/fixtures.js'

describe('fetchRelatedFamilies', () => {
  const middlewares = [thunk]
  const testActionsDispatch = async (currstate, expectedActions) => {
    const store = configureStore(middlewares)(currstate)
    store.dispatch(fetchRelatedFamilies())

    // need to mimick wait for async actions to be dispatched
    //await new Promise((r) => setTimeout(r, 200));
    expect(store.getActions()).toEqual(expectedActions)
  }

  afterEach(() => {
    fetchMock.reset()
    fetchMock.restore()
  })  

  it('Dispatches correct actions when data - relatedFamiliesById - is absent in state', () => {
    const relatedFamiliesById = cloneDeep(STATE1.relatedFamiliesById)
    fetchMock
      .getOnce('/api/related_families', { body: relatedFamiliesById, headers: { 'content-type': 'application/json' } })

    STATE1.relatedFamiliesById = {}
    const expectedActions = [ 
      { type: REQUEST_RELATED_FAMILIES_BY_ID },
      { type: RECEIVE_RELATED_FAMILIES_BY_ID, updates: relatedFamiliesById }
    ]   
    testActionsDispatch(STATE1, expectedActions)
  })  
})

I don't see { type: RECEIVE_RELATED_FAMILIES_BY_ID, updates: relatedFamiliesById } in the resulting store actions, so I tried to use the trick: await new Promise((r) => setTimeout(r, 200)); in hope that it's the issue with async fetch but what it causes is that test will pass no matter what expected actions are as if the code that is following await is completely being ignored. I can't use store.dispatch(fetchRelatedFamilies()).then(... probably because Promise is not returned, and I am getting then access of undefined error. I tried to use waitFor from the library: https://testing-library.com/docs/guide-disappearance/ but I am having really big troubles installing the library itself due to the nature of the project itself and its version, so I need to avoid it still somehow.

So, the only question that I have is how I can make the action dispatched inside the async reducer to appear, in this case - { type: RECEIVE_RELATED_FAMILIES_BY_ID, updates: relatedFamiliesById }.

Upvotes: 0

Views: 194

Answers (1)

mgarcia
mgarcia

Reputation: 6325

The problem with the current code is that although you are awaiting for 200ms in your testActionsDispatch helper method (so that the mocked promise is resolved), you are not awaiting in the test code for that promise of 200ms to resolve.

In order to do that you have to declare your test as async and await for the execution of the testActionsDispatch code:

const testActionsDispatch = async (currstate, expectedActions) => {
    const store = configureStore(middlewares)(currstate)
    store.dispatch(fetchRelatedFamilies())

    // need to mimick wait for async actions to be dispatched
    await new Promise((r) => setTimeout(r, 200));
    expect(store.getActions()).toEqual(expectedActions)
}

// Note that the test is declared as async
it('Dispatches correct actions when data - relatedFamiliesById - is absent in state', async () => {
    const relatedFamiliesById = cloneDeep(STATE1.relatedFamiliesById)
    fetchMock
      .getOnce('/api/related_families', { body: relatedFamiliesById, headers: { 'content-type': 'application/json' } })

    STATE1.relatedFamiliesById = {}
    const expectedActions = [ 
      { type: REQUEST_RELATED_FAMILIES_BY_ID },
      { type: RECEIVE_RELATED_FAMILIES_BY_ID, updates: relatedFamiliesById }
    ]

    // Await the execution of the helper code  
    await testActionsDispatch(STATE1, expectedActions)
})  

Now that should work, but we are adding a delay of 200ms in every test that uses this testActionsDispatch helper. That can end up adding a lot of time when you launch your test and ultimately at a logical level is not really ensuring that the promise resolves.

A better approach is to return the promise in your reducer so we can wait for it to resolve directly in the test (I'm assuming the get method from HttpRequestHelper returns the promise created by fetch and returning it):

export const fetchRelatedFamilies = () => {
  return (dispatch, getState) => {
    if (isEmpty(getState().relatedFamiliesById)) {
      dispatch({ type: REQUEST_RELATED_FAMILIES_BY_ID })
      return new HttpRequestHelper('/api/related_families',
        (responseJson) => {
          dispatch({ type: RECEIVE_RELATED_FAMILIES_BY_ID, updates: responseJson.relatedFamiliesById })
        },  
        e => dispatch({ type: RECEIVE_RELATED_FAMILIES_BY_ID, error: e.message, updates: {} }), 
      ).get()
    }   
  }
}

Then, in your helper you can simply await for this returned promise to resolve:

const testActionsDispatch = async (currstate, expectedActions) => {
    const store = configureStore(middlewares)(currstate)
    // Await for the promise instead of awaiting a random amount of time.
    await store.dispatch(fetchRelatedFamilies())

    expect(store.getActions()).toEqual(expectedActions)
}

Upvotes: 1

Related Questions