Easiest way to test asynchronous redux sagas with jest
Use case for saga
Each time a request to get the list of movies is triggered, toggle the application state to loading and fetch the list of movies from server asynchronously. Once the request is completed, toggle the application state to loaded and update the application state with fetched movies.
Let us create our helper function first which will fetch the list of movies. We are using async await feature of ES6 to call our api and return our list of movies wrapped in a promise. Using ES6 async function we can write async code in a much more declarative way. It will wrap the returned object in a promise if you do not explicitly return a promise.
//moviesApi.js
export async function fetchMoviesApi() {
let response = await fetch('http://example.com/path/to/api');
let result = await response.json();
return result;
}
Now let us create our saga.
//movie-saga.js
import { put, call, takeEvery} from 'redux-saga/effects';
import { fetchMoviesApi} from './moviesApi';
export function* fetchMoviesSaga(action) {
yield put({type: 'TOGGLE_LOADING', payload: true});
try {
const movies = yield call(fetchMoviesApi);
yield put({type: 'TOGGLE_LOADING', payload: false});
yield put({type: 'LOAD_INITIAL_MOVIES', payload: movies});
} catch (e) {
yield put({type: 'TOGGLE_LOADING', payload: false});
yield put({type: 'ITEM_HAS_ERRORED', message: e.message});
}
}
function* mySaga() {
yield takeEvery('MOVIE_FETCH_REQUESTED', fetchMoviesSaga);
}
export default mySaga;
Take a moment and match it with the use case we have. It should be self explanatory. Whenever an action with type ‘MOVIE_FETCH_REQUESTED’ is triggered, fetchMoviesSaga is called. It will first emit ‘TOGGLE_LOADING’ action with payload true, then call our api and fetch the list of movies asynchronously. After the async call is successful, it will emit ‘TOGGLE_LOADING’ action with payload false and then emit ‘LOAD_INITIAL_MOVIES’ action which will update the state of the component (not covered here). In case there is an error in the api call it will go into the catch block, then emit ‘TOGGLE_LOADING’ action with payload false and trigger the error action ‘ITEM_HAS_ERRORED’.
Now let us write the test for the saga using jest.
//movie-saga.test.js
import { put, call} from 'redux-saga/effects';
import {fetchMoviesApi} from './moviesApi';
import {fetchMoviesSaga} from './movie-saga';
describe('movies fetching flow', () => { it('Fetches the movies successfully', () => {
const generator = fetchMoviesSaga();
expect(generator.next().value)
.toEqual(put(
{type: 'TOGGLE_LOADING', payload: true}
)); expect(generator.next().value)
.toEqual(call(fetchMoviesApi)); expect(generator.next().value)
.toEqual(put(
{type: 'TOGGLE_LOADING', payload: false}
)); expect(generator.next().value)
.toEqual(put(
{type: 'LOAD_INITIAL_MOVIES', payload: undefined}
));
});
it('Handles exception as expected', () => {
const generator = fetchMoviesSaga();
expect(generator.next().value)
.toEqual(put(
{type: 'TOGGLE_LOADING', payload: true}
)); expect(generator.next().value)
.toEqual(call(fetchMoviesApi)); expect(generator.throw('error').value)
.toEqual(put(
{type: 'TOGGLE_LOADING', payload: false}
)); expect(generator.next().value)
.toEqual(put(
{type: 'ITEM_HAS_ERRORED', message: undefined}
)); });
});
Notice the second expectation of first test expect(generator.next().value)
.toEqual(call(fetchMoviesApi)). We are just checking that the next value of generator is a call to our api. We are not testing the actual results of the call and that is fine because in unit testing we are only concerned that our saga is working as expected and not if our service is returning data correctly. We can write in a separate unit test for that service. That is why our last expectation of first test is that the payload will be undefined :).
Another interesting case is the third expectation of second test. Here we are testing the catch block of our saga. To make our saga throw an exception, we call generator.throw() instead of generator.next(). The value of this throw will be the first yield inside our catch block, which is triggering ‘TOGGLE_LOADING’ action. We are also sending another error action which lets our reducer know that an error has occurred and we can handle it in our state and UI accordingly.
Test results
Congratulations! We just tested our saga with 100% code coverage. Did you notice that we didn’t use any other testing helper libraries or mocks for this? Testing sagas in pure jest.