Testing AuthSaga - Unhappy Paths


In this video we are going to continue on testing our Authentication Saga, this time covering two unhappy paths through our code. These are:

  1. Ensuring our saga throws when a call to api.login fails, and;
  2. Ensuring our saga throws if the api.login response doesn't contain a token in the response body.

As we're not quite working in a test driven manner here, but rather grafting tests back on to code that is pre-existing, we know a further two things will happen should our code throw:

  1. An action of type LOGIN_FAILED will be dispatched, containing information related to the error, and;
  2. Our finally block will ensure the current Request is removed from our list of in progress requests.

By the end of this video we will have full coverage of our doLogin workflow.

It's Time To Start Testing

As is always the case when starting coding, there are things we know that will be easy to do, and things that we haven't yet done - which we anticipate being difficult.

In this case, the parts I would expect to give us the most problems would be in how we trigger a throw, and also how we fake the response from the api.login call to give us a response that - crucially - doesn't contain a token on the response body.

Well, to alleviate the worry here, the good news is we already indirectly know how to achieve both of these things here, as we inadvertently covered them during our happy path testing :)

That is to say that we can pass in arguments to our generator function when calling next, allowing us to setup our functions however we desire. Let's see that in action again in our first unhappy path test.

Throwing When A Call To api.login Fails

To start off, I am going to define the two tests needed in this video, but add an x before the third test so that it won't be considered an active test by Jest:

// /__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', () => {
      // passing test logic here
    });

    it('throws when a call to api.login fails', () => {

    });

    xit('throws if unable to find a token in the response.body', () => {

    });

  });

});

Next, let's quickly recap the logic that is under test here:

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

import * as api from '../connectivity/api';
import {call, put} from 'redux-saga/effects';
import {takeLatest} from 'redux-saga';
import jwtDecode from 'jwt-decode';
import {push} from 'react-router-redux';
import * as types from '../constants/actionTypes';

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);

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

    yield put({
      type: types.LOGIN__SUCCEEDED,
      payload: {
        idToken: responseBody.token
      }
    });

  } catch (e) {

    yield put({
      type: types.LOGIN__FAILED,
      payload: {
        message: e.message,
        statusCode: e.statusCode
      }
    });

  } finally {

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

  }
}

Ok, so looking at this, we can assert that to get to the part where we expect our problem to lie - the call to api.login - that up until this point, our test code is going to be identical to that of the happy path. Good news, that means we can copy / paste the same logic to get setup:

// /__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('throws when a call to api.login fails', () => {

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

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

    });
  });
});

Next, we need to go ahead and actually call the api.login function. Thinking about it, this call itself should - again - be exactly how this works in the "happy path" scenario, as we only expect the outcome to be problematic, not the call itself. As such, our third expect statement is going to mirror the "happy path" also:

// /__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('throws when a call to api.login fails', () => {

      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')
      );

    });
  });
});

Ok, but now what?

Let's recap the implementation logic:

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

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

We need to simulate call(api.login, username, password) to have thrown an error.

There may be other ways to do this, but as best I can find, the simplest is to simply fake it. Instead of now calling generator.next().value as we have done in happy scenarios, we can instead call generator.throw({ something: 'here' }).value (the .value part is important still), which throws an error into our generator. That part is important - we are resuming the execution of the generator, and throwing an error into it.

The error we choose to throw into our generator in our case will be an object matching the shape of the error object we expect a bad call to api.login to throw:

// /__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('throws when a call to api.login fails', () => {

      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.throw({
          message: 'something went wrong',
          statusCode: 123,
        }).value
      ).toEqual(
        put({
          type: types.LOGIN__FAILED,
          payload: {
            message: 'something went wrong',
            statusCode: 123,
          }
        })
      );

    });
  });
});

If the generator gets given an error, then we expect it to fall in to the catch block, and dispatch a new action of type LOGIN__FAILED. In other words, even though things went wrong, we still manage to gracefully handle the situation.

After this we know from our "happy path" testing, and from our implementation itself, that we must always hit the finally block, after which our generator function should be in a done state. We can therefore copy / paste this same logic from our happy path test:

// /__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('throws when a call to api.login fails', () => {

      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.throw({
          message: 'something went wrong',
          statusCode: 123,
        }).value
      ).toEqual(
        put({
          type: types.LOGIN__FAILED,
          payload: {
            message: 'something went wrong',
            statusCode: 123,
          }
        })
      );

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

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

    });
  });
});

Hopefully you agree that this was much easier than initially thought, and also aside from being a little verbose due to the way I've used many lines to make the code more readable for a human, is actually quite terse, especially when compared to alternative approaches with tons of mocking and setup.

Throwing When A Call To api.login Doesn't Return A token

Knowing what we know from the previous test, we can get a bunch of the 'boilerplate' test implementation out of the way straight away:

// /__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('throws when a call to api.login fails', () => {
        // previous test
    });

    it('throws if unable to find a token in the response.body', () => {

      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')
      );

      // **************************************
      // this is where things will be different
      // **************************************

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

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

Remember to remove the x from before your xit if following along :)

Ok, so the confusing part here now is how to get into a situation whereby our responseBody is a valid object, but it doesn't contain a token field?

Well, we can borrow from what we learned in our happy path testing.

We can create a fake response body, make it look and feel however we like, and simply pass that in to the next call to the generator:

  let fakeResponseBody = { bad: 'response' };

  expect(
    generator.next(fakeResponseBody).value
  ).toEqual(
    put({
      type: types.LOGIN__FAILED,
      payload: {
        message: 'Unable to find JWT in response body',
        statusCode: undefined
      }
    })
  );

This works because in our implementation we have the following:

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

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

When we call next and pass in our fakeResponseBody, whatever we pass in ({ bad: 'response' } in our case) becomes the value of responseBody.

The code then checks if responseBody.token is undefined - which it is, because we it doesn't exist on our faked object. At this point it throw's an error with the message we provide, which we then check for in our toEqual block.

The mind bender is passing values back into a generator, or even more strangely, throwing errors into a generator.

The thing is though, now you know this 'pattern', you can repeat it over and over again to test as many of these generator functions as you need too. Fairly awesome.

Code For This Course

Get the code for this course.

Episodes