Bionic Julia
Posted on November 20, 2021
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 auserId
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 theinterestCount
, and toggles theuserIsInterested
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: {
...state.games,
[action.payload.gameKey]: {
...state.games[action.payload.gameKey],
interest_count: state.games[action.payload.gameKey].interest_count + action.payload.interestCount,
user_is_interested: action.payload.userIsInterested,
},
},
}),
},
extraReducers: {
[fetchGamesSummary.fulfilled.type]: (state, action: { payload: TGames }) => {
const games = action.payload
return {
...state,
games,
}
},
},
})
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 myextraReducers
. - 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 ourgames
state. - Dispatch the
updateGameInterest
action. - Check that the
games
state updates theinterestCount
anduserIsInterested
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! 😃
Talk to me on Twitter, Instagram or my website https://bionicjulia.com
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,
}
Posted on November 20, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.