Avatar

Ciao, I'm Julia.

Writing Jest Tests For a Redux Toolkit Slice

#axios #jest #react #redux-toolkit

7 min read

I've been doing a fair amount of work recently with Redux Toolkit (RTK), for a new feature I'm building. I'm also trying to be a lot stricter with ensuring I've got tests for all the key parts of the code I've written, and so, have also been delving deeper into writing Jest tests for RTK.

The way I learn how to write tests is by following along to good examples. I therefore thought I'd write this blog post as a way to help others who might also be going through this process, but also as a record for myself, as I'm sure I'll be writing similar tests in the future.

Scene setting

To set the context, let's say we've set up our RTK slice for a gaming app we're creating. This Games slice has a state that's basically an object of objects. It allows for an asynchronous fetchGamesSummary action that calls an external API, and a synchronous updateGameInterest action.

  • The fetchGamesSummary async thunk is called with a userId and returns a list of games that looks like this:
    {
      call_of_duty: {
        interest_count: 10,
        key: "call_of_duty",
        user_is_interested: true,
      },
      god_of_war: {
        interest_count: 15,
        key: "god_of_war",
        user_is_interested: false,
      },
      //...
    }
  • The updateGameInterest action is effected by a button toggle, where a user is able to toggle whether they are interested (or not) in a game. This increments/decrements the interestCount, and toggles the userIsInterested value between true/false. Note, the camelCase is because it relates to frontend variable. snake_case is what's received from the API endpoint.
import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit'

export const initialStateGames: TStateGames = {
  games: {},
}

export const fetchGamesSummary = createAsyncThunk('games/fetch_list', async (userId: string) => {
  const response = await gamesService.list(userId)
  return response
})

export const gamesSlice = createSlice({
  initialState: initialStateGames,
  name: 'Games',
  reducers: {
    updateGameInterest: (state, action: PayloadAction<TUpdateGameInterestAction>) => {
      state.games[action.payload.key].interest_count += action.payload.interestCount
      state.games[action.payload.key].user_is_interested = action.payload.userIsInterested
    },
  },
  extraReducers(builder) {
    builder.addCase(fetchGamesSummary.fulfilled, (state, action) => {
      state.games = action.payload
    })
  },
})

I haven't shown it here, but upon defining your new slice, you're also going to need to ensure the reducer is added to your combineReducers. e.g.

export default combineReducers({
  games: gamesSlice.reducer,
  // your other reducers
})

Side note: If you want to see the types, scroll down to the Appendix below.

Jest tests

There are a few different things I want to test my RTK slice for. My tests' describe looks like this:

  • Games redux state tests...
    • Should initially set games to an empty object.
    • Should be able to fetch the games list for a specific user.
    • Should be able to toggle interest for a specific game.

Should initially set games to an empty object

I'm going to assume you've already got your Jest config setup for your app. This first test checks that we can connect to our store and specific slice.

import store from './store'

describe('Games redux state tests', () => {
  it('Should initially set games to an empty object', () => {
    const state = store.getState().games
    expect(state.games).toEqual({})
  })
})

Your store is where you set up your configureStore. See the documentation here for more info. getState() is a method that returns the current state tree, from which I'm particularly interested in the games slice.

Should be able to fetch the games list for a specific user

This test requires some initial setup as we'll be calling an external API. This bit might differ for you, as it'll depend on how you call your API. I have mine set up through an ApiClient class, which I use to set up my base API Axios settings. If you're interested in learning more about this, read my previous blog post on Axios wrappers. In this app, I've defined a getClient() method within my ApiClient class that returns an AxiosInstance.

For the purposes of testing, I don't actually want to make an API call, so I mocked the API request through the use of axios-mock-adapter. There are other packages available, so browse around for whatever works best for you. The MockAdaptor takes in an Axios instance as an argument, and from there, enables you to mock call your GET endpoint with your defined mock response. Note here that the API endpoint /games/list/?user_id=${userId} is in effect what my gamesService.list(userId) calls in my fetchGamesSummary function above.

import ApiClient from '../api/ApiClient'
import MockAdapter from 'axios-mock-adapter'
import store from '../../store'

const userId = 'test123'

const getListResponse = {
  game_1: {
    interest_count: 0,
    key: 'game_1',
    user_is_interested: false,
  },
}

const apiClient = new ApiClient()

const mockNetworkResponse = () => {
  const mock = new MockAdapter(apiClient.getClient())
  mock.onGet(`/games/list/?user_id=${userId}`).reply(200, getListResponse)
}

When writing the test, I needed to:

  • Dispatch the fetchGamesSummary async action.
  • Check the result type was fulfilled i.e. matches how I defined my extraReducers.
  • Check that the result from the dispatch matches the mock response.
  • Check that the games state reflects what I fetched from the API.

Putting it all together then...

import ApiClient from '../api/ApiClient'
import MockAdapter from 'axios-mock-adapter'

import store from '../../store'
// import your slice and types

const userId = 'test123'
const getListResponse = {
  game_1: {
    interest_count: 0,
    key: 'game_1',
    user_is_interested: false,
  },
}

const apiClient = new ApiClient()

const mockNetworkResponse = () => {
  const mock = new MockAdapter(apiClient.getClient())
  mock.onGet(`/games/list/?user_id=${userId}`).reply(200, getListResponse)
}

describe('Games redux state tests', () => {
  beforeAll(() => {
    mockNetworkResponse()
  })

  it('Should be able to fetch the games list for a specific user', async () => {
    const result = await store.dispatch(fetchGamesSummary(userId))
    const games = result.payload

    expect(result.type).toBe('games/fetch_list/fulfilled')
    expect(games.game_1).toEqual(getListResponse.game_1)

    const state = store.getState().games
    expect(state).toEqual({ games })
  })
})

Should be able to toggle interest for a specific game

With everything set up nicely now, this final test is relatively simpler to write. Just be sure to include the beforeAll block calling the mockNetworkResponse() (since ultimately, all your tests will be in this one file).

When writing this test, I needed to:

  • Dispatch the fetchGamesSummary async action to fill out our games state.
  • Dispatch the updateGameInterest action.
  • Check that the games state updates the interestCount and userIsInterested values correctly.
import ApiClient from '../api/ApiClient'
import MockAdapter from 'axios-mock-adapter'

import store from '../../store'
// import your slice and types

const userId = 'test123'
const getListResponse = {
  game_1: {
    interest_count: 0,
    key: 'game_1',
    user_is_interested: false,
  },
}

const apiClient = new ApiClient()

const mockNetworkResponse = () => {
  const mock = new MockAdapter(apiClient.getClient())
  mock.onGet(`/games/list/?user_id=${userId}`).reply(200, getListResponse)
}

describe('Games redux state tests', () => {
  beforeAll(() => {
    mockNetworkResponse()
  })

  it('Should be able to toggle interest for a specific game', async () => {
    await store.dispatch(fetchGamesSummary(userId))

    store.dispatch(
      gamesSlice.actions.updateGameInterest({
        interestCount: 1,
        userIsInterested: true,
        gameKey: 'game_1',
      }),
    )

    let state = store.getState().games
    expect(state.games.game_1.interest_count).toBe(1)
    expect(state.games.game_1.userIsInterest).toBe(true)

    store.dispatch(
      gamesSlice.actions.updateGameInterest({
        interestCount: -1,
        userIsInterested: false,
        gameKey: 'game_1',
      }),
    )
    state = store.getState().games
    expect(state.games.game_1.interest_count).toBe(0)
    expect(state.games.game_1.userIsInterest).toBe(false)
  })
})

And that's it! I came up with this example solely for the purpose of this blog post, so didn't actually test that the code works. ๐Ÿ˜… If you come across any suspected errors, let me know. Or, if you come up with a better way of testing my cases, I'd be all ears! ๐Ÿ˜ƒ

Appendix

Types

export type TGame = {
  interest_count: number,
  key: string,
  user_is_interested: boolean,
}

export type TGames = { string: TGame } | {}

export type TStateGames = {
  games: TGames,
}

export type TUpdateGameInterestAction = {
  gameKey: string,
  userIsInterested: boolean,
  interestCount: number,
}

ยฉ 2016-2024 Julia Tan ยท Powered by Next JS.