Redux Saga is a middleware library that lets the Redux store interact with resources outside of itself asynchronously. It also handles asynchronous logic in applications. For example, Ajax calls, logging, accessing browsers local storage. It is also responsible for executing the effects that are yielded from a saga. While updating the store, most of the logic is handled by the saga and it adds great value to the application. It is also responsible for performing tasks like making HTTP requests to external services, accessing browser storage, and executing all the I/O operations. These operations, usually known as side effects, become a lot easier to organize and manage using a saga. But, to get the most out of the Redux-Saga, the saga must be thoroughly tested before it is run in your application.
In this blog, we will take you through the efficient test cases for Redux-Saga.
What Can Sagas do?
A saga is a generator function. When a promise is run and yielded, the middleware suspends the saga until the promise is resolved. Once the promise is resolved the middleware resumes the saga, until the next yield statement is found. It is suspended again until its promise resolves. Inside the saga code, you can generate effects using a few special helper functions provided by the redux-saga package.
Testing Your Sagas, the Right Way
Sagas are generator functions. They are special functions that generate a series of values. So, that usual mocking techniques won't be of any help. The step-by-step testing of Sagas is possible but it is a time-consuming and repetitive process.
Thus, the native approach for testing a saga is to call the generator function. This will return the iterator object. Now call the next method for each yield and assert each value.
Let's see example of testing redux-saga using iterator methods:
export function* fetchEmployeesList(action) {
yield put({ type: "SHOW_LOADER_START", payload: null });
const response = yield call(() => {
return Employee.getEmployees(action.filter);
});
yield put({
payload: { message: error, groupName: action.groupName },
type: "SET_EMPLOYEE_LIST",
});
yield put({ type: "SHOW_LOADER_END", payload: null });
}
it("test redux-saga using iterator methods", async() => {
const fetchEmployeesListSaga = fetchEmployeesList();
const putEffect = fetchEmployeesListSaga.next();
const callEffect = putEffect.next();
expect(callEffect.value).toEqual({mockedEmployeeResponse});
//mockedEmployeeResponse is placeholder object, mentioned just for example.
});
The first call to next method, will execute first yield.
The next call to next method, will execute second yield, in this case will call to getEmployees API.
We can assert the return value of response.
If you want to test without using a library then we need to assert each yield with the right return value into the yield statement. We also need to make sure they are called in the exact order in which they were first implemented.
To bypass this challenge, it's good to choose a third-party library. Pick “redux-saga-test plan" and you can effectively test different scenarios in redux-saga.
Some of the popular libraries to test the redux-saga:
- redux-saga-tester
- redux-saga-test
- redux-saga-testing
- redux-saga-test-plan
- redux-saga-test-engine
let's see some different cases to test the sagas
Simple API saga with action params
Look at the example given below. A Saga function fetches employees lists and updates the reducer with the list.
export function* fetchEmployeesList(action) {
yield put({ type: "SHOW_LOADER_START", payload: null });
const response = yield call(() => {
return Employee.getEmployees(action.filter);
});
yield put({
payload: { message: error, groupName: action.groupName },
type: "SET_EMPLOYEE_LIST",
});
yield put({ type: "SHOW_LOADER_END", payload: null });
}
const actionObj = {
filter: {}
}
expectSaga(fetchEmployeesList, actionObj).run();
The expect Saga function takes saga as the first parameter, any additional arguments to expectSaga will become arguments to the saga function. The return value is a chainable API with assertions for the different effect creators available in Redux-Saga.
If needed, you can mock the API response and pass it as a payload when action to the reducer is dispatched.
Employee = {
getEmployees: jest
.fn()
.mockImplementation(() => actualResponse)
.mockImplementationOnce(() => response),
}
The result is chainable to return different responses if the API is called again.
Testing state selector Saga
Consider the below example. Here, we have prepared a custom query object based upon the state values. Component dispatch action with minimum params. Saga checks the redux state and prepares the query object and passes it to the API call.
import { call, put, select } from "redux-saga/effects";
export function* fetchEmployeesList(action) {
....
const queryObj = yield select(getQueryObj, action.intialParams, "Employees");
const response = yield call(() => {
return Employee.getEmployees(queryObj);
});
The method to prepare query object looks like this:
export const getQueryObj = (state, params, moduleName): IGetQueryParams => {
const queryObj: IGetQueryParams = {
params
};
const modelState = state[moduleName];
if( modelState.sort){
queryObj.sort = modelState.sort
}
if(modelState.search){
queryObj.search = modelState.search
}
if(modelState.filter){
queryObj.filter = modelState.filter
}
...
The state selector can be an external/internal method. All that is needed is to mock the selector method to the fake response object. Here is how you do this:
const stateQueryResponse = {
Employees: {
search: "inno",
filterQuery: [{ filterName: "test", filterValue: "test1", condition: "in" }],
sortQuery: "test",
},
};
it("should fetch employees with query params", async () => {
return expectSaga(fetchEmployeesList)
.provide([[select(), stateQueryResponse]])
.....
});
jest.mock("../stateSelector", () => ({
getQueryObj: jest.fn().mockImplementation(() => stateQueryResponse),
}));
The provided method takes an array of matcher-value pairs. Each matcher-value pair is an array with an effect to match and a fake value to return.
Unit testing Sagas
If you want to ensure that your saga yields specific types of effects in a particular order, then you can use the test saga function. Here you need to take care of the order.
Any change in the order will fail the test case.
Let us see the below example. Here saga's job is to set the format and language code.
/**
* Saga to select the appropriate language locale.
*/
export function* userSettingFn() {
const [languageList, acceptHeaderLang] = yield all([call(getLanguage), call(getBrowserLanguage)]);
yield call(getFormattedDate, userSettingData);
yield put({ type: UserSettingActions.UM_USER_SETTING_RESPONSE, payload: userSettingData });
}
it("test setting format and localte", async () => {
const saga = testSaga(userSettingFn);
saga
.next()
.all([call(getLanguage), call(getBrowserLanguage)])
.save(userSettingData.applyLanguageCode)
.call(getFormattedDate, userSettingData)
.save("en-us")
.next()
.put({ type: UserSettingActions.UM_USER_SETTING_RESPONSE, payload: { applyLanguageCode: "en-US" } })
.next()
.finish();
});
Testing dispatch effects
For the above example let us test the effects.
export function* getAllEmployees() {
yield takeLatest("GET_ALL_EMPLOYEES", fetchEmployeesList);
}
Here when the "GET_ALL_EMPLOYEES" action dispatches, redux-saga forks fetchEmployeesList.
Fetch the required parameters from the payload of the action. We can test these effects using the dispatch method.
it("should fetch employees with query params", () => {
return expectSaga(fetchEmployeesList)
.provide([[select(), stateQueryResponse]])
.dispatch({type: "GET_ALL_EMPLOYEES"})
.silentRun();
});
Key Points:
- Use silent run to suppress warnings.
- Use Jest to assert return values.
Summary:
The separation between the description of an effect and the execution of that effect is incredibly valuable for testing redux-saga.
There are two approaches to testing sagas It is recommended to have integration testing rather than step-by-step testing. We can test any complex business logic in saga through integration testing. We can assert any API call or the state of the reducer at any point or final state.