Redux Saga is famous for being easy to test but what if it could be even more comfortable. redux-saga-test-plan by Jeremy Fairbank was designed precisely for this purpose.
I'm a software engineer and consultant with Test Double. We believe that software is broken, and we're here to fix it. Our mission is to improve how the world builds software.
I've been doing front-end development for almost ten years now and enjoy the paradigms that React and Redux helped introduce to the front-end world. I've created a few open source projects that work well with the React and Redux ecosystem such as revalidate, redux-saga-router, and, the topic of this interview, redux-saga-test-plan.
I'm a huge fan of functional programming and Elm. In fact, I'm currently writing a book on Elm with The Pragmatic Programmers called Programming Elm: Build Safe and Maintainable Front-End Applications. The book is over halfway complete and should be available sometime in Spring 2018.
redux-saga-test-plan is a library for easily testing redux-saga.
If you're unfamiliar with redux-saga, check out [the redux-saga interview](/blog/redux-saga-interview/) with creator Yassine Elouafi.
redux-saga-test-plan removes the headache of manually testing saga generator functions that couple your tests to their implementations. It offers a declarative, chainable API for testing that your saga yields certain effects without worrying about other effects or the order effects were yielded. It also runs your saga with redux-saga's runtime so that you can write integration tests, or you can use redux-saga-test-plan's built-in effect mocking to write unit tests too.
Let's look at some example sagas to see how redux-saga-test-plan makes it easy to test them.
Given this simple saga for fetching an array of users:
import { call, put } from "redux-saga/effects";
function* fetchUsersSaga(api) {
const users = yield call(api.getUsers);
yield put({ type: "FETCH_USERS_SUCCESS", payload: users });
}
You can test it with redux-saga-test-plan like this:
import { expectSaga } from "redux-saga-test-plan";
it("fetches users", () => {
const users = ["Jeremy", "Tucker"];
const api = {
getUsers: () => users,
};
return expectSaga(fetchUsersSaga, api)
.put({ type: "FETCH_USERS_SUCCESS", payload: users })
.run();
});
The expectSaga
function accepts a saga as an argument as well as any additional arguments for the saga itself. Here, we pass in the fetchUsersSaga
and inject a mock api
to fake the API response.
expectSaga
returns a chainable API with lots of useful methods. The put
method is an assertion that the saga will eventually yield a put
effect with the given FETCH_USERS_SUCCESS
action.
The run
method starts the saga. redux-saga-test-plan uses redux-saga's runSaga
function to run the saga like it would be run in your application. expectSaga
tracks any effects your saga yields, so you can assert them like we do with put
here.
Sagas are inherently asynchronous, so redux-saga-test-plan returns a promise from the run
method. You need that promise to know when the test is complete. In this example, we're using Jest so that we can return the promise directly to it.
Because redux-saga-test-plan runs asynchronously, it times out your saga after a set amount of time. You can configure the timeout length.
If you don't inject dependencies like the api
object, you can use expectSaga
's built-in mocking mechanism called providers. Let's say you import api
from another file and use it like this instead:
import { call, put } from "redux-saga/effects";
import api from "./api";
function* fetchUsersSaga() {
const users = yield call(api.getUsers);
yield put({ type: "FETCH_USERS_SUCCESS", payload: users });
}
You can mock it with the provide
method like this:
import { expectSaga } from "redux-saga-test-plan";
import api from "./api";
it("fetches users", () => {
const users = ["Jeremy", "Tucker"];
return expectSaga(fetchUsersSaga)
.provide([[call(api.getUsers), users]])
.put({ type: "FETCH_USERS_SUCCESS", payload: users })
.run();
});
The provide
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. redux-saga-test-plan will intercept effects that match and return the fake value instead of letting redux-saga handle the effect. In this example, we match any call
effects to api.getUsers
and return a fake array of users instead.
redux-saga-test-plan can handle more complex saga relationships like this:
import { call, put, takeLatest } from "redux-saga/effects";
import api from "./api";
function* fetchUserSaga(action) {
const id = action.payload;
const user = yield call(api.getUser, id);
yield put({ type: "FETCH_USER_SUCCESS", payload: user });
}
function* watchFetchUserSaga() {
yield takeLatest("FETCH_USER_REQUEST", fetchUserSaga);
}
In this example, watchFetchUserSaga
uses takeLatest
to handle the latest FETCH_USER_REQUEST
action. If something dispatches FETCH_USER_REQUEST
, then redux-saga forks fetchUserSaga
to handle the action and fetch a user by id from the action's payload
. You can test these sagas with redux-saga-test-plan like this:
import { expectSaga } from "redux-saga-test-plan";
import api from "./api";
it("fetches a user", () => {
const id = 42;
const user = { id, name: "Jeremy" };
return expectSaga(watchFetchUserSaga)
.provide([[call(api.getUser, id), user]])
.put({ type: "FETCH_USER_SUCCESS", payload: user })
.dispatch({ type: "FETCH_USER_REQUEST", payload: id })
.silentRun();
});
redux-saga-test-plan captures effects from forked sagas too. Notice that we call expectSaga
with watchFetchUserSaga
but still test the behavior of fetchUserSaga
with the put
assertion.
We use the dispatch
method to dispatch a FETCH_USER_REQUEST
action with a payload
id of 42
to watchFetchUserSaga
. redux-saga then forks and runs fetchUserSaga
.
takeLatest
runs in a loop so that redux-saga-test-plan will time out the saga with a warning message. You can safely silence the warning with the alternative silentRun
method since we expect a timeout here.
You can use providers to test your saga's error handling too. Take this new version of fetchUsersSaga
that uses a try-catch
block:
function* fetchUsersSaga() {
try {
const users = yield call(api.getUsers);
yield put({ type: "FETCH_USERS_SUCCESS", payload: users });
} catch (e) {
yield put({ type: "FETCH_USERS_FAIL", payload: e });
}
}
You can import throwError
from redux-saga-test-plan/providers
to simulate an error in the provide
method:
import { expectSaga } from "redux-saga-test-plan";
import { throwError } from "redux-saga-test-plan/providers";
it("handles errors", () => {
const error = new Error("Whoops");
return expectSaga(fetchUsersSaga)
.provide([[call(api.getUsers), throwError(error)]])
.put({ type: "FETCH_USERS_FAIL", payload: error })
.run();
});
You can also test your Redux reducers alongside your sagas. Take this reducer for updating the array of users in the store state:
const INITIAL_STATE = { users: [] };
function reducer(state = INITIAL_STATE, action) {
switch (action.type) {
case "FETCH_USERS_SUCCESS":
return { ...state, users: action.payload };
default:
return state;
}
}
You can use the withReducer
method to hook up your reducer and then assert the final state with hasFinalState
:
import { expectSaga } from "redux-saga-test-plan";
it("fetches the users into the store state", () => {
const users = ["Jeremy", "Tucker"];
return expectSaga(fetchUsersSaga)
.withReducer(reducer)
.provide([[call(api.getUsers), users]])
.hasFinalState({ users })
.run();
});
Here are the other effect assertions available for testing.
take(pattern)
take.maybe(pattern)
put(action)
put.resolve(action)
call(fn, ...args)
call([context, fn], ...args)
apply(context, fn, args)
cps(fn, ...args)
cps([context, fn], ...args)
fork(fn, ...args)
fork([context, fn], ...args)
spawn(fn, ...args)
spawn([context, fn], ...args)
join(task)
select(selector, ...args)
actionChannel(pattern, [buffer])
race(effects)
expectSaga
. You don't have to manually iterate through your saga's yielded effects, which decouples your test from the implementation.put
a particular type
of action without worrying about the action payload.I grew tired of manually testing sagas by iterating through yielded effects like this:
function* fetchUsersSaga() {
const users = yield call(api.getUsers);
yield put({ type: "FETCH_USERS_SUCCESS", payload: users });
}
it("fetches users", () => {
const users = ["Jeremy", "Tucker"];
const iter = fetchUsersSaga();
expect(iter.next().value).toEqual(call(api.getUsers));
expect(iter.next(users).value).toEqual(
put({ type: "FETCH_USERS_SUCCESS", payload: users })
);
});
These tests took long to write and coupled the test to the implementation. One small change in the order of effects would break a test even if the change didn't change the saga's overall behavior. Ironically, I created a testSaga API that took some of that boilerplate away but still coupled tests to their implementation.
I finally set out to create a more user-friendly API that removed most of the boilerplate and let you focus on testing the behavior you were most interested, and this is how expectSaga
was born.
Writing my Elm book is currently consuming a lot of my time, so I've had to take a short break from redux-saga-test-plan. However, the next big plan is to support redux-saga v1, which adds support for effect middlewares. Effect middlewares let you intercept effects to return a mock value. I hope to simplify expectSaga
's implementation of providers with effect middlewares.
There's a nice backlog of issues for other cool features like new helpful assertions and integrating with a full Redux store too.
Contributors are welcome!
I'm not entirely sure because it depends on the life of redux-saga. Mateusz Burzyński and all the contributors have been doing a great job maintaining it. It's a great sign that they're working toward v1. But front-end development can move and change so fast. For example, we've seen a massive rise in the popularity of RxJS and redux-observable.
As long as there is broad support for redux-saga in front-end applications, I think redux-saga-test-plan will stick around and fill a much-needed testing niche. Testing saga generators is hard, so redux-saga-test-plan will hopefully continue to make it easy. That being said, I don't always get to use redux-saga with my client projects, so I could use the support of other contributors to make redux-saga-test-plan the best it can be for testing.
As far as trends, I think front-end development is heading toward better maintainability and safety with static typing. Elm, TypeScript, and Flow are making it easier to build robust front-end applications. Static types can catch so many simple bugs and mistakes to help you refactor code more confidently.
You don't need to keep up with every new library and framework coming out. Focus on a stack that you like and build fantastic software. Don't let others make you feel like you're not a real developer because you're not up-to-date with the latest JavaScript framework. What's most important is understanding the language you're working with and how to stick to good software engineering practices. Find a mentor that's empathetic and eager to help you.
Also, ask to speak at a meetup or submit to a conference. You'd be surprised how many people sometimes aren't experts on the topics they share (I've been there for sure). You can share the pain points you experienced learning a technology and offer your unique perspective on what you love about it. Then, you can inspire and empower other newcomers.
I might be a little biased because I work for Test Double, but you should interview Justin Searls. He speaks a lot about testing, and his insight is something the JavaScript world would greatly benefit from. He maintains our awesome test double library testdouble.js, which has transformed how I think about mocking in tests.
Thanks for the interview Jeremy! redux-saga-test-plan seems to complement redux-saga well.
You can learn more from the redux-saga-test-plan site and redux-saga-test-plan GitHub page.