In this post I’ll try to show you how convenient it is to test side effects using redux saga. To read this post you should know something about generators and redux-saga library. Here are some great articles about this topic:
To test my code I use mocha and chai libraries. I will use my Api
module which exports functions for data fetching (using promises and callbacks, the implementation is not important). In sagasall side effects should be created using helper functions from redux-saga/effects
package. I will omit importing all those modules in my examples, this is how imports look like:
1 2 3 4 5 6 |
const { assert } = require('chai') // assertion library const { Api, CallbackApi } = require('../Api') // promise and callback api const { takeEvery, takeLatest } = require('redux-saga') // concurrency helper functions const { call, take, fork, cancel } = require('redux-saga/effects') // side effects helper functions const { createMockTask } = require('redux-saga/utils') // utility to create Task object |
Let’s start with simple saga which fetches articles from the server using promise API:
1 2 3 4 5 6 7 8 |
function* fetchArticle(action) { try { const article = yield call(Api.fetchArticle, action.payload.articleId) yield put({type: 'FETCH_ARTICLE_SUCCEEDED', article}) } catch (error) { yield put({type: 'FETCH_ARTICLE_FAILED', error}) } } |
To test this code you need to create generator and iterate it manually. Value of each iteration represents side effect itself and you can easily assert it.The result of asynchronous fetch
action can be passed as an argument of next
method. There is no need for mocking. Convenient, isn’t it?:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
it('should fetch article', () => { const action = { payload: { articleId: 1 } } const generator = fetchArticle(action) assert.deepEqual( generator.next().value, call(Api.fetchArticle, action.payload.articleId), 'waiting for fetched article' ) const article = { name: 'first article', id: 1 } assert.deepEqual( generator.next(article).value, put({ type: 'FETCH_ARTICLE_SUCCEEDED', article }), 'putting success action' ) }) |
In case of error, the saga above ends in catch
block and calls FETCH_ARTICLE_FAILED
action. To simulate error in test we can use throw
method of generator. The final test looks like this:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
it('should fail to fetch article', () => { const action = { payload: { articleId: 1 } } const generator = fetchArticle(action) assert.deepEqual( generator.next().value, call(Api.fetchArticle, action.payload.articleId), 'waiting for fetch response' ) const error = new Error('unexpected network error') assert.deepEqual( generator.throw(error).value, put({ type: 'FETCH_ARTICLE_FAILED', error }), 'putting failure action' ) }) |
If you have to use error first callback functions instead of promises, redux saga provides helper function for it – cps
. It can be simply used instead of call
function. Let’s suppose I have callback API for adding comments. This is how my saga looks like:
1 2 3 4 5 6 7 |
function* createComment(action) { try { yield cps(CallbackApi.createComment, action.payload.comment) } catch (error) { yield put({type: 'CREATE_COMMENT_FAILED', error}) } } |
Testing of callback API it is as simple as testing promises (and any other side effects):
1 2 3 4 5 6 7 8 9 10 |
it('should create comment', () => { const comment = { user: 1, article: 123, text: 'Hello world!' } const action = { payload: { comment } } const generator = createComment(action) assert.deepEqual( generator.next().value, cps(CallbackApi.createComment, action.payload.comment), 'waiting for callback response' ) }) |
One of the features of the saga is that you can run multiple tasks in parallel and wait for the final result. For example here is saga which preloads articles with given ids. Fetching of articles is done concurrently:
1 2 3 4 5 6 7 8 9 |
function* preloadArticles(action) { try { const ids = action.payload.articleIds const articles = yield ids.map(id => call(Api.fetchArticle, id)) yield put({ type: 'PRELOAD_ARTICLE_SUCCEEDED', articles }) } catch (error) { yield put({type: 'PRELOAD_ARTICLE_FAILED', error}) } } |
It is not a big deal to test code like this in sagas. Parallel calls can be asserted the same way it has been called – in array:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
it('should preload articles in parallel', () => { const articleIds = [ 1, 3 ] const action = { payload: { articleIds } } const generator = preloadArticles(action) assert.deepEqual( generator.next().value, [ call(Api.fetchArticle, 1), call(Api.fetchArticle, 3), ], 'waiting for fetched articles' ) const articles = [ { name: 'first article', id: 1 }, { name: 'second article', id: 3 }, ] assert.deepEqual( generator.next(articles).value, put({ type: 'PRELOAD_ARTICLE_SUCCEEDED', articles }), 'putting success action' ) }) |
In certain cases you need to test if task has been cancelled. Consider login/logout action. When login is in progress and the user clicks logout button, login should be cancelled. Saga provides mechanism to cancel effects using cancel
helper function:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
function* authorize(user, password) { // saga handling user authorization // ... } function* clearSession() { // saga clearing session data // ... } function* loginFlow() { while (true) { const { user, password } = yield take('LOGIN_REQUEST') // non-blocking call const task = yield fork(authorize, user, password) const action = yield take([ 'LOGOUT', 'LOGIN_ERROR' ]) if (action.type === 'LOGOUT') yield cancel(task) yield call(clearSession) } } |
To test task cancellation it is the same as with other side effects. The only thing you need is to create Task
object represinting the task which is returned from non-blocking fork
effect. There is helper function for it – createMockTask
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
it('should cancel login task', () => { const generator = loginFlow() assert.deepEqual( generator.next().value, take('LOGIN_REQUEST'), 'waiting for login request' ) const credentials = { name: 'kitty', password: 'secret' } assert.deepEqual( generator.next(credentials).value, fork(authorize, credentials.user, credentials.password), 'authorizing user' ) const task = createMockTask() assert.deepEqual( generator.next(task).value, take([ 'LOGOUT', 'LOGIN_ERROR' ]), 'waiting for logout or login error' ) const action = { type: 'LOGOUT' } assert.deepEqual( generator.next(action).value, cancel(task), 'cancelling login' ) assert.deepEqual( generator.next().value, call(clearSession), 'clearing session' ) }) |
To put it all together you can create root saga which watches for incomming actions and starts login flow:
1 2 3 4 5 6 |
function* rootSaga() { yield fork(takeLatest, 'FETCH_ARTICLE', fetchArticle) yield fork(takeEvery, 'CREATE_COMMENT', createComment) yield fork(takeEvery, 'PRELOAD_ARTICLES', preloadArticles) yield fork(loginFlow) } |
The test is pretty strightforward:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
it('sould create root saga', () => { const generator = rootSaga() assert.deepEqual( generator.next().value, fork(takeLatest, 'FETCH_ARTICLE', fetchArticle), 'fetch the latest requested article' ) assert.deepEqual( generator.next().value, fork(takeEvery, 'CREATE_COMMENT', createComment), 'create comment for every request' ) assert.deepEqual( generator.next().value, fork(takeEvery, 'PRELOAD_ARTICLES', preloadArticles), 'preload requested articles' ) assert.deepEqual( generator.next().value, fork(loginFlow), 'initialize login flow' ) }) |
Redux saga adds structure to your code and separate side effect logic from the rest of the application. One of the greatest benefit is the code testability. You don’t need any complex mocking and injecting dependencies everywhere. The tests of side effect are simple, easy to write and read.