Skip to content

Instantly share code, notes, and snippets.

@HiDeoo
Last active March 1, 2018 15:45
Show Gist options
  • Save HiDeoo/3ce7a356ba7bf75a7b6594abac36cd85 to your computer and use it in GitHub Desktop.
Save HiDeoo/3ce7a356ba7bf75a7b6594abac36cd85 to your computer and use it in GitHub Desktop.
Redux testing

Jest

I'll be using Jest in all the examples below as it's the only testing framework I use for a long time now and the one I consider to be the best at the moment but I'm pretty sure everything (or almost) can be applied to Mocha or w/e testing framework you're using.

Some of the reasons on why Jest:

  • Easy All-in-One solution
  • Snapshot testing is built-in
  • Code coverage built-in
  • Mocking built-in
  • Parallelism by default

I'm not gonna go over anything more regarding Jest specifically like installation & usage as it was not the point of the question but if needed, I can provide ressources / setup examples for that.

Redux

Redux by definition is very easy to test as it's mostly functions and most of these functions are pure.

Action creators

Action creators are the easiest thing to test in Redux as they are just functions returning a POJO so testing them just involves making sure the proper action type is used and the correct action was returned.

Imagine the following action creator:

/**
 * Actions types.
 * @type {Enum}
 */
export const ACTIONS = _.enum(['UPDATE'], 'namespace')

/**
 * Update something.
 * @param  {string} thingy - The new value.
 * @return {Object} The action.
 */
export const update = (thingy) => ({
  type: ACTIONS.UPDATE,
  thingy,
})

The associated test would be as simple as:

describe('update action creator', () => {
  test('should return the proper action', () => {
    const thingy = 'Bweeeeee'

    const action = thing.update(thingy)

    expect(action.type).toBe(thing.ACTIONS.UPDATE)
    expect(action.thingy).toBe(thingy)
  })
})

Simplification / Snapshot

Of course, this test can be simplified:

describe('update action creator', () => {
  test('should return the proper action', () => {
    const thingy = 'Bweeeeee'

    expect(thing.update(thingy)).toEqual({
      type: thing.ACTIONS.UPDATE,
      thingy,
    })
  })
})

But it can be even more easier / less boilerplate using snapshots. Snapshot testing is only 1 assertion type baked in Jest (and some other frameworks too).

Let's rewrite the same test using a snapshot:

describe('update action creator', () => {
  test('should return the proper action', () => {
    const thingy = 'Bweeeeee'

    expect(thing.update(thingy)).toMatchSnapshot()
  })
})

And that's it. The first time you'll run your test, it'll pass by default and a snapshot will be created next to your test file in __snapshots__/file.test.js.snap containing:

// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`update action should return the proper action 1`] = `
Object {
  "type": "UPDATE",
  "thingy": "Bweeeeee",
}
`;

Your first job after writing the test is making sure the snapshot created is correct. It's very easy as they're pretty formatted to make them human readable for code review. When running the test again, Jest will simply compare the output to the prevous snapshot and pass if they match or fail if they don't:

Snapshot

If something is wrong, you see exactly what / where the issue is and can go fix it. If the change is expected following a refactor for example, you just have to update the snapshot and persist the new version (u key in Jest built-in watch mode).

Checking snapshots content on your hard drive can be annoying if you have to go in the proper directory, find the __snapshots__ directory, open the file.test.js.snap file, etc. Some CLI tools exists to make this easier, some editor extensions can also help with that. For example, I personally use this extension in VSCode to get a tooltip with the snapshot content when hovering the toMatchSnapshot() function call.

Tooltip

Notes:

  • Snapshot files should be committed, they're part of the test and you want your tests to have the same snapshot output no matter where they run. It also helps a lot during code reviews to see the expected output.
  • Pretty much anything can be serialized to a snapshot from JS native data types, JSON ofc, React render output (yes, yes, Kreygasm), etc.
exports[`renders properly a link 1`] = `
<a
  className="less-dansgame"
  href="https://play.bot.land/"
  onClick={[Function]}
  onMouseEnter={[Function]}
  onMouseLeave={[Function]}
>
  My Awesome Link
</a>
`;

Enough about snapshots, back to Redux ^^ Altho I'll assume snapshot is a known concept now ^^

Selectors

Selectors are also very easy to test as they just take a slice of the state and return a derived value from it. The way the selector is being built using plain JS or a library like Reselect doesn't matter. You just want to make sure that for a given input, it always produce the same output.

Imagine the following memoized selector built using Reselect to compute some values based on some value in your state:

/**
 * Returns the computed thing.
 * @param  {Object} state - The Redux state.
 * @return {Object} The computed thingy.
 */
export const selector = createSelector(
  state => state.thingyA,
  state => state.thingyB,
  (thingyA, thingyB) => ({
    outputA: thingyA * 2,
    outputB: thingyB + thingyA,
  })
)

The associated test would be:

describe('selector', () => {
  test('should return the proper result by default', () => {
    // Note: it's very useful to export your reducer default state as you can test it if
    // you want and re-use in other tests.
    expect(thing.selector(initialState)).toMatchSnapshot()
  })

  test('should return the proper result with a negative thingy', () => {
    // Note: it's very useful to export your reducer default state as you can test it if
    // you want and re-use in other tests.
    expect(thing.selector({ ...initialState, thingyA: -42 })).toMatchSnapshot()
  })
})

If your tests are memoized, you might also want to check that they're properly memoized. For example, if you're using Reselect to memoize it for you, selectors have a recomputations method that returns the number of time it has been recomputed:

describe('selector', () => {
  test('should be properly memoized', () => {
    let state = reducer(initialState, increment)

    expect(thing.selector(state)).toMatchSnapshot()

    // Should have been computed 1 time at this point.
    expect(thing.selector.recomputations()).toBe(1)

    // Update a part of the state that doesn't have any impact on this selector.
    state = reducer(initialState, increment)

    expect(thing.selector(state)).toMatchSnapshot()

    // It shouldn't have recomputed.
    expect(thing.selector.recomputations()).toBe(1)

    // Update a relevant part of the state.
    state = reducer(initialState, increment)

    expect(thing.selector(state)).toMatchSnapshot()

    // It should have recomputed once.
    expect(thing.selector.recomputations()).toBe(2)
  })
})

Reducers

Testing reducers are also pretty easy as they're nothing more than a function returning a new state computed by applying an action to the previous state.

For example, with the following duck based on the previous example:

/**
 * Actions types.
 * @type {Enum}
 */
export const ACTIONS = _.enum(['UPDATE'], 'namespace')

/**
 * Initial state.
 * @type {Object}
 */
export const initialState = {
  /**
   * Thingy.
   * @type {string}
   */
  thingy: '',
}

/**
 * Thing reducer.
 * @param  {Object} state = initialState - Current state.
 * @param  {Object} action = {} - Current action.
 * @return {Object} The new state.
 */
export default function thing(state = initialState, action = {}) {
  switch (action.type) {
    case ACTIONS.UPDATE: {
      return {
        ...state,
        thingy: action.thingy,
      }
    }
    default: {
      return state
    }
  }
}

/**
 * Update something.
 * @param  {string} thingy - The new value.
 * @return {Object} The action.
 */
export const update = thingy => ({
  type: ACTIONS.UPDATE,
  thingy,
})

The tests would be:

describe('thing reducer', () => {
  test('should return the initial state', () => {
    expect(thing.reducer()).toMatchSnapshot()
  })

  test('should handle UPDATE', () => {
    expect(
      thing.reducer(thing.initialState, {
        type: thing.ACTIONS.UPDATE,
        thingy: 'test',
      })
    ).toMatchSnapshot()

    // Or...
    expect(thing.reducer(thing.initialState, thing.update('test'))).toMatchSnapshot()
  })
})

Async Action creators

Disclaimer: I'm most of the time using redux-saga myself as I prefer the features / flexibility they bring to the table vs redux-thunk so this part may be a little bit less complete than the other ones as the tool / way to test would be different with things like redux-saga-tester.

Let's start with the following duck involving an async action creator using redux-thunk:

/**
 * Actions types.
 * @type {Enum}
 */
export const ACTIONS = _.enum(['FETCH', 'FETCH_NEWS_SUCCESS', 'FETCH_NEWS_FAILURE'], 'namespace')

/**
 * Starts fetching something.
 * @return {Object} The action.
 */
export const fetch = () => ({
  type: ACTIONS.FETCH,
})

/**
 * Fetching news items did succeed.
 * @param  {Object} news - The fetched news.
 * @return {Object} The action.
 */
export const fetchNewsSuccess = news => ({
  type: ACTIONS.FETCH_NEWS_SUCCESS,
  news,
})

/**
 * Fetching news items did fail.
 * @param  {Error} error - The encountered error.
 * @return {Object} The action.
 */
export const fetchNewsFailure = error => ({
  type: ACTIONS.FETCH_NEWS_FAILURE,
  error,
})

/**
 * Fetches news items from the server.
 * @return {Function} The thunk.
 */
export const fetchNews = () => async dispatch => {
  dispatch(fetch())

  try {
    const response = await fetch('https://play.bot.land/?queryString=areAwesome')
    const json = await response.json()

    dispatch(fetchNewsSuccess(json))
  } catch (error) {
    dispatch(fetchNewsFailure(error))
  }
}

What we really want to test here is that our FETCH action is dispatched and that depending on the succcess / failure of the request, the proper action (either FETCH_NEWS_SUCCESS or FETCH_NEWS_FAILURE) will be dispatched with the proper arguments. We don't care about the actions themselves as long as they're called with the proper arguments, the action itself should be tested in another test (as we saw earlier in the part regarding testing action creators).

When testing async action creators, it's easier to mock the entire Redux store (redux-mock-store is perfect for that) and use redux-thunk as a middleware of the mocked store. We'll also mock the answer we're getting from the server as we just want to see the thunk behavior depending on the request result (jest-fetch-mock (used in the following example), fetch-mock, nock, manual mock, etc.).

import configureMockStore from 'redux-mock-store'
import thunk from 'redux-thunk'

// TODO: In a real case, this should be done somewhere else than in this specific test file so we can configure it once
// and import it in the tests needing it.
const mockStore = configureMockStore([thunk])

describe('fetchNews action', () => {
  test('should dispatch the proper actions when it succeed', async () => {
    const news = [
      { id: 1, text: 'Something' },
      { id: 2, text: 'Bweeee' },
      { id: 3, text: 'Miam' },
      { id: 8, text: 'Test' },
    ]

    // The next fetch call will be mocked and return the news items.
    fetch.mockResponseOnce(JSON.stringify({ news }))

    const store = mockStore(thing.initialState)

    await store.dispatch(thing.fetchNews())

    // Dispatched actions.
    const actions = store.getActions()

    // We expect 2 actions to have been dispatched.
    expect(actions).toHaveLength(2)

    // We expect the FETCH action to have been dispatched.
    expect(expectedActions).toContainEqual({ type: thing.ACTIONS.FETCH })

    // We expect the FETCH_NEWS_SUCCESS action to have been dispatched with the proper news items.
    expect(expectedActions).toContainEqual({ type: thing.ACTIONS.FETCH_NEWS_SUCCESS, news })

    // We could also have used a snapshot insted of all these individuals expect statement.
    expect(store.getActions()).toMatchSnapshot()
  })

  test('should dispatch the proper actions when it fails', async () => {
    // The next fetch call will be mocked and fails.
    fetch.mockRejectOnce(new Error('fake error message'))

    const store = mockStore(thing.initialState)

    await store.dispatch(thing.fetchNews())

    // Let's use snapshots for that because we can and it'll be shorter than the previous test.
    expect(store.getActions()).toMatchSnapshot()
  })
})

Middlewares

If you want to test your custom middlewares, it's also possible. A middleware is nothing more than:

const customMiddleware = store => next => action => {
  // Custom middleware code here...

  return next(action)
}

Let's imagine a logger middleware:

export const loggerMiddleware = store => next => action => {
  console.log('dispatching', action)

  let result = next(action)

  console.log('next state', store.getState())

  return result
}

To test this middleware, we'll want to mock the store, the next middleware & the action and asserts the behavior a middleware should have:

describe('loggerMiddleware', () => {
  test('should pass the action to the next middleware', () => {
    const store = {}
    const next = jest.fn()
    const action = { type: 'TEST_ACTION' }

    loggerMiddleware(store)(next)(action)

    expect(next).toHaveBeenCalledWith(action)
  })

  test('should log the action & the next state', () => {
    const store = {}
    const next = jest.fn()
    const action = { type: 'TEST_ACTION' }

    const spy = jest.spyOn(console, 'log')

    loggerMiddleware(store)(next)(action)

    expect(spy).toHaveBeenCalledTimes(2)
    // TODO: Test the logged strings too.
  })
})

Connected components (using connect())

I don't think you mentionned testing anything related to React itself but if needed I have some things to say about that too but it's a lot more as it needs to cover basic component testing first. The most useful tip when testing connected components (containers) is to not only export the decorated (using connect()) component but both the decorated & non-decorated one.

Ressources

React/Redux Testing links

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment