Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Testing async actions with redux thunk

I am trying to test my action which has an async call. I use Thunk as my middleware. In the action below, I only dispatch and update the store if the server returns an OK response.

export const SET_SUBSCRIBED = 'SET_SUBSCRIBED'

export const setSubscribed = (subscribed) => {
  return function(dispatch) {
    var url = 'https://api.github.com/users/1/repos';

    return fetch(url, {method: 'GET'})
      .then(function(result) {
        if (result.status === 200) {
          dispatch({
            type: SET_SUBSCRIBED,
            subscribed: subscribed
          })
          return 'result'
        }
        return 'failed' //todo
      }, function(error) {
        return 'error'
      })
  }
}

I am having trouble writing tests to either tests that dispatch either gets called or doesn't (depending on server response) or I could just let the action get called and check that the value in the store is updated correctly.

I am using fetch-mock to mock the web's fetch() implementation. However, it looks like the block of my code in then does not execute. I have also tried using the example here with no luck - http://redux.js.org/docs/recipes/WritingTests.html

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

//passing test
it('returns SET_SUBSCRIBED type and subscribed true', () => {
  fetchMock.get('https://api.github.com/users/1/repos', { status: 200 })

  const subscribed = { type: 'SET_SUBSCRIBED', subscribed: true }
  const store = mockStore({})

  store.dispatch(subscribed)

  const actions = store.getActions()

  expect(actions).toEqual([subscribed])
  fetchMock.restore()
})

//failing test
it('does nothing', () => {
  fetchMock.get('https://api.github.com/users/1/repos', { status: 400 })

  const subscribed = { type: 'SET_SUBSCRIBED', subscribed: true }
  const store = mockStore({})

  store.dispatch(subscribed)

  const actions = store.getActions()

  expect(actions).toEqual([])
  fetchMock.restore()
})

After looking into this some more, I believe there is something wrong with fetch-mock either not resolving the promise so that the then statements execute or it's completely stubbing out fetch. When I add a console.log to both then statements, nothing executes.

What am I doing incorrectly in my tests?

like image 965
Huy Avatar asked Oct 20 '16 22:10

Huy


1 Answers

Testing Async Thunk Actions in Redux

You are not calling the setSubscribed redux-thunk action creator in any of your tests. Instead you are defining a new action of a the same type and trying to dispatch that on your test.

In both of your tests the following action is being dispatched synchronously.

  const subscribed = { type: 'SET_SUBSCRIBED', subscribed: true }

In this action no request is being made to any API.

We want to be able to fetch from an external API and then dispatch an action on success or failure.

Since we are dispatching the action at somepoint in the future we need to use your setSubscribed thunk action creator.

After briefly explaining how redux-thunk works I will explain how to test this thunk action creator.

Actions vs Action Creators

Perhaps it is worthwhile explaining that an action creator is a function which when called returns an action object.

The term action refers to the object itself. For this action object the only compulsory property is type which should be a string.

For example here is an action creator.

function addTodo(text) {
  return {
    type: ADD_TODO,
    text
  }
}

It is just a function that returns an object. We know that this object is a redux action because one of its properties is called type.

It creates toDos to add on demand. Lets make a new todo to remind us about dog walking.

const walkDogAction = addTodo('walk the dog')

console.log(walkDogAction)
* 
* { type: 'ADD_TO_DO, text: 'walk the dog' }
*

At this point we have an action object which was generated by our action creator.

Now if we want to send this action to our reducers to update our store then we call dispatch with the action object as an argument.

store.dispatch(walkDogAction)

Great.

We have dispatched the object and it will go straight to the reducers and update our store with the new todo reminding us to walk the dog.

How do we make more complex actions? What if I want my action creator to do something that relies on an asynchronous operation.

Synchronous vs Asynchronous Redux Actions

What do we mean by async (asynchronous) and sync (synchronous)?

When you execute something synchronously, you wait for it to finish before moving on to another task. When you execute something asynchronously, you can move on to another task before it finishes.

Ok, so if I want to ask my dog to fetch something? In this case there are three things I care about

  • when I asked him to fetch an object
  • did he fetch something successfully?
  • did he fail to fetch the object? (i.e came back to me with no stick, didn't come back to me at all after a given amount of time)

Its probably hard to imagine how this could be represented by a single object like our addtodo action for walking the dog which just consisted of a type and a piece of text.

Instead of the action being an object it needs to be a function. Why a function? Functions can be used to dispatch further actions.

We split the big overarching action of fetch into three smaller synchronous actions. Our main fetch action creator is asynchronous. Remember this main action creator is not an action itself, it only exists to dispatch further actions.

How does a Thunk Action creator work?

In essence thunk action creators are action creators that return functions instead of objects. By adding redux-thunk into our middleware store these special actions will get access to the store's dispatch and getState methods.

Here is the code inside Redux thunk that does this:

    if (typeof action === 'function') {
      return action(dispatch, getState, extraArgument);
    }

The setSubscribed function is a thunk action creator as it follows the signature of returning a function that takes dispatch as an argument.

Okay so that is why our thunk action creator returns a function. because this function will be called by middleware and give us access to dispatch and get state meaning we can dispatch further actions at a later date.

Modelling Asynchronous Operations with Actions

Lets write our actions. our redux thunk action creator is responsible for asynchronously dispatching the three other actions which represent the lifecycle of aour async action which in this case is an http request. Remember this model applies to any async action as there is necessarily a beginning and a result which marks success or some error (failure)

actions.js

export function fetchSomethingRequest () {
  return {
    type: 'FETCH_SOMETHING_REQUEST'
  }
}

export function fetchSomethingSuccess (body) {
  return {
    type: 'FETCH_SOMETHING_SUCCESS',
    body: body
  }
}

export function fetchSomethingFailure (err) {
  return {
    type: 'FETCH_SOMETHING_FAILURE',
    err
  }
}

export function fetchSomething () {
  return function (dispatch) {
    dispatch(fetchSomethingRequest())
    return fetchSomething('http://example.com/').then(function (response) {
      if (response.status !== 200) {
        throw new Error('Bad response from server')
      } else {
        dispatch(fetchSomethingSuccess(response))
      }
    }).catch(function (reason) {
      dispatch(fetchSomethingFailure(reason))
    })
  }
}

As you probably know the last action is the redux thunk action creator. We know this because it is the only action which returns a function.

Creating our Mock Redux store

In the test file import the configure store function from the redux-mock-store library to create our fake store.

import configureStore from 'redux-mock-store';

This mock store will the dispatched actions in an array to be used in your tests.

Since we are testing a thunk action creator our mock store needs to be configured with the redux-thunk middleware in our test otherwise our store won't be able to handle thunk action creators. Or in other words we won't be able to dispatch functions instead of objects.

const middlewares = [ReduxThunk];
const mockStore = configureStore(middlewares);

Out mock store has a store.getActions method which when called gives us an array of all previously dispatched actions.

We then make our test assertions to compare the actual actions that were to dispatched to the mock store versus our expected actions.

Testing the promise returned by our thunk action creator in Mocha

So at the end of the test we dispatch our thunk action creator to the mock store. We must not forget to return this dispatch call so that the assertions will be run in the .then block when the promise returned by the thunk action creator is resolved.

Working Tests

If you copy this test file into your app with the actions above, making sure to install all the packages and import the actions in the below test file properly then you will have a working example of testing redux thunk action creators to ensure that they dispatch the correct actions.

import configureMockStore from 'redux-mock-store'
import thunk from 'redux-thunk'
import fetchMock from 'fetch-mock'  // You can use any http mocking library
import expect from 'expect' // You can use any testing library

import { fetchSomething } from './actions.js'

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

describe('Test thunk action creator', () => {
  it('expected actions should be dispatched on successful request', () => {
    const store = mockStore({})
    const expectedActions = [ 
        'FETCH_SOMETHING_REQUEST', 
        'FETCH_SOMETHING_SUCCESS'
    ]

 // Mock the fetch() global to always return the same value for GET
 // requests to all URLs.
 fetchMock.get('*', { response: 200 })

    return store.dispatch(fetchSomething())
      .then(() => {
        const actualActions = store.getActions().map(action => action.type)
        expect(actualActions).toEqual(expectedActions)
     })

    fetchMock.restore()
  })

  it('expected actions should be dispatched on failed request', () => {
    const store = mockStore({})
    const expectedActions = [ 
        'FETCH_SOMETHING_REQUEST', 
        'FETCH_SOMETHING_FAILURE'
    ]
 // Mock the fetch() global to always return the same value for GET
 // requests to all URLs.
 fetchMock.get('*', { response: 404 })

    return store.dispatch(fetchSomething())
      .then(() => {
        const actualActions = store.getActions().map(action => action.type)
        expect(actualActions).toEqual(expectedActions)
     })

    fetchMock.restore()
  })
})

Remember since our Redux thunk action creator is not an action itself and only exists to dispatch further actions.

Much of our testing of thunk action creators will focus on making assertions on exactly what additional actions are dispatched under specific conditions.

Those specific conditions are the state of the asynchronous operation which could be a timed out http request or a 200 status representing success.

Common Gotcha when Testing Redux Thunks - Not Returning Promises in Action Creators

Always ensure that when using promises for action creators that you return the promise inside the function returned by the action creator.

    export function thunkActionCreator () {
          return function thatIsCalledByreduxThunkMiddleware() {

            // Ensure the function below is returned so that 
            // the promise returned is thenable in our tests
            return function returnsPromise()
               .then(function (fulfilledResult) {
                // do something here
            })
          }
     }

So if that last nested function is not returned then when we try and call the function asynchronously we will get the error:

TypeError: Cannot read property 'then' of undefined - store.dispatch - returns undefined

That is because we are trying to make an assertion after the promise is fulfilled or rejected in the .then clause. However .then won't work because we can only call .then on a promise. Since we forgot to return the last nested function in the action creator which returns a promise then we will be calling .then on undefined. The reason it is undefined is because there is no return statement within the scope of the function.

So always return functions in action creators that when called return promises.

like image 191
therewillbecode Avatar answered Nov 16 '22 03:11

therewillbecode