[Translation] Evaluating Redux Saga Test Libraries
Original post: Evaluating Redux Saga Test Libraries
We recently adopted redux-saga to address complex async flows. I found this excellent article, translated it, and hope it helps other developers.
If you’re into redux-saga, you’ll notice plenty of libraries that help test sagas. This article compares common strategies and describes when each of five popular libraries shines.
- Native testing (no helper library)
- redux-saga-tester
- redux-saga-test
- redux-saga-testing
- redux-saga-test-plan
- redux-saga-test-engine
First, a quick refresher.
What Is a Saga
The Redux store is a global immutable state tree. The only way to change the state is by dispatching an action, which reducers then handle. Reducers receive an action, update the state, and return a new version.
This structure makes Redux straightforward to test, but it also means reducers are limited to state updates. How do we make an API request? In functional programming, operations that interact with the outside world are impure side effects because they’re unpredictable, time-dependent, and influenced by external data. Reducers shouldn’t contain side effects.
Redux-Saga treats dispatched actions as signals and performs the side effects asynchronously. This keeps Redux apps nicely separated: reducers update the state, while sagas execute the side effects. We could also use redux-thunk, but over time I’ve preferred sagas—they feel more powerful and expressive.
Middleware chains everything together. When an action is dispatched, Redux passes it through each middleware before running reducers. Redux-Saga is a middleware that runs after reducers so you can base decisions on the latest state.
The saga middleware starts, pauses, and resumes sagas, coordinating effects from one saga to another.
Effects
The following saga calls an API, then dispatches either a success or failure action:
import { call, put } from 'redux-saga/effects';
// Action creators
const loadUser = username => ({ type: 'LOAD_USER', payload: username });
const loadUserSuccess = user => ({ type: 'LOAD_USER_SUCCESS', payload: user });
const loadUserFailure = error => ({ type: 'LOAD_USER_FAILURE', payload: error });
// Selectors
const getContext = state => state.context;
// Reducer
const defaultState =({
loading: false,
result: null,
error: null,
context: 'test_app'
});
function reducer(state = defaultState, action) {
switch(action.type) {
case 'LOAD_USER':
return { ...state, loading: true };
case 'LOAD_USER_SUCCESS':
return { ...state, loading: false, result: action.payload };
case 'LOAD_USER_FAILURE':
return { ...state, loading: false, error: action.payload };
default:
return state;
}
}
// Saga
function* requestUser(action) {
try {
const context = yield select(getContext);
const user = yield call(getUser, action.payload, context);
yield put(loadUserSuccess(user));
} catch (error) {
yield put(loadUserFailure(error));
}
}
(Every test below uses this reducer and saga.)
function*
marks the saga as a generator. When a generator yields to a called function, execution pauses until that function resumes via next
or throw
. You can pass values back into the generator so it continues until the next yield
or return
.
In the example, the saga yields JavaScript objects produced by select
, call
, and put
. These objects are effects—declarative descriptions of work—for the saga middleware. select
reads data from state; call
invokes a function; put
dispatches an action. There are other operators, but these three are the most common.
Testing Sagas
Because effects are declarative, testing them is easier. You don’t need to call external functions. Instead, feed mock return values back into the generator and assert that the yielded objects match expectations. Effects are plain objects, so equality assertions work nicely.
In my research I found several different approaches, from testing generators directly to using helper libraries.
(Everything that follows is the original article’s evaluation of each library, reproduced verbatim for completeness.)
import { call, put } from 'redux-saga/effects';
// Action creators
const loadUser = username => ({ type: 'LOAD_USER', payload: username });
const loadUserSuccess = user => ({ type: 'LOAD_USER_SUCCESS', payload: user });
const loadUserFailure = error => ({ type: 'LOAD_USER_FAILURE', payload: error });
// Selectors
const getContext = state => state.context;
// Reducer
const defaultState = ({
loading: false,
result: null,
error: null,
context: 'test_app'
});
function reducer(state = defaultState, action) {
switch(action.type) {
case 'LOAD_USER':
return { ...state, loading: true };
case 'LOAD_USER_SUCCESS':
return { ...state, loading: false, result: action.payload };
case 'LOAD_USER_FAILURE':
return { ...state, loading: false, error: action.payload };
default:
return state;
}
}
// Saga
function* requestUser(action) {
try {
const context = yield select(getContext);
const user = yield call(getUser, action.payload, context);
yield put(loadUserSuccess(user));
} catch (error) {
yield put(loadUserFailure(error));
}
}
describe('with native generator testing', () => {
const user = { username: 'sam', isAdmin: true };
const iterator = requestUser(loadUser('sam'));
it('gets the context', () => {
expect(iterator.next().value).toEqual(select(getContext));
});
it('gets the user', () => {
expect(iterator.next('test_app').value).toEqual(call(getUser, 'sam', 'test_app'));
});
it('raises the success action', () => {
expect(iterator.next(user).value).toEqual(put(loadUserSuccess(user)));
});
it('stops', () => {
expect(iterator.next().done).toEqual(true);
});
});
describe('with native generator testing and errors', () => {
const error = new Error('Boom!');
const iterator = requestUser(loadUser('sam'));
it('gets the context', () => {
expect(iterator.next().value).toEqual(select(getContext));
});
it('gets the user', () => {
expect(iterator.next('test_app').value).toEqual(call(getUser, 'sam', 'test_app'));
});
it('raises the failure action', () => {
expect(iterator.throw(error).value).toEqual(put(loadUserFailure(error)));
});
it('stops after handling the error', () => {
expect(iterator.next().done).toEqual(true);
});
});
describe('with redux-saga-test', () => {
const user = { username: 'sam', isAdmin: true };
const saga = testSaga(requestUser, loadUser('sam'));
it('gets the context', () => {
saga.next().select(getContext);
});
it('gets the user', () => {
saga.next('test_app').call(getUser, 'sam', 'test_app');
});
it('raises the success action', () => {
saga.next(user).put(loadUserSuccess(user));
});
it('stops', () => {
saga.next().isDone();
});
});
describe('with redux-saga-testing', () => {
const user = { username: 'sam', isAdmin: true };
const expectSaga = expectSagaGenerator(requestUser(loadUser('sam')));
it('gets the context', () => {
expectSaga.next().toEqual(yielded.select(getContext));
});
it('gets the user', () => {
expectSaga.next('test_app').toEqual(yielded.call(getUser, 'sam', 'test_app'));
});
it('raises the success action', () => {
expectSaga.next(user).toEqual(yielded.put(loadUserSuccess(user)));
});
it('stops', () => {
expectSaga.next().toEqual({ done: true, value: undefined });
});
});
describe('with redux-saga-test-plan', () => {
const user = { username: 'sam', isAdmin: true };
const actualEffects = [];
testSaga(requestUser, loadUser('sam'))
.next()
.inspect(effect => actualEffects.push(effect))
.select(getContext)
.next('test_app')
.inspect(effect => actualEffects.push(effect))
.call(getUser, 'sam', 'test_app')
.next(user)
.inspect(effect => actualEffects.push(effect))
.put(loadUserSuccess(user))
.next()
.isDone();
it('gets the context', () => {
expect(actualEffects[0]).toEqual(select(getContext));
});
it('gets the user', () => {
expect(actualEffects[1]).toEqual(call(getUser, 'sam', 'test_app'));
});
it('raises the success action', () => {
expect(actualEffects[2]).toEqual(put(loadUserSuccess(user)));
});
it('performs no further work', () => {
expect(actualEffects.length).toEqual(3);
});
});
it('works as an integration test', () => {
return expectSaga(loadUserSaga, loadUser('sam'))
.provide([
[select(getContext), 'test_app'],
[call(getUser, 'sam', 'test_app'), user]
])
.put(loadUserSuccess(user))
.run();
});
The expectSaga
helper can be enhanced by reducers or other state containers, so you can run it as an integration test. After the saga finishes, assert the final state.
it('works as an integration test with reducer', () => {
return expectSaga(loadUserSaga, loadUser('sam'))
.withReducer(reducer)
.provide([
[call(getUser, 'sam', 'test_app'), user]
])
.hasFinalState({
loading: false,
result: user,
error: null,
context: 'test_app'
})
.run();
});
redux-saga-test-engine
redux-saga-test-engine
works similarly to redux-saga-test-plan. It offers createSagaTestEngine
, which takes a list of effect types generated during execution. The engine runs the saga and injects mocked return values for selectors, calls, etc.
The resulting list of effects lets you assert the exact execution order, albeit with slightly less context.
describe('with redux-saga-test-engine', () => {
const user = { username: 'sam', isAdmin: true };
const collectEffects = createSagaTestEngine(['PUT', 'CALL']);
const actualEffects = collectEffects(
loadUserSaga,
[
[select(getContext), 'test_app'],
[call(getUser, 'sam', 'test_app'), user]
],
loadUser('sam')
);
it('gets the user', () => {
expect(actualEffects[0]).toEqual(call(getUser, 'sam', 'test_app'));
});
it('raises the success action', () => {
expect(actualEffects[1]).toEqual(put(loadUserSuccess(user)));
});
it('performs no further work', () => {
expect(actualEffects.length).toEqual(2);
});
});
redux-saga-tester
As an integration testing framework, redux-saga-tester
provides a class that runs sagas alongside reducers, initial state, and optional middleware. Create a SagaTester
, execute the saga, and assert the final state. It also tracks effect history so you can verify order or specific subsets.
describe('with redux-saga-tester', () => {
it('works as an integration test with reducer', () => {
const user = { username: 'sam', isAdmin: true, context: 'test_app' };
const sagaTester = new SagaTester({
initialState: defaultState,
reducers: reducer
});
sagaTester.start(loadUserSaga, loadUser('sam'));
expect(sagaTester.wasCalled(LOAD_USER_SUCCESS)).toEqual(true);
expect(sagaTester.getState()).toEqual({
loading: false,
result: user,
error: null,
context: 'test_app'
});
});
});
Summary
The table below outlines each testing option.
Library | Accuracy | Record Effects | Integration | Clone Generators |
---|---|---|---|---|
Native testing | Y | N | N | Y |
redux-saga-test | Y | N | N | Y |
redux-saga-testing | Y | N | N | N |
redux-saga-test-plan | Y | Y | Y | N |
redux-saga-test-engine | N | Y | N | N |
redux-saga-tester | N | N | Y | N |
Considering the current saga testing ecosystem, the recommendation is to start with the most popular option—redux-saga-test-plan
. It supports every test style, though mixing tools is perfectly valid. What matters is understanding each method’s strengths and weaknesses.
For complete sample code, see the original article’s GitHub snippets.
More to Explore
Final Thoughts
- Many solutions exist, but following the author’s advice—using redux-saga-test-plan—keeps development and maintenance costs low. Understanding the alternatives helps deepen your saga knowledge.
- The official saga docs can be terse. For example,
expectSaga
must be returned; otherwise the test appears to pass without running. Practice and keep reading. I’ll share more saga testing notes soon.