Testing AuthSaga - Happy Path


In this video we are going to cover how to write a test for a Redux Saga. Specifically we will be testing the *doLogin generator function inside our auth.saga.js file.

There are three potential paths through this code. The first is the happy path - if everything goes well then happy days, but we still need to make sure each step completed as expected.

The other two paths are what happens if the API is unresponsive, and what happens if the API responds but the response is not in the 'shape' we expect? These would be the 'unhappy' paths, and are as important to test as the 'happy' path.

We will cover the unhappy paths in the next video. For the moment we will focus on how to write a test for a Saga when everything goes to plan.

As we've already written the implementation for the *doLogin function in auth.saga, this is made somewhat easier for us as it allows us to cheat a little and determine what 'things' we actually do get returned by our tests. This is much more evident in the video, so be sure to watch along if unsure.

Testing Redux Sagas

Just because we are writing a test for a Redux Saga doesn't mean we have to do anything different in our test setup.

I'm going to use a describe for the file as a whole, and then an inner describe for the specific function (*doLogin, in this case) to better structure the test output on the console. Aside from this, all we care about is that it has a happy path:

// /__tests__/sagas/auth.saga.react-test.js

describe('Auth Saga', () => {

  describe('doLogin', () => {

    it('has a happy path', () => {

    });
  });
});

Again, testing Redux Sagas are no different to testing other files in our project. We must first import the saga we want to test, and then we can use it in our test proper:

// /__tests__/sagas/auth.saga.react-test.js

import * as authSaga from '../../src/sagas/auth.saga';

describe('Auth Saga', () => {

  describe('doLogin', () => {

    it('has a happy path', () => {

      const generator = authSaga.doLogin({
        payload: {
          username: 'bob',
          password: 'testpass'
        }
      });

    });
  });
});

All our *doLogin function expects to receive is an action object. There's nothing special about this object compared to any other Flux Standard Action. So, we can send in an object that looks and feels just like the Action that doLogin expects, which in this case is an object with a payload or username and password. The details are nonsense, but that doesn't matter as they won't be sent to our API, thankfully.

But now what?

Well, our generator is just that: a generator.

What we care about is what the generator does (think: returns) next.

As we've just called our *doLogin generator function, our const generator now contains a Generator object.

If we look at that *doLogin function, the part we are interested in is the first thing to be yielded, as that's the first thing that will be returned by a call to .next():

// /src/sagas/auth.saga.js

import {put} from 'redux-saga/effects';
import * as types from '../constants/actionTypes';
// * snip *

export function *doLogin(action) {

  try {

    yield put({
      type: types.REQUEST__STARTED,
      payload: {
        requestFrom: 'authSaga.doLogin'
      }
    });

    // * snip *

Interesting.

We could therefore expect that a call to generator.next() would return 'something' with the contents of this yield statement, right? Right.

That 'something' would be an object with two properties - value, and done.

done is interesting as it tells us whether the generator has just yeilded it's last value to us, and is therefore finished. This would be a boolean, and in our case, should be false and we still have at least one more yield ahead.

The value is also interesting, as this should be the contents from the yield statement. In our case, that should be:

put({
  type: types.REQUEST__STARTED,
  payload: {
    requestFrom: 'authSaga.doLogin'
  }
})

Writing our expect assertion could therefore look like this:

// /__tests__/sagas/auth.saga.react-test.js

describe('Auth Saga', () => {

  describe('doLogin', () => {

    it('has a happy path', () => {

      const generator = authSaga.doLogin({
        payload: {
          username: 'bob',
          password: 'testpass'
        }
      });

      expect(
        generator.next().value
      ).toEqual(
        put({
          type: types.REQUEST__STARTED,
          payload: {
            requestFrom: 'authSaga.doLogin'
          }
        })
      );

      // * snip *

However, this would fail.

Why?

Well, we haven't imported types, nor put. Our test file will end up looking highly similar to our implementation:

// /__tests__/sagas/auth.saga.react-test.js

import * as authSaga from '../../src/sagas/auth.saga';
import * as types from '../../src/constants/actionTypes';
import {put} from 'redux-saga/effects';

describe('Auth Saga', () => {

  describe('doLogin', () => {

    it('has a happy path', () => {

      const generator = authSaga.doLogin({
        payload: {
          username: 'bob',
          password: 'testpass'
        }
      });

      expect(
        generator.next().value
      ).toEqual(
        put({
          type: types.REQUEST__STARTED,
          payload: {
            requestFrom: 'authSaga.doLogin'
          }
        })
      );

      // * snip *

Note though that our import paths go from the __tests__/ directory, rather than being directly copy / paste-able from the actual implementation.

Anyway, fairly awesome, this should now be a pass.

Only, the pass is a bit misleading. Our test should still fail, after all, we haven't fully tested this function.

We can fix this by testing for the outcome of generator.next().done being true, which at this point will actually be false.

// /__tests__/sagas/auth.saga.react-test.js

import * as authSaga from '../../src/sagas/auth.saga';
import * as types from '../../src/constants/actionTypes';
import {put} from 'redux-saga/effects';

describe('Auth Saga', () => {

  describe('doLogin', () => {

    it('has a happy path', () => {

      const generator = authSaga.doLogin({
        payload: {
          username: 'bob',
          password: 'testpass'
        }
      });

      expect(
        generator.next().value
      ).toEqual(
        put({
          type: types.REQUEST__STARTED,
          payload: {
            requestFrom: 'authSaga.doLogin'
          }
        })
      );

      expect(
        generator.next().done
      ).toBeTruthy();

Awesome, our test now fails - which is what we want.

The thing is, that was the easy test really. We didn't have to do very much to make that test pass - just describe what should have happened. This is important. We aren't testing the actual 'thing' happened, only that we are describing what should happen.

But, as I say, it's easy when all we are doing is describing that we want to dispatch an action (which is what put does).

If we go back to our *doLogin implementation, the next step is much more difficult - we need to call our api.login function. Yikes. Seems harder:

// /src/sagas/auth.saga.js

import * as api from '../connectivity/api';
import {call, put} from 'redux-saga/effects';
import * as types from '../constants/actionTypes';
// * snip *

export function *doLogin(action) {

  try {

    yield put({
      type: types.REQUEST__STARTED,
      payload: {
        requestFrom: 'authSaga.doLogin'
      }
    });

    const {username, password} = action.payload;

    const responseBody = yield call(api.login, username, password);

How do we go about testing this then? Do we need to mock out the API?

Thankfully no.

It's as easy as testing the yield of put!

The first time I did this, having spent a while with Redux Thunks, I have to say I felt justified in devoting the time to changing my implementation over to Redux Sagas. This is incredibly freeing. And it works an absolute charm.

Anyway, enough gushing, let's see how we can test this call:

// /__tests__/sagas/auth.saga.react-test.js

import * as authSaga from '../../src/sagas/auth.saga';
import * as types from '../../src/constants/actionTypes';
import {call, put} from 'redux-saga/effects';
import * as api from '../../src/connectivity/api';

describe('Auth Saga', () => {

  describe('doLogin', () => {

    it('has a happy path', () => {

      const generator = authSaga.doLogin({
        payload: {
          username: 'bob',
          password: 'testpass'
        }
      });

      expect(
        generator.next().value
      ).toEqual(
        put({
          type: types.REQUEST__STARTED,
          payload: {
            requestFrom: 'authSaga.doLogin'
          }
        })
      );

      expect(
        generator.next().value
      ).toEqual(
        call(api.login, 'bob', 'testpass')
      );

      expect(
        generator.next().done
      ).toBeTruthy(); // should still fail here

It's awesome 'cus we don't need to worry at all about how the api.login function works. All we do is describe the way in which we want to call it. Incredible.

We will get on to testing (and re-implementing) the actual API connectivity shortly, so don't concern yourself with the implementation of that just now.

We've covered how easy it is to test the call to the API (or similar), but now we need to work with the response. Curve ball:

// /src/sagas/auth.saga.js

    // * snip *

    const responseBody = yield call(api.login, username, password);

    if (typeof responseBody.token === "undefined") {
      throw new Error('Unable to find JWT in response body');
    }

    // * snip *

If we call generator.next() now then we will hit the catch of our try / catch block. Why? Well, responseBody is undefined, and so responseBody.token is definitely undefined.

How do we fix this?

Perhaps the most confusing part of generators (that I have encountered, so far at least): we can pass in an argument to our next call to next() which will become the value of the yield statement inside our function. If this makes zero sense then Good News! That's exactly how I felt. Honestly, generators are pretty complicated beasts. If you are at all interested in Redux Saga and haven't yet done so, I would strongly recommend reading this chapter at the very least of this book, which is the very best book on JavaScript I have ever read.

Anyway, knowing that we can pass an argument back into our generator's next() function means we can answer our question of how to fake the responseBody: we pass one in!

``` language-javascript
// /__tests__/sagas/auth.saga.react-test.js

      // * snip *

      expect(
        generator.next().value
      ).toEqual(
        call(api.login, 'bob', 'testpass')
      );

      let fakeResponseBody = { token: 'some-token' };

      expect(
        generator.next(fakeResponseBody).value
      ).toEqual(
        put({
          type: types.LOGIN__SUCCEEDED,
          payload: {
            idToken: 'some-token'
          }
        })
      );

And those are the hard parts of the *doLogin function tested.

To wrap up, all we need to do is ensure we test the last step, which is our finally block:

// /src/sagas/auth.saga.js

import * as api from '../connectivity/api';
import {call, put} from 'redux-saga/effects';
import * as types from '../constants/actionTypes';
// * snip *

export function *doLogin(action) {

  try {

    // * snip * 

  } catch {

    // * snip * 

  } finally {

    yield put({
      type: types.REQUEST__FINISHED,
      payload: {
        sendingRequest: true
      }
    });

  }

And we already know how to do this, making our final test look as follows:

// /__tests__/sagas/auth.saga.react-test.js

import * as authSaga from '../../src/sagas/auth.saga';
import * as types from '../../src/constants/actionTypes';
import {call, put} from 'redux-saga/effects';
import * as api from '../../src/connectivity/api';

describe('Auth Saga', () => {

  describe('doLogin', () => {

    it('has a happy path', () => {

      const generator = authSaga.doLogin({
        payload: {
          username: 'bob',
          password: 'testpass'
        }
      });

      expect(
        generator.next().value
      ).toEqual(
        put({
          type: types.REQUEST__STARTED,
          payload: {
            requestFrom: 'authSaga.doLogin'
          }
        })
      );

      expect(
        generator.next().value
      ).toEqual(
        call(api.login, 'bob', 'testpass')
      );

      let fakeResponseBody = { token: 'some-token' };

      expect(
        generator.next(fakeResponseBody).value
      ).toEqual(
        put({
          type: types.LOGIN__SUCCEEDED,
          payload: {
            idToken: 'some-token'
          }
        })
      );

      expect(
        generator.next().value
      ).toEqual(
        put({
          type: types.REQUEST__FINISHED,
          payload: {
            sendingRequest: true
          }
        })
      );

      expect(
        generator.next().done
      ).toBeTruthy();

    });
  });
});

And now our generator.next().done call should correctly be reporting true, and so our test is complete.

What's really nice about all this is that we haven't had to mock a bunch of things, or really touch any other files at all. All we've done is describe what we expect to be called, and that's good enough.

It's probably the most compelling reason to choose Redux Saga, in my opinion.

Code For This Course

Get the code for this course.

Code For This Video

Get the code for this video.

Episodes