Blog post

Unit Testing Redux with Redux Sagas

/partners/client-logo-1.svg
/partners/client-logo-2.svg
/partners/client-logo-3.svg
/partners/client-logo-4.svg
/partners/client-logo-5.svg
/partners/client-logo-6.svg

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.

Application context

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.

Action creators testing

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.

Reducers testing

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;

Testing saga

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.

Conclusion

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

See more blogs:

Leave your thought here

Your email address will not be published. Required fields are marked *