Bressain.com

Swapping redux-thunk With redux-saga For Better Testing

November 10th 2017

redux-saga logo

I think it’s pretty safe to say at this point that the vast majority of React code bases that need state management are using Redux. One of the things people like about Redux (yours truly included), is the testing story is great. Because reducers don’t allow mutating state, you’ll get the same result with the same input every time (yay functional paradigms!). But then there’s testing actions…

Action Testing With redux-thunk

Why is action testing difficult? I have talked to people that said they don’t because it’s too hard or they don’t get enough value from it. My first follow-up question is what middleware they’re using to do asynchronous things in their actions and almost always the answer is redux-thunk.

Don’t get me wrong, redux-thunk is great and I used it for quite a while. Pair redux-thunk up with async/await and you get some great looking code–until you try to test it. Let me illustrate.

Take this action that makes a call to the Star Wars API. It’s simple enough: it creates a request to get information about a Star Wars film, awaits it, then dispatches a success event with the film data.

export function fetchFilm(filmId) {
  return async (dispatch) => {
    dispatch(fetchFilmRequest(filmId))
    const res = await api.fetchFilm.request(filmId)
    dispatch(fetchFilmSuccess(api.fetchFilm.deserializeSuccess(res)))
  }
}

Here’s the associated test:

describe('#fetchFilm', () => {
  let dispatch
  const res = { data: { title: 'wow', episode_id: 3, characters: [] } }
  const reqPromise = new Promise(resolve => resolve(res))

  beforeAll(async () => {
    dispatch = jest.fn()
    api.fetchFilm.request = jest.fn(() => reqPromise)

    await actions.fetchFilm(3)(dispatch)
  })

  it('dispatches fetch request', () => {
    expect(dispatch).toBeCalledWith({ type: TYPES.FETCH_FILM_REQUEST, filmId: 3 })
  })

  it('calls api', () => {
    expect(api.fetchFilm.request).toBeCalledWith(3)
  })

  it('dispatches success event', async () => {
    const film = api.fetchFilm.deserializeSuccess(res)
    await reqPromise
    expect(dispatch).toBeCalledWith({ type: TYPES.FETCH_FILM_SUCCESS, film })
  })
})

Let’s be honest, the test isn’t terrible. There are a few mocks but it’s totally doable and it’s not unusual to for tests be larger than the code that they’re testing.

What happens when you need to do something like take the film result and get all the people associated with that film:

export function fetchFilm(filmId) {
  return async (dispatch) => {
    dispatch(fetchFilmRequest(filmId))

    const res = await api.fetchFilm.request(filmId)
    const film = api.fetchFilm.deserializeSuccess(res)
    dispatch(fetchFilmSuccess(film))

    await Promise.all(film.characters.map(personId => fetchPerson(personId)(dispatch)))
  }
}

export function fetchPerson(personId) {
  return async (dispatch) => {
    dispatch(fetchPersonRequest(personId))
    const res = await api.fetchPerson.request(personId)
    dispatch(fetchPersonSuccess(personId, api.fetchPerson.deserializeSuccess(res)))
  }
}

Good thing we went with async/await. It reads well and is (I hope) obvious what’s going on. Once we get the film, we spin through the characters, request their info, and then dispatch success to be picked up by the reducer. Let’s see how that affected the tests.

describe('#fetchFilm', () => {
  let dispatch
  const characters = ['http://swapi.co/api/people/1/', 'http://swapi.co/api/people/2/']
  const characterIds = [1, 2]
  const res = { data: { title: 'wow', episode_id: 3, characters } }
  const reqPromise = new Promise(resolve => resolve(res))
  const personPromises = []
  const peopleRes = []

  beforeAll(async () => {
    dispatch = jest.fn()
    api.fetchFilm.request = jest.fn(() => reqPromise)
    api.fetchPerson.request = jest.fn(() => {
      const pRes = { data: { name: 'Derp' } }
      const req = new Promise(resolve => resolve(pRes))
      personPromises.push(req)
      peopleRes.push(pRes)
      return req
    })

    await actions.fetchFilm(3)(dispatch)
  })

  it('dispatches fetch request', () => {
    expect(dispatch).toBeCalledWith({ type: TYPES.FETCH_FILM_REQUEST, filmId: 3 })
  })

  it('calls api', () => {
    expect(api.fetchFilm.request).toBeCalledWith(3)
  })

  it('calls api for characters', () => {
    expect(api.fetchPerson.request).toBeCalledWith(1)
    expect(api.fetchPerson.request).toBeCalledWith(2)
  })

  it('dispatches success event', async () => {
    const film = { ...api.fetchFilm.deserializeSuccess(res), characters: characterIds }
    await reqPromise
    expect(dispatch).toBeCalledWith({ type: TYPES.FETCH_FILM_SUCCESS, film })
  })

  it('dispatches person success events', async () => {
    const people = characterIds.map((id, idx) => ({ ...api.fetchPerson.deserializeSuccess(peopleRes[idx]), id }))
    await Promise.all(personPromises)

    for (let person of people) {
      expect(dispatch).toBeCalledWith({ type: TYPES.FETCH_PERSON_SUCCESS, person })
    }
  })
})

Oh man, those two expressions sure did result in a lot of additional complexity in the tests. If we need to include more things to do within our action, the complexity is only going to be worse and the tests will be more brittle.

This is where people start throwing up their hands because they’re not getting as much value out of their tests. If the value of the tests aren’t outweighing the cost of maintaining them, good developers won’t test the code. So with that, we have a few options:

  1. Throw out our action tests and lean on reducer & component testing only.
  2. Find a library that abstracts this nastiness away.

I chose #2 by moving to redux-saga.

Introducing redux-saga

redux-saga leans on using generators and the “effects as data” paradigm that Elm users are familiar with. Don’t worry about understanding those two things right now, only that redux-saga uses them and makes testing rad.

Let’s convert that action to use redux-saga.

export function* executeFetchFilm({ filmId }) {
  const res = yield call(api.fetchFilm.request, filmId)
  const film = api.fetchFilm.deserializeSuccess(res)
  yield put(fetchFilmSuccess(film))

  for (let personId of film.characters) {
    yield put(fetchPerson(personId))
  }
}

export const fetchPerson = personId => ({
  type: TYPES.FETCH_PERSON_REQUEST,
  personId
})

export function* executeFetchPerson({ personId }) {
  const res = yield call(api.fetchPerson.request, personId)
  yield put(fetchPersonSuccess(personId, api.fetchPerson.deserializeSuccess(res)))
}

You might be thinking, “This looks about the same size as the redux-thunk version, and perhaps a bit more complicated” and you’d be right, especially if you didn’t know what the function* thing is about. The asterisk indicates that this is a generator function and any time the yield keyword is used, execution effectively “pauses” until the statement completes (similar to await).

call & put are redux-saga-specific things that mean “call this method asynchronously” & “dispatch an action” respectively. You’ll notice that we’re not dispatching request actions anymore at the beginning of the “execute” actions and there’s no clear connection between fetchPerson and executeFetchPerson. That magic happens over here:

import { takeEvery } from 'redux-saga'
import { fork } from 'redux-saga/effects'

import * as actions from './actions'
import TYPES from './types'

function watchEvery(actionType, saga) {
  return fork(function* () {
    yield* takeEvery(actionType, saga)
  })
}

export default function* () {
  yield [
    watchEvery(TYPES.FETCH_FILM_REQUEST, actions.executeFetchFilm),
    watchEvery(TYPES.FETCH_PERSON_REQUEST, actions.executeFetchPerson)
  ]
}

Because redux-saga is a redux middleware, it looks for those REQUEST actions and any time they see one, they call the respective generator function in the actions. There are fancier things that redux-saga can do but for now we’re just trying to do a straight cutover from redux-thunk.

Testing With redux-saga

Enough of that though, we’re not here to learn all the nooks and crannies of redux-saga, we’re here to improve our testing story! Let’s see how things changed:

describe('#executeFetchFilm', () => {
  const characters = ['http://swapi.co/api/people/1/', 'http://swapi.co/api/people/2/']
  const characterIds = [1, 2]
  const res = { data: { title: 'wow', episode_id: 3, characters } }

  const iterator = actions.executeFetchFilm({ filmId: 3 })

  it('calls api', () => {
    expect(iterator.next().value).toEqual(call(api.fetchFilm.request, 3))
  })

  it('dispatches success event', () => {
    const film = { ...api.fetchFilm.deserializeSuccess(res), characters: characterIds }
    expect(iterator.next(res).value).toEqual(put({ type: TYPES.FETCH_FILM_SUCCESS, film }))
  })

  it('fetches characters', () => {
    expect(iterator.next().value).toEqual(put({ type: TYPES.FETCH_PERSON_REQUEST, personId: 1 }))
    expect(iterator.next().value).toEqual(put({ type: TYPES.FETCH_PERSON_REQUEST, personId: 2 }))
  })
})

Now this is something I can get on-board with. No mocking needed and nearly as clear as a reducer test. Once again, we’re using the call & put constructs from redux-saga to help us but no APIs are getting called, and no redux actions are flying around.

How these tests work is each time iterator.next() is called, the next yield is called and the result is given in the .value. If something that a yielded statement returned is needed for the next yield statement, just pass in something that would work into the next iterator.next() call, like we did with the API call result.

One caveat: this particular set of tests need to be run in order to run correctly. You could remedy this by creating the iterator in a beforeEach block and calling iterator.next() as many times as you needed in each it block to get to the specific thing you want to test.

Make Action Testing Great Again

I hope I’ve at least piqued your interest in testing your actions or giving you some tools to fix some brittle action tests. If you’re interested in seeing the whole code-base progression from redux-thunk to redux-saga, check out my repo at https://github.com/bressain/ohai-redux-saga and view the commit diffs.

If you’ve found something even better, let me know in the comments!