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: return
s) 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 yield
ed, as that's the first thing that will be return
ed 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 yeild
ed 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 import
ed 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.