DEV Community

loading...

The best way to test Redux Sagas

Phil Herbert
Constantly trying to get more involved in tech for social good
・7 min read

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.

Discussion (13)

Collapse
royb0y profile image
Roy

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.

Collapse
noriste profile image
Stefano Magni

I don't know if the APIs are changed, at the moment (Feb, 2020) runSaga returns a Task that has a toPromise() method.
So await runSaga(...).done does not make sense, you need to do

const task = runSaga(...)
const result = await task.toPromise()
expect(result).toStrictEqual({...})
Enter fullscreen mode Exit fullscreen mode

anyway: thank you so much for the article 😊

Collapse
worc profile image
worc

we have to check that every yield is not a call to getProfile()

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.

Collapse
jonesy profile image
Robert Jones

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!

Collapse
matt_chabert profile image
Matthieu Chabert

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...

Collapse
renatonankran profile image
Renato Magalhães

How you got the api calls to be overrided inside the saga?

Collapse
brayanl profile image
Brayan Loayza

Thanks bro, you help me a lot..!

Collapse
chadsteele profile image
Chad Steele

I'd love it if you wanted to dissect my Redux one line replacement hook... useSync
dev.to/chadsteele/redux-one-liner-...

Collapse
mean2me profile image
Emanuele Colonnelli

Hi Phil, I've been trying the runSaga approach but my dispatch is never called... :-/

Collapse
seanyboy49 profile image
Sean Lee

I'm not sure if this still relevant, but .done seems to be depracated. Have you tried appending .toPromise() to the end of runsaga ?

Collapse
jitendra_gosavi profile image
Jitendra Gosavi

Really helpful article. Thanks Phil! This helped me alot.

Collapse
austinknight profile image
Austin Knight

Great overview, its surprising how little info there is on testing sagas this way.

Collapse
gabrlucht profile image
Gabriel Luchtenberg

Awesome post!