tl;dr: to test a saga, it's way, way better to run it as a whole (using runSaga()
) than to do it step-by-step (using gen.next()
)
In my team, we're currently using redux-saga
to handle asynchronous calls in our React/Redux application. These sagas can call APIs and dispatch actions using ES6 generators. Below is a contrived example, in which we load a profile. After the yield
statements, you can see 3 side effects that tend to show up in our team's sagas:
-
select
"instructs the middleware to invoke the provided selector" on the store -
put
"instructs the middleware to dispatch an action" to the store -
call
instructs the middleware to call the given function
You can find full descriptions in the API reference.
All the code snippets in this blog can be found in this example repository.
import {call, put, select} from 'redux-saga/effects';
import {isAuthenticated} from './selectors';
import {loadProfileFailure, loadProfileSuccess} from './actionCreators';
import {getProfile} from './api';
export function* loadProfileSaga(action) {
// use a selector to determine if the user is authenticated
const authenticated = yield select(isAuthenticated);
if (authenticated) {
// call the API and dispatch a success action with the profile
const profile = yield call(getProfile, action.profileId);
yield put(loadProfileSuccess(profile));
} else {
// dispatch a failure action
yield put(loadProfileFailure());
}
}
Testing sagas step-by-step is rubbish
To test sagas, our approach so far has been to call the generator function to get the iterator object, and then to manually call .next()
to bump through the yield
statements, asserting on the value of each yield
as we go.
To test that the saga dispatches a failure action if the user is not authenticated, we can assert that the first gen.next()
- i.e. the first yield
- calls the selector.
Then, to pretend that the selector returned false, we need to pass a pretend return value from the selector into the following gen.next()
. That's why we have to call gen.next(false).value
in the test below. Without an intimate understanding of generators, this syntax is alien and opaque.
it('should fail if not authenticated', () => {
const action = {profileId: 1};
const gen = loadProfileSaga(action);
expect(gen.next().value).toEqual(select(isAuthenticated));
expect(gen.next(false).value).toEqual(put(loadProfileFailure()));
expect(gen.next().done).toBeTruthy();
});
Next, let's test the case where the user is authenticated. It's not really necessary to assert that the first yield
is a select()
, since we did that in the previous test. To avoid the duplicate assertion, we can write gen.next()
outside of an assertion to just skip over it. However, when reading the test in isolation, this gen.next()
is just a magic incantation, whose purpose is not clear. Like in the previous test, we can call gen.next(true).value
to pretend that the selector has returned true
.
Then, we can test that the following yield
is the API call, pass some pretend return value of getProfile()
into the following gen.next()
and assert that the success action is dispatched with that same return value.
it('should get profile from API and call success action', () => {
const action = {profileId: 1};
const gen = loadProfileSaga(action);
const someProfile = {name: 'Guy Incognito'};
gen.next();
expect(gen.next(true).value).toEqual(call(getProfile, 1));
expect(gen.next(someProfile).value).toEqual(put(loadProfileSuccess(someProfile)));
expect(gen.next().done).toBeTruthy();
});
Why is step-by-step testing bad?
Unintuitive test structure
Outside of saga-land, 99% of tests that we write roughly follow an Arrange-Act-Assert structure. For our example, that would be something like this:
it('should fail if not authenticated', () => {
given that the user is not authenticated
when we load the profile
then loading the profile fails
});
For sagas, the conditions of our tests could be the results of side effects like yield call
or yield select
. The results of these effects are passed as arguments into the gen.next()
call that immediately follows, which is often itself inside an assert. This is why the first example test above includes these two lines:
// this is the call that we want to "stub"
// ↓
expect(gen.next().value).toEqual(select(isAuthenticated));
expect(gen.next(false).value).toEqual(put(loadProfileFailure()));
// ↑
// this is the return value (!)
So, rather than Arrange-Act-Assert, the example saga tests above are more like this:
it('should fail if not authenticated', () => {
create the iterator
for each step of the iterator:
assert that, given the previous step returns some_value,
the next step is a call to someFunction()
});
Difficult to test negatives
For the example saga, it would be reasonable to test that we don't call the API if the user is not authenticated. But if we're testing each yield
step-by-step, and we don't want to make assumptions about the internal structure of the saga, the only thorough way to do this is to run through every yield
and assert that none of them call the API.
expect(gen.next().value).not.toEqual(call(getProfile));
expect(gen.next().value).not.toEqual(call(getProfile));
...
expect(gen.next().done).toBeTruthy();
We want to assert that getProfile()
is never called, but instead we have to check that every yield
is not a call to getProfile()
.
Coupling between test and implementation
Our tests closely replicate our production code. We have to bump through the yield
statements of the saga, asserting that they yield the right things, and as a byproduct, asserting that they are called in some fixed order.
The tests are brittle, and refactoring or extending the sagas is incredibly difficult.
If we reorder the side effects, we need to fix all of our expect(gen.next(foo).value)
assertions, to make sure we're passing the right return value into the right yield
statement.
If we dispatch an additional action with a new yield put()
near the top of a saga, the tests will all have to have an additional gen.next()
added in somewhere, to skip over that yield
, and move the assertions "one yield down".
I have frequently stared at a failing test, repeatedly trying to insert gen.next()
in various places, blindly poking until it passes.
A better way is to run the whole saga
What if we could set up the conditions of our test, instruct the saga to run through everything and finish its business, and then check that the expected side effects have happened? That's roughly how we test every other bit of code in our application, and there's no reason we can't do that for sagas too.
The golden ticket here is our utility function recordSaga()
, which uses redux-saga
's runSaga()
to start a given saga outside of the middleware, with a given action as a parameter. The options object is used to define the behaviour of the saga's side effects. Here, we're only using dispatch
, which fulfils put
effects. The given function adds the dispatched actions to a list, which is returned after the saga is finished executing.
import {runSaga} from 'redux-saga';
export async function recordSaga(saga, initialAction) {
const dispatched = [];
await runSaga(
{
dispatch: (action) => dispatched.push(action)
},
saga,
initialAction
).done;
return dispatched;
}
With this, we can mock some functions to set up the test's conditions, run the saga as a whole, and then assert on the list of actions dispatched or functions called to check its side effects. Amazing! Consistent! Familiar!
Note: it's possible to pass a store into runSaga()
that selectors would be run against, as in the example in the documentation. However, instead of building a fake store with the correct structure, we've found it easier to stub out the selectors.
Here's the necessary set up, which can go in a describe()
block. We're using jest
to stub the functions that the saga imports.
api.getProfile = jest.fn();
selectors.isAuthenticated = jest.fn();
beforeEach(() => {
jest.resetAllMocks();
});
For our first test, we can set up the conditions of our test using the stubbed selector, run through the saga, and then assert on the actions it dispatched. We can also assert that the API call was never made!
it('should fail if not authenticated', async () => {
selectors.isAuthenticated.mockImplementation(() => false);
const initialAction = {profileId: 1};
const dispatched = await recordSaga(
loadProfileSaga,
initialAction
);
expect(dispatched).toContainEqual(loadProfileFailure());
expect(api.getProfile).not.toHaveBeenCalled();
});
In our second test, we can mock the implementation of the API function to return a profile, and then later, assert that the loadProfileSuccess()
action was dispatched, with the correct profile.
it('should get profile from API and call success action if authenticated', async () => {
const someProfile = {name: 'Guy Incognito'};
api.getProfile.mockImplementation(() => someProfile);
selectors.isAuthenticated.mockImplementation(() => true);
const initialAction = {profileId: 1};
const dispatched = await recordSaga(
loadProfileSaga,
initialAction
);
expect(api.getProfile).toHaveBeenCalledWith(1);
expect(dispatched).toContainEqual(loadProfileSuccess(someProfile));
});
Why is it better to test as a whole?
- Familiar test structure, matching the Arrange-Act-Assert layout of every other test in our application.
- Easier to test negatives, because the saga will actually call functions, so we have the full power of mocks at our disposal.
-
Decoupled from the implementation, since we're no longer testing the number or order of
yield
statements. I think this is absolutely the main reason why this approach is preferable. Instead of testing the internal details of the code, we're testing its public API - that is, its side effects.
The two approaches to testing sagas are mentioned in the redux-saga
documentation, but I'm surprised the step-by-step method is even discussed. Testing a saga as a whole is conceptually familiar, and considerably less brittle.
Heavily inspired by this github issue.
Top comments (13)
This is a great article! Thank you for this!
How would you throw exceptions to test for api call failures?
For example, if you had a try / catch block for,
const profile = yield call(getProfile, action.profileId);
When you test the saga as a whole, how do you force an error?
I tried using to mockImplementation to return a new Error(), but that doesn't go into the catch block.
I don't know if the APIs are changed, at the moment (Feb, 2020)
runSaga
returns a Task that has atoPromise()
method.So
await runSaga(...).done
does not make sense, you need to doanyway: thank you so much for the article 😊
i'm not sure that's entirely true. when i've gone down the path of testing sagas step-by-step, i've thrown
gen.next()
s into the body of the test without any assertions to get to the step i'm actually trying to test. it's not great and it's pretty brittle if you change your order of operations, but it's not silly either. i don't need to assert that the saga isn't calling a function.Phil, this is brilliant- thanks for writing the article. I'm trying to (roughly) define a testing approach for sagas that'll encourage a more maintainable test suite, and I think you've just nailed a decent way of going about it without introducing another dependency!
Agree with you Phil! I've come across this repo where you can set up end to end test for sagas
github.com/jfairbank/redux-saga-te...
How you got the api calls to be overrided inside the saga?
Thanks bro, you help me a lot..!
I'd love it if you wanted to dissect my Redux one line replacement hook... useSync
dev.to/chadsteele/redux-one-liner-...
Hi Phil, I've been trying the runSaga approach but my dispatch is never called... :-/
I'm not sure if this still relevant, but .done seems to be depracated. Have you tried appending .toPromise() to the end of runsaga ?
Really helpful article. Thanks Phil! This helped me alot.