Redux Hero Part 5: The Hero is Tested (a Fun Introduction to redux-saga-test-plan)

Redux-Saga Redux

Most developers agree that automated software testing is an important practice. It proves that code is correct now and that it stays correct in the future as the code evolves. However, despite this widely-held opinion, there are still lots of developers who aren't testing their software.

Not knowing what nor how to test are big barriers to getting started -- and that's just with regular code. The barrier feels even bigger for redux-saga code because generator functions can't be tested in the same way as regular functions.

This could become a big problem for teams who are increasingly relying on putting complex business logic in sagas without tests.

I am going to walk you through some saga unit tests written using a library called redux-saga-test-plan.

We wrote some sagas in the previous article (Part 4) for a mock RPG. Now let's test them!

Testing the game saga

Recall we had a saga to run our main game loop that looks like this:

export function* gameSaga() {    let playerAlive = true;    while (playerAlive) {        yield take(Actions.MOVE);        const location = yield select(getLocation);        if ( continue;        const monsterProbability = yield call(Math.random);        if (monsterProbability < location.encounterThreshold) continue;        // fight the monster        playerAlive = yield call(fightSaga);    }}

To test it, we might want to assert the following specifications:

When written in Jest (or Mocha, etc), those test specifications could start out looking like this:

describe('gameSaga', () => {    describe('when player is in a safe location', () => {        it('allows player to move freely');    });    describe('when a monster is not encountered', () => {        it('does not start a fight');    });    describe('when a monster is encountered', () => {        it('starts a fight');        describe('after the fight', () => {            it('exits if the player died');            it('continues if the player lives');        });    });});

Run that and your output will look like this:

Redux Hero Part 5 empty tests

From there we can start to fill in the implementations.

Testing that the player can move freely in a safe location

Here is what the test looks like; an explanation follows.

describe('when player is in a safe location', () => {    const safeLocation = {        safe: true,    };    it('allows player to move freely', () => {        return expectSaga(gameSaga)            .provide([                [ select(getLocation), safeLocation ],            ])            .dispatch({ type: 'MOVE' })            .dispatch({ type: 'MOVE' })            .dispatch({ type: 'MOVE' })              .run();    });});

There's a bit to take in here if you're new to redux-saga-test-plan. Let me explain a few things.

First, expectSaga() is a test helper that will run your saga.

Second, the provide() method is like dependency injection for sagas.

The code does a yield select(getLocation) and so, in our unit test where we have already set a precondition that the user is in a safe location, we're going to provide a safe location when the code asks for it.

Third, dispatch() is used to advance the saga. In this example I'm dispatching a mock MOVE action to simulate a player moving around on the map. I've omitted a payload because the code doesn't care about it. It only cares that the action happened.

Fourth, we have a "negated assertion" that the code does not call Math.random. (Remember we're using it to decide if the player encountered a monster).

As an aside, consider what would happen if the code were to be changed later on to call Math.random() in more places -- for instance, at the beginning of the function. This unit test would break.

Architecturally speaking, the code would have been clearer and the unit test would have been less brittle if the code had delegated to a helper function called something like decideIfMonsterIsPresent rather than explicitly calling Math.random(). Then, we could have instead asserted that decideIfMonsterIsPresent was not called.

Fifth, we run the saga. All of the previous lines are just setting up the saga test runner. It's the run() method at the end that actually starts iterating through our saga.

Save those changes and you'll see the following output in the test console.

Redux Hero Part 5 test timed out

Hmm, see that? There's a warning that "Saga exceeded async timeout of 250ms"

We got a warning because our saga contains an infinite loop. There are a few ways we can deal with this. For now, let's keep it simple.

First things first, taking 250ms per test is unacceptable. Perhaps you won't mind now when you only have one test. But trust me... once you have hundreds or thousands of tests, you'll want to squeeze every bit of time out that you can.

The run() method accepts a timeout parameter. We can shorten the timeout by specifying how many milliseconds to allow. For instance, pass in a parameter of 50 and the warning message changes to

"Saga exceeded async timeout of 50ms"

Okay, that's better in terms of time. But the console output is very distracting. Don't you hate when hundreds of tests are zooming by on the console and then you catch a glimpse of a break in the pattern for a console message... but it, too, scrolls away before you can process it. And then you're freaking out because you think something is broken.

We've already accepted that the test will timeout and we don't want Jest to bark at us every time it runs the test. Fortunately, redux-saga-test-plan has already anticipated this. It provides another method called silentRun(), which suppresses warnings. And, like run(), it accepts a timeout parameter.

After revising the test and saving, the output is clean. Also notice that the runtime was 53ms instead of 257ms.

Redux Hero Part 5 test passing

Testing that there is no fight if there is no monster

We are now declaring that our player is no longer in a safe position. This is done by providing an unsafe location in response to requests to get the current location.

const unsafeLocation = {    safe: false,};describe('when a monster is not encountered', () => {    it('does not start a fight', () => {        return expectSaga(gameSaga)            .provide([                [ select(getLocation), unsafeLocation ],                [ call(Math.random), 0 ],            ])            .dispatch({ type: 'MOVE' })              .silentRun(50);    });});

We're now also intercepting the call to Math.random and making it return a value of 0.

Warning: The unit test shouldn't have to know that a low random value results in monsters being absent. It's an implementation detail. Instead, delegating to a helper method decideIfMonsterIsPresent would be a better solution.

Since there is no monster present, we expect that fightSaga is not called. And, as before, run silent and timeout after 50ms.

Testing that a fight starts if there is a monster

Now we're going to force a monster to be present by providing a return value of 1 from Math.random() and then assert that fightSaga is called.

describe('when a monster is encountered', () => {    it('starts a fight', () => {        return expectSaga(gameSaga)            .provide([                [ select(getLocation), unsafeLocation ],                [ call(Math.random), 1 ],                [ call(fightSaga), false ],                        ])            .dispatch({ type: 'MOVE' })            .call(fightSaga)            .run();    });});

You'll also notice that we're providing a return value of false for fightSaga. The fact that we're asserting that fightSaga got called and that we're mocking the call might seem odd. The reason for mocking the call is to prevent to real fightSaga from being called. The return value doesn't matter for this test. However, I'm returning false so that the saga will exit and save some time (and so we can run without a timeout)

Testing that it exits if the player dies

This will be almost exactly the same as the previous test. The difference is that we're going to assert that the code only performs yield take() once because we expect the main loop to exit when the player is dead.

describe('after the fight', () => {    it('exits if the player died');        return expectSaga(gameSaga)            .provide([                [ select(getLocation), unsafeLocation ],                [ call(Math.random), 1 ],                [ call(fightSaga), false ],            ])            .dispatch({ type: 'MOVE' })            .call(fightSaga)            .run()            .then(({ effects }) => {                expect(effects.take).toHaveLength(1);            });    });});

Once the run is complete, we can inspect the set of effects that were yielded during execution of the saga.

For this test the contents of effects looks like the JSON below. The code did one take (of 'MOVE'); one call (to Math.random); and one select (of getLocation).

{  "take": [    {      "@@redux-saga/IO": true,      "TAKE": {        "pattern": "MOVE"      }    }  ],  "call": [    {      "@@redux-saga/IO": true,      "CALL": {        "context": null,        "args": []      }    }  ],  "select": [    {      "@@redux-saga/IO": true,      "SELECT": {        "args": []      }    }  ]}

Testing that it continues if the player survives

The player survives in this test, which means fightSaga must return true. It follows then that we expect the yield take('MOVE') line to be executed multiple times because our main loop will... loop.

How many times will the loop execute? Exactly twice. We are telling the test runner to dispatch { type: 'MOVE' } once. Thus, the first take gets the one and only dispatch; and the second take blocks until the test times out -- so we've also switched back to running silently to avoid warnings on the console.

describe('after the fight', () => {    it('continues if the player lives');        return expectSaga(gameSaga)            .provide([                [ select(getLocation), unsafeLocation ],                [ call(Math.random), 1 ],                [ call(fightSaga), true ],            ])            .dispatch({ type: 'MOVE' })            .call(fightSaga)            .silentRun(50)            .then(({ effects }) => {                expect(effects.take).toHaveLength(2);            });    });});

You might feel uneasy about having a very-general assertion that there were two takes. This is like the leaky code from before that makes assumptions about how to decide if a monster is present.

Instead of assuming there are no other takes in the code, the assertion could be rewritten to be more robust by filtering on the take pattern.

e.g. const takeMoves = effects.take.filter(t => t.TAKE.pattern === 'MOVE');

We're done testing gameSaga

Hurray! All the tests are passing now.

Redux Hero Part 5 all tests passing

I've shown you how to write unit tests for the main saga. The other four sagas I'll leave as an exercise for you -- or you can just download the bonus content. :)

Bonus Content: Download the source code and unit tests

Are you still not confident enough to test your own sagas? Are you hungry for more examples? Or maybe you'd just like to play around with the source without having to manually copy-paste it.

The full source code including all five sagas from the previous article and the complete suite of tests is available for download exclusively by mailing list members. Also as a list member you'll get articles about modern web development topics like React, Redux, TypeScript, etc. emailed to you occasionally.

You might also enjoy...