I am newbee with the redux toolkit library and especially when it comes to testing. I looked through the documentation and read a bunch of posts and articles in regards to this subject but still struggle. I build a simple todo app and included a couple of API requests to cover asynchronous cases. Testing those turned out to be a bit challenging though. I am hoping to get some advice and feedback on my code and what could be improved. I also wanted some opinions on whether testing the createAsyncThunk slice makes sense or not. NOTE: I am not interested in testing the API calls itself, and use mock data to recreate a successful request.
Constructive criticism is very helpful and would be highly appreciated
Please take a look of one of my slice file and test
postsSlice.ts
import { createSlice, createAsyncThunk } from "@reduxjs/toolkit";
import { RootState } from "../../store";
import axios from "axios";
export type Post = {
userId: number;
id: number;
title: string;
body: string;
};
export type PostsState = {
posts: Post[];
loading: boolean;
error: null | string;
};
export const initalPostState: PostsState = {
posts: [],
loading: false,
error: null,
};
export const fetchAllPosts = createAsyncThunk(
"posts/allPosts",
async (data, { rejectWithValue }) => {
try {
const response = await axios.get(
`https://jsonplaceholder.typicode.com/posts`
);
return (await response.data) as Post[];
} catch (err) {
if (!err.response) {
throw err;
}
return rejectWithValue(err.response.data);
}
}
);
export const fetchSuccessful = fetchAllPosts.fulfilled;
export const fetchPending = fetchAllPosts.pending;
export const fetchFailed = fetchAllPosts.rejected;
const postsSlice = createSlice({
name: "Posts",
initialState: initalPostState,
reducers: {},
extraReducers: (builder) => {
builder.addCase(fetchSuccessful, (state, { payload }) => {
state.posts = payload;
state.loading = false;
});
builder.addCase(fetchPending, (state, action) => {
state.loading = true;
});
builder.addCase(fetchFailed, (state, action) => {
state.error = action.error.message
? action.error.message
: "Failed to load data";
state.loading = false;
});
},
});
export const selectPosts = (state: RootState) => state.fetchedPosts;
export const fetchedPostsReducer = postsSlice.reducer;
Testing
postsSlice.test.ts
import {
initalPostState,
fetchPending,
fetchFailed,
selectPosts,
fetchSuccessful,
fetchedPostsReducer,
} from "./postsSlice";
import { Post, PostsState } from "./postsSlice";
import store, { RootState } from "../../store";
const appState = store.getState();
describe("postsSlice", () => {
describe("Posts State, Posts Action and Selector", () => {
it("should set loading state on true when API call is pending", async (done) => {
// Arrange
// Act
const nextState: PostsState = await fetchedPostsReducer(
initalPostState,
fetchPending
);
// Assert
const rootState: RootState = { ...appState, fetchedPosts: nextState };
expect(selectPosts(rootState).loading).toBeTruthy();
expect(selectPosts(rootState).error).toBeNull();
done();
});
it("should set error state when API call is rejected", async (done) => {
// Arrange
const response = {
message: "Network request failed",
name: "error",
};
// Act
const nextState: PostsState = await fetchedPostsReducer(
initalPostState,
fetchFailed(response, "")
);
// Assert
const rootState: RootState = { ...appState, fetchedPosts: nextState };
expect(selectPosts(rootState).loading).toBeFalsy();
expect(selectPosts(rootState).error).not.toBeNull();
expect(selectPosts(rootState).error).toEqual("Network request failed");
done();
});
it("should update state when API call is successful", async (done) => {
// Arrange
const response: Post[] = [
{
userId: 1,
id: 1,
title:
"sunt aut facere repellat provident occaecati excepturi optio reprehenderit",
body:
"quia et suscipit\nsuscipit recusandae consequuntur expedita et cum\nreprehenderit molestiae ut ut quas totam\nnostrum rerum est autem sunt rem eveniet architecto",
},
{
userId: 1,
id: 2,
title: "qui est esse",
body:
"est rerum tempore vitae\nsequi sint nihil reprehenderit dolor beatae ea dolores neque\nfugiat blanditiis voluptate porro vel nihil molestiae ut reiciendis\nqui aperiam non debitis possimus qui neque nisi nulla",
},
];
// Act
const nextState: PostsState = await fetchedPostsReducer(
initalPostState,
fetchSuccessful(response, "")
);
// Assert
const rootState: RootState = { ...appState, fetchedPosts: nextState };
expect(selectPosts(rootState).loading).toBeFalsy();
expect(selectPosts(rootState).error).toBeNull();
expect(selectPosts(rootState).posts).toEqual(
expect.arrayContaining(response)
);
done();
});
});
});
The Redux team recommends using React Testing Library (RTL) to test React components that connect to Redux. React Testing Library is a simple and complete React DOM testing utility that encourages good testing practices. It uses ReactDOM's render function and act from react-dom/tests-utils.
Steps: extract each mapDispatchToProps property as a separate action creator function in another file. extract each mapStateToProps property as a separate selector function in another file. write tests for the selectors and action creators.
I answered already on the GitHub for the redux toolkit, but I will also post here since it was one of the many links I came to before experimenting with my own solution.
Since createAsyncThunk
returns a function for later execution, you can use this to your advantage. Instead of going through the hassle of testing an entire store's interaction with your thunks, you can test the thunks themselves in isolation away from a store.
Run your jest.mock
calls to mock any API/hooks you may be using to access your server or local state, change what those resolve/return, and then execute the method you saved. Doing so gives you access to the promise / method inside your createAsyncThunk
call with the argument you would normally call baked in.
Instead of testing the store, you want to test that the thunks are dispatching the actions that the store relies on to set things like loading, the error messages you want to save, etc. This way you can create tests for your reducers instead, where you can recreate a brand new store with every test, and ensure that all your transforms via those reducers are correct.
// features/account/thunks.ts
import api from './api'; // http calls to the API
import { actions } from './reducer'; // "actions" from a createSlice result
import { useRefreshToken } from './hooks'; // a `useSelector(a => a.account).auth?.refreshToken` result
// declare and code as normal
export const register = createAsyncThunk(
'accounts/register',
async (arg: IRegisterProps, { dispatch }) => {
try {
const data = await api.register(arg);
dispatch(actions.authSuccess(data));
} catch (err) {
console.error('Unable to register', err);
}
}
);
// Using a hook to access state
export const refreshSession = createAsyncThunk(
'accounts/refreshSession',
async (_, { dispatch }) => {
// or add `, getState` beside dispatch and do token = getState().accounts.auth.refreshToken;
// If you use getState, your test will be more verbose though
const token: string = useRefreshToken();
try {
const data = await api.refreshToken(token);
dispatch(actions.tokenRefreshed(data));
} catch (err) {
console.error('Unable to refresh token', err);
}
}
);
// features/account/thunks.test.ts
import apiModule from './api';
import hookModule from './hooks';
import thunks from './thunks';
import { actions } from './reducer';
import { IRegisterProps } from './types';
import { AsyncThunkAction, Dispatch } from '@reduxjs/toolkit';
import { IAuthSuccess } from 'types/auth';
jest.mock('./api');
jest.mock('./hooks')
describe('Account Thunks', () => {
let api: jest.Mocked<typeof apiModule>;
let hooks: jest.Mocked<typeof hookModule>
beforeAll(() => {
api = apiModule as any;
hooks = hookModule as any;
});
// Clean up after yourself.
// Do you want bugs? Because that's how you get bugs.
afterAll(() => {
jest.unmock('./api');
jest.unmock('./hooks');
});
describe('register', () => {
// We're going to be using the same argument, so we're defining it here
// The 3 types are <What's Returned, Argument, Thunk Config>
let action: AsyncThunkAction<void, IRegisterProps, {}>;
let dispatch: Dispatch; // Create the "spy" properties
let getState: () => unknown;
let arg: IRegisterProps;
let result: IAuthSuccess;
beforeEach(() => {
// initialize new spies
dispatch = jest.fn();
getState = jest.fn();
api.register.mockClear();
api.register.mockResolvedValue(result);
arg = { email: '[email protected]', password: 'yeetmageet123' };
result = { accessToken: 'access token', refreshToken: 'refresh token' };
action = thunks.registerNewAccount(arg);
});
// Test that our thunk is calling the API using the arguments we expect
it('calls the api correctly', async () => {
await action(dispatch, getState, undefined);
expect(api.register).toHaveBeenCalledWith(arg);
});
// Confirm that a success dispatches an action that we anticipate
it('triggers auth success', async () => {
const call = actions.authSuccess(result);
await action(dispatch, getState, undefined);
expect(dispatch).toHaveBeenCalledWith(call);
});
});
describe('refreshSession', () => {
// We're going to be using the same argument, so we're defining it here
// The 3 types are <What's Returned, Argument, Thunk Config>
let action: AsyncThunkAction<void, unknown, {}>;
let dispatch: Dispatch; // Create the "spy" properties
let getState: () => unknown;
let result: IAuthSuccess;
let existingToken: string;
beforeEach(() => {
// initialize new spies
dispatch = jest.fn();
getState = jest.fn();
existingToken = 'access-token-1';
hooks.useRefreshToken.mockReturnValue(existingToken);
api.refreshToken.mockClear();
api.refreshToken.mockResolvedValue(result);
result = { accessToken: 'access token', refreshToken: 'refresh token 2' };
action = thunks.refreshSession();
});
it('does not call the api if the access token is falsy', async () => {
hooks.useRefreshToken.mockReturnValue(undefined);
await action(dispatch, getState, undefined);
expect(api.refreshToken).not.toHaveBeenCalled();
});
it('uses a hook to access the token', async () => {
await action(dispatch, getState, undefined);
expect(hooks.useRefreshToken).toHaveBeenCalled();
});
// Test that our thunk is calling the API using the arguments we expect
it('calls the api correctly', async () => {
await action(dispatch, getState, undefined);
expect(api.refreshToken).toHaveBeenCalledWith(existingToken);
});
// Confirm a successful action that we anticipate has been dispatched too
it('triggers auth success', async () => {
const call = actions.tokenRefreshed(result);
await action(dispatch, getState, undefined);
expect(dispatch).toHaveBeenCalledWith(call);
});
});
});
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With