Blog post
Unit Testing Redux with Redux Sagas
Unit Testing Redux with Redux Sagas
Uros Radosavljevic
2021-07-27
In this article, we will be testing a simple React todo application that uses Redux to manage its state.
We will be testing an application that fetches a list of todos on page load, displays them, and can add todo in the list on the button click:
1// App.jsx
2
3const App = ({loading,todos,addTodo,fetchTodos}) => {
4 const [inputValue, setInputValue] = useState("");
5
6 const onAdd = () => {
7 if(inputValue !== ""){
8 addTodo(inputValue)
9 setInputValue("")
10 }
11 }
12
13 useEffect(() => {
14 fetchTodos()
15 },[fetchTodos])
16
17 return (
18 <div className="container mx-auto mt-10">
19 <div className="bg-gray-100 rounded-xl p-8 w-1/2 mx-auto">
20 <h2 className="text-3xl p-2">Todo list</h2>
21
22 <input
23 value={inputValue}
24 onChange={(e) => setInputValue(e.target.value)}
25 className="input"
26 type="text"
27 />
28
29 <button className="btn" onClick={onAdd}>
30 Add task
31 </button>
32
33 <hr className="py-2 my-2" />
34
35 {loading ? "loading.." : todos?.length > 0 && sortTodos(todos).map((t) => (
36 <TodoItem key={t.id} data={t} />
37 ))}
38 </div>
39 </div>
40 );
41};
To fetch our todos, we dispatch fetchTodos
action with FETCH_TODOS
type, which gets caught by saga middleware to make requests to API whenever that action is dispatched:
1// src/redux/sagas/index.js
2
3function* fetchData() {
4 yield takeEvery("FETCH_TODOS", makeFetchTodosRequest);
5}
6
7function* makeFetchTodosRequest() {
8 try {
9 // dispatch loading action
10 yield put(showLoading());
11 // call api
12 const { data } = yield call(axios.get, routes.GET_TODOS);
13 // return only first 5 todos from response
14 const todos = data.slice(0, 5);
15 // dispatch todos action
16 yield put(todosFetched(todos));
17 } catch (error) {
18 console.error(error);
19 }
20}
Users can mark todo as complete or remove it from the list by dispatching toggleTodo
or removeTodo
actions by clicking on the buttons:
1// src/components/TodoItem.jsx
2
3 const TodoItem = ({ data, toggleTodo, removeTodo }) => {
4 const { id, title, completed } = data
5
6 return (
7 <div className="todo-list-item" key={id}>
8 {completed ? (
9 <span className="flex justify-center w-8">✓</span>
10 ) : (
11 <span className="w-8" />
12 )}
13
14 <span
15 className={`item-text ${completed ? "done" : ""}`}
16 onClick={() => toggleTodo(id)}
17 >
18 {title}
19 </span>
20
21 <button className="btn ml-auto" onClick={() => removeTodo(id)}>
22 x
23 </button>
24 </div>
25 )
26}
Every action dispatches type and corresponding data:
1// src/redux/actions/index.js
2
3export const addTodo = (title) => ({
4 type: "ADD_TODO",
5 data: { title },
6});
7
8export const toggleTodo = (id) => ({
9 type: "TOGGLE_TODO",
10 data: { id },
11});
12
13export const removeTodo = (id) => ({
14 type: "REMOVE_TODO",
15 data: { id },
16});
17
18export const fetchTodos = () => ({
19 type: "FETCH_TODOS",
20});
21
22export const todosFetched = (todos) => ({
23 type: "TODOS_FETCHED",
24 data: { todos },
25});
26
27export const showLoading = () => ({
28 type: "SHOW_LOADING",
29});
All non-saga actions get handled by our todoReducer
:
1// src/redux/reducers/index.js
2
3const todoReducer = (state = initState, action) => {
4 switch (action.type) {
5 case "TODOS_FETCHED":
6 return {
7 ...state,
8 todos: [...state.todos, ...action.data.todos],
9 loading: false,
10 };
11
12 case "ADD_TODO":
13 return {
14 ...state,
15 todos: [
16 ...state.todos,
17 {
18 id: Math.max(...state.todos.map((i) => i.id)) + 1,
19 title: action.data.title,
20 completed: false,
21 },
22 ],
23 };
24
25 case "TOGGLE_TODO":
26 return {
27 ...state,
28 todos: state.todos.map((item) => {
29 if (item.id === action.data.id) {
30 return {
31 ...item,
32 completed: !item.completed,
33 };
34 }
35 return item;
36 }),
37 };
38
39 case "SHOW_LOADING":
40 return {
41 ...state,
42 loading: true,
43 };
44
45 case "REMOVE_TODO":
46 return {
47 ...state,
48 todos: state.todos.filter((i) => action.data.id !== i.id),
49 };
50
51 default:
52 return state;
53 }
54};
Looks good? What are the next steps, how to test it? Every React application created with create-react-app
script comes with configured jest, but if you need help with jest setup and configuration, refer to our Unit Testing post. By using redux sagas, we made our action creators pretty simple. Let’s first test them, as it would be easy as testing any other javascript function.
Everything we test within action creators is its returned data and dispatch type. In addition, we use faker to generate random data and cover more use cases.
1// src/redux/actions/index.test.js
2
3it("should test removeTodo action", () => {
4 const id = faker.datatype.number();
5 const actionReturnValue = removeTodo(id);
6
7 expect(actionReturnValue.type).toEqual("REMOVE_TODO");
8 expect(actionReturnValue.data.id).toEqual(id);
9});
10
11it("should test fetchTodos action", () => {
12 const actionReturnValue = fetchTodos();
13
14 expect(actionReturnValue.type).toEqual("FETCH_TODOS");
15});
16
17it("should test todosFetched action", () => {
18 const todos = [];
19 // create random todos
20 Array.from(Array(faker.datatype.number(32)).keys()).forEach((idx) => {
21 todos.push({
22 id: idx,
23 title: faker.lorem.sentence(),
24 completed: faker.datatype.boolean(),
25 });
26 });
27
28 const actionReturnValue = todosFetched(todos);
29
30 expect(actionReturnValue.type).toEqual("TODOS_FETCHED");
31 expect(actionReturnValue.data.todos).toEqual(todos);
32});
After running npm run test
script, we should get results that look like this.
Our reducer is quite simple too, let’s continue with testing its functionality.
When testing the reducer, the first thing that is important to test is when the user passes an action type that is not covered by the reducer or doesn’t pass the action type.
1// src/redux/reducers/index.test.js
2
3it("should return initial state", () => {
4 const initState = {
5 loading: false,
6 todos: [],
7 };
8
9 expect(reducer(undefined, {})).toEqual(initState);
10});
In our application, we are dispatching showLoading
action throughout the saga put
function before every API call. It is used for setting the loading state. Every time it gets dispatched, it should use SHOW_LOADING
type and set the loading state to true. Let’s test it!
1// src/redux/reducers/index.test.js
2
3it("should handle SHOW_LOADING", () => {
4 const type = "SHOW_LOADING";
5
6 const action = { type };
7
8 const reducerReturnValue = reducer(initState, action);
9
10 expect(reducerReturnValue.loading).toEqual(true);
11});
If the API call gets successfully resolved, the saga will dispatch todosFetched
action with TODOS_FETCHED
type. For the initial state, we will use store with loading value set to true, and empty todos array, as that would be the case in the application.
To write a meaningful test for it, we will create some random todos and pass them to the reducer with TODOS_FETCHED
type. When the reducer returns a new store value, we expect loading to be set to false and to contain passed todos.
1// src/redux/reducers/index.test.js
2
3it("should handle TODOS_FETCHED", () => {
4 const type = "TODOS_FETCHED";
5 const initState = {
6 loading: true,
7 todos: []
8 };
9
10 // create random todos
11 const todos = [];
12 Array.from(Array(faker.datatype.number(32)).keys()).forEach((idx) => {
13 todos.push({
14 id: idx,
15 title: faker.lorem.sentence(),
16 completed: faker.datatype.boolean()
17 });
18 });
19
20 const actionData = {
21 todos
22 };
23
24 const action = {
25 type,
26 data: actionData
27 };
28
29 const reducerReturnValue = reducer(initState, action);
30
31 expect(reducerReturnValue.todos).toEqual(todos);
32 expect(reducerReturnValue.loading).toEqual(false);
33});
For testing other use cases in the reducer, the approach will be pretty similar. First, we pass expected data according to type, and after that, we check if the reducer will return the store object as expected.
We will not be going thru other reducer cases, but you can always check them out on Montecha examples github repo (https://github.com/montecha/examples/tree/main/redux-testing ).
After testing action creators and reducer, since the src/redux/store/index.js
file mostly comes down to configuring middlewares and creating a store, we will not need to test it with testing sagas.
1// src/redux/store/index.js
2
3const sagaMiddleware = createSagaMiddleware();
4
5const configureStore = (preloadedState) => {
6 const composeEnhancers =
7 window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
8
9 const store = createStore(
10 reducers,
11 preloadedState,
12 composeEnhancers(applyMiddleware(sagaMiddleware))
13 );
14
15 sagaMiddleware.run(todosSaga);
16
17 return store;
18};
19
20const store = configureStore();
21
22export default store;
1// src/redux/sagas/todos.js
2
3function* makeFetchTodosRequest() {
4 try {
5 // dispatch loading action
6 yield put(showLoading());
7 // call api
8 const { data } = yield call(axios.get, routes.GET_TODOS);
9 // return only first 5 todos from response
10 const todos = data.slice(0, 5);
11 // dispatch todos action
12 yield put(todosFetched(todos));
13 } catch (error) {
14 console.error(error);
15 }
16}
Since our saga fetches data from an external API with axios
module, let’s mock the whole axios
module and its get
function by returning fake todos data with the magic of mockResolvedValue
function.
1// src/redux/sagas/todos.test.js
2
3jest.mock("axios");
4
5describe("Sagas", () => {
6 it("should test makeFetchTodosRequest saga", async () => {
7 const randomTodos = createRandomTodos();
8
9 axios.get.mockResolvedValue({ data: randomTodos });
10
11...
To test the saga, we will use the runSaga
function from the redux-saga
module. The first argument of this function should be an object with options on how to run a saga. In this object, we will be passing dispatch
callback that stores all dispatched actions in the array, and finally, the saga that needs to be tested as a second argument.
1 const dispatched = [];
2 await runSaga(
3 {
4 dispatch: (action) => dispatched.push(action),
5 },
6 makeFetchTodosRequest
7 ).toPromise();
As we mentioned earlier, our saga dispatches two actions. First, it dispatches action that changes loading state, which we expect to be dispatched with SHOW_LOADING
value as soon as saga init occurs.
1expect(dispatched[0].type).toEqual("SHOW_LOADING");
When the API call is successfully resolved, TODOS_FETCHED
action should be dispatched with todos
returned from API mock.
1 expect(dispatched[1].type).toEqual("TODOS_FETCHED");
2 expect(dispatched[1].data.todos).toEqual(randomTodos);
The whole test should look like this:
1// src/redux/sagas/todos.test.js
2
3it("should test makeFetchTodosRequest saga", async () => {
4 const randomTodos = createRandomTodos();
5
6 axios.get.mockResolvedValue({ data: randomTodos });
7
8 const dispatched = [];
9 await runSaga(
10 {
11 dispatch: (action) => dispatched.push(action),
12 },
13 makeFetchTodosRequest
14 ).toPromise();
15
16 expect(dispatched[0].type).toEqual("SHOW_LOADING");
17
18 expect(dispatched[1].type).toEqual("TODOS_FETCHED");
19 expect(dispatched[1].data.todos).toEqual(randomTodos);
20});
Taking another look at our test console shows that tests from sagas, actions, and reducers are passing.
In this short walkthru, we covered most of the application redux code with tests, but our application still misses components tests, and those can be tested with numerous different approaches. If you want to look at the code or suggest changes, please leave comments below this article, or make a pull request on Github (examples/redux-testing at main · montecha/examples).
Uros Radosavljevic
2021-07-27
Uros is experienced JavaScript engineer, passionate about React, Redux, MobX, Jest and Cypress. He has a proven knowledge of working in Agile teams and deliver products in marketing, hiring and healthcare
Leave your thought here
Your email address will not be published. Required fields are marked *