Applying unit test for Redux-Saga with React and Typescript
Purpose:
The objective of this document is to describe guidance,
recommendations, and practices for applying developer test for Redux Saga using
React and Typescript.
Prerequisites:
This article mainly targets people with the knowledge of
the followings:
-
React
-
Redux
-
TypeScript
-
NodeJS
-
Unit testing with Jest
-
Using Redux-Saga
Library to manage async calls in web App
Redux Saga test using
“redux-saga-test-plan”:
During
this article we will mainly use
Redux Saga Test
Plan to test exact effects and their ordering or to test your saga put's
a specific action at some point.
Redux
Saga Test Plan aims to embrace both unit testing and integration testing
approaches to make testing your sagas easy.
The Following example generally explain
using redux-saga-test-plan.
Import the
expectSaga
function and
pass in your saga function as an argument.
Any additional arguments to
expectSaga
will become
arguments to the saga function. import { call, put, take }
from
'redux-saga/effects';
import { expectSaga }
from
'redux-saga-test-plan';
function* userSaga(api) {
const action =
yield take(
'REQUEST_USER');
const user =
yield call(api.fetchUser, action.payload);
yield put({ type:
'RECEIVE_USER', payload: user });
}
it(
'just works!', () => {
const api = {
fetchUser: id => ({ id, name:
'Tucker' }),
};
return expectSaga(userSaga, api)
// Assert that the `put` will eventually happen.
.put({
type:
'RECEIVE_USER',
payload: { id:
42, name:
'Tucker' },
})
// Dispatch any actions that the saga will `take`.
.dispatch({ type:
'REQUEST_USER', payload:
42 })
// Start the test. Returns a Promise.
.run();
});
In the example above, we test that the
userSaga
successfully put
s a RECEIVE_USER
action with
the fakeUser
as the payload. We call expectSaga
with
the userSaga
and supply an api
object as an
argument to userSaga
.
We assert the expected
put
effect via
the put
assertion method.
Then, we call the
dispatch
method with
a REQUEST_USER
action that contains the user id payload.
The
dispatch
method will
supply actions to take
effects.
Finally, we start the test by calling
the run method which returns a
Promise
.
Tests with
expectSaga
will always
run asynchronously, so the returned Promise
resolves when the saga finishes or when expectSaga
forces a
timeout.
Mocking with Providers
Sometimes
you will need to mock things like server APIs. In this case, you can use providers which
are perfect for mocking values directly with
expectSaga
.
Providers
are like middleware that allow you to intercept effects before they reach Redux
Saga. You can choose to return a mock value instead of allowing Redux Saga to
handle the effect, or you can pass on the effect to other providers or
eventually Redux Saga.
import { call, put, take } from 'redux-saga/effects';
import { expectSaga } from 'redux-saga-test-plan';
import * as matchers from 'redux-saga-test-plan/matchers';
import { throwError } from 'redux-saga-test-plan/providers';
import api from 'my-api';
function* userSaga(api) {
try {
const action = yield take('REQUEST_USER');
const user = yield call(api.fetchUser, action.payload);
const pet = yield call(api.fetchPet, user.petId);
yield put({
type: 'RECEIVE_USER',
payload: { user, pet },
});
} catch (e) {
yield put({ type: 'FAIL_USER', error: e });
}
}
it('fetches the user', () => {
const fakeUser = { name: 'Jeremy', petId: 20 };
const fakeDog = { name: 'Tucker' };
return expectSaga(userSaga, api)
.provide([
[call(api.fetchUser, 42), fakeUser],
[matchers.call.fn(api.fetchPet), fakeDog],
])
.put({
type: 'RECEIVE_USER',
payload: { user: fakeUser, pet: fakeDog },
})
.dispatch({ type: 'REQUEST_USER', payload: 42 })
.run();
});
Example with Reducer
One good use
case for integration testing is testing your reducer too. You can hook up your
reducer to your test by calling the
withReducer
method with
your reducer function.import { put } from 'redux-saga/effects';
import { expectSaga } from 'redux-saga-test-plan';
const initialDog = {
name: 'Tucker',
age: 11,
};
function reducer(state = initialDog, action) {
if (action.type === 'HAVE_BIRTHDAY') {
return {
...state,
age: state.age + 1,
};
}
return state;
}
function* saga() {
yield put({ type: 'HAVE_BIRTHDAY' });
}
it('handles reducers and store state', () => {
return expectSaga(saga)
.withReducer(reducer)
.hasFinalState({
name: 'Tucker',
age: 12, // <-- age changes in store state
})
.run();
});
Redux Saga Test Plan Example:
For this article we are using
Redux-saga-test-plan library explained above to test our saga functionalities
..
Assume your saga function is defined as the
following.
import { put, takeEvery, all, call } from 'redux-saga/effects'
export function* fetchNetworkLibrary(action: LoadNetworkLibraryAction) {
try {
const data = yield call(
NetworkLibraryService.fetchNetworkLibrary,
action.LibraryKey
)
yield put({
type: NetworkLibraryActions.LOAD_COMPLETED,
networkLibrary: data
}
)
} catch (error) {
yield put({ type: 'ERROR_FETCHING_NP', error: error })
}
}
Now we need to apply test for this saga.
We will need for each saga function to
implement test for both SUCCESS and ERROR Flows ..
1- Create test file with name for
example “networkLibrary.test.js”
2- We create file “.js” to
implement our test.
3- Note that file is JavaScript so
you can’t use any typescript types defined on the project directly.
4- Also, the file name should
contain “.test” suffix so that it can be considered as a test file otherwise you
will not find your file included in npm test files.
5- You will need to add the
following imports some of them are optional but the mandatory ones are the
following:
-
expectSaga, throwError
from 'redux-saga-test-plan'
- saga functions you need to test
- Reducer, services, actions need
to be included in your test
import { call } from 'redux-saga/effects'
import { expectSaga } from 'redux-saga-test-plan'
import * as matchers from 'redux-saga-test-plan/matchers'
import { throwError } from 'redux-saga-test-plan/providers'
import { push } from 'react-router-redux'
import { fetchNetworkLibrary, createNetworkLibrary, saveNetworkLibrary } from './networkLibrary'
import { networkLibraryReducer } from '../reducers/networkLibrary'
import { NetworkLibraryService } from '../api/NetworkLibraryService'
import { NetworkLibraryActions } from '../constants'
6- All your Saga test need to be
included in
describe(name,
fn)
jest
function that creates a block that groups together several related tests
in one "test suite".
7- Also, it is recommended to give
good description about what your test is doing.
describe('Network Library Saga', () => {
})
8- Define Any variables you need
for your test
const initialState = {
loadedNetworkLibrary: undefined
}
const fakeLibrary = { LibraryKey: 100, LibraryName: 'Library x', hostType: 'Ipv4' }
9- Start implementing your saga
test.
· SUCCESS FLOW
let’s start with Success Flow. it will be
as the following and we are going to explain what it is done in details:
it('test fetchNetworkLibrary
using saga - SUCCESS FLOW', () => {
return expectSaga(fetchNetworkLibrary, {
type: NetworkLibraryActions.LOAD,
LibraryKey: '100'
})
.withReducer(networkLibraryReducer) // Use reducer
.provide([
[
call(NetworkLibraryService.fetchNetworkLibrary, '100'),
fakeLibrary
] // call API NetworkLibraryService.fetchNetworkLibrary
with mocked data
])
.put({
// Checking if correct
action was executed
type: NetworkLibraryActions.LOAD_COMPLETED,
networkLibrary: fakeLibrary
})
.hasFinalState({
// checking if state in
Storage was changed
...initialState,
loadedNetworkLibrary: fakeLibrary
})
.run() // run Test
})
In the code above, you will find that it
tests what your saga function actually do.
First you use the
“expectSaga”
method and give it your saga function as
first parameter. any other parameters will be parameters to be passed for the
saga function itself.
As we see that our saga function we are
testing takes LoadNetworkLibraryAction
as parameter. so, we need to pass this
parameter as second parameter to
expectSaga
Method.
If you look at you will find it defined as
the following
export interface LoadNetworkLibraryAction {
type: NetworkLibraryActions.LOAD
LibraryKey: string
}
And As you are using the test within .js
file so you can’t use LoadNetworkLibraryAction
Type directly so you have to use it is
object definition as the following
{
type: NetworkLibraryActions.LOAD,
LibraryKey: '100'
}
So, your
expectsaga
method usage
will be as the following
expectSaga(fetchNetworkLibrary, {
type: NetworkLibraryActions.LOAD,
LibraryKey: '100'
})
Then call
.withReducer()
Method and give it you reducer as parameter
so that you can verify your reducer behavior also during your test ..
.withReducer(networkLibraryReducer) // Use reducer
Now we need to test the part our saga
function do for calling the API to fetch data .. in this case we are going to
mock this call by using the provider as the following
.provide([
[
call(NetworkLibraryService.fetchNetworkLibrary, '100'),
fakeLibrary
] // call API NetworkLibraryService.fetchNetworkLibrary
with mocked data
])
Note that this is not actual call for Api.
it is just mocking and we simulate calling Api and pass data needed for it by
call(NetworkLibraryService.fetchNetworkLibrary,
'100')
then define the returned data from this
mockup is our predefined variable
fakeLibrary
.
Now we are going to test the next part from
saga function that dispatching the
LOAD_COMPLETED
action with the data as the following.
.put({
// Checking if correct
action was executed
type: NetworkLibraryActions.LOAD_COMPLETED,
networkLibrary: fakeLibrary
})
Last step is validating the state if it
returns the right object with the right changed values .
.hasFinalState({
// checking if state in
Storage was changed
...initialState,
loadedNetworkLibrary: fakeLibrary
})
Then finally run your test by
calling
.run()
Method
· ERROR FLOW
Now let’s go throw the Error Flow. you will
find it is sequence is same as described above but instead of testing the method
functionality itself you will test the Error Path for your saga method.
In our saga function in case there is any
errors the catch block will catch it and start dispatching
‘ERROR_FETCHING_NP'
with your Error as the following
} catch (error) {
yield put({ type: 'ERROR_FETCHING_NP', error: error })
}
}
Now we need to write test for this part.
Our test will be as the following and we are going to explain what it is done
in details:
it('test fetchNetworkLibrary
using saga - ERROR FLOW', () => {
const error = new Error('error');
return expectSaga(fetchNetworkLibrary, {
type: NetworkLibraryActions.LOAD,
LibraryKey: '100'
})
.withReducer(networkLibraryReducer) // Use reducer
.provide([
[matchers.call.fn(NetworkLibraryService.fetchNetworkLibrary), throwError(error)],
])
.put({
type: 'ERROR_FETCHING_NP',
error: error
})
.hasFinalState({
...initialState
})
.run();
})
})
In the code above you will find that first
thing we defined our test error variable ..
const error = new Error('error');
use the
“expectSaga”
method and
give it your saga function as first parameter. any other parameters will be
parameters to be passed for the saga function itself.
As we see that our saga function we are
testing takes LoadNetworkLibraryAction as parameter. So, we need to pass this
parameter as second parameter to
expectSaga
Method.
If you look at you will find it defined as
the following
export interface LoadNetworkLibraryAction {
type: NetworkLibraryActions.LOAD
LibraryKey: string
}
And As you are using the test within .js
file so you can’t use LoadNetworkLibraryAction
Type directly so you should use it is
object definition as the following
{
type: NetworkLibraryActions.LOAD,
LibraryKey: '100'
}
So, your
expectsaga
method usage will be as the following
expectSaga(fetchNetworkLibrary, {
type: NetworkLibraryActions.LOAD,
LibraryKey: '100'
})
Then call
.withReducer()
Method and give it you reducer as parameter
so that you can verify your reducer behavior also during your test ..
.withReducer(networkLibraryReducer) // Use reducer
Now we need to test the part our saga
function do for calling the API to fetch data. in this case we are going to
mock this call by using the provider as the following
.provide([
[matchers.call.fn(NetworkLibraryService.fetchNetworkLibrary), throwError(error)],
])
Note that this is not actual call for API.
it is just mocking and we simulate calling API then define that it is throw
Exception with the Error you defined.
Now we are going to test the next part from
saga function that catching the exception and dispatching the
'ERROR_FETCHING_NP'
action with the error as the following.
.put({
type: 'ERROR_FETCHING_NP',
error: error
})
As there is no change in state so validate
state should be validating that there is no change done in the state by the
following.
.hasFinalState({
...initialState
})
Then finally run your test by calling
.run()
Method
Now your test is done for tour saga
function.
Full Example:
import { call } from 'redux-saga/effects'
import { expectSaga } from 'redux-saga-test-plan'
import * as matchers from 'redux-saga-test-plan/matchers'
import { throwError } from 'redux-saga-test-plan/providers'
import { push } from 'react-router-redux'
import { fetchNetworkLibrary, createNetworkLibrary, saveNetworkLibrary } from './networkLibrary'
import { networkLibraryReducer } from '../reducers/networkLibrary'
import { NetworkLibraryService } from '../api/NetworkLibraryService'
import { NetworkLibraryActions } from '../constants'
describe('Network Library Saga', () => {
const initialState = {
loadedNetworkLibrary: undefined
}
const fakeLibrary = { LibraryKey: 100, LibraryName: 'Library x', hostType: 'Ipv4' }
it('test fetchNetworkLibrary
using saga - SUCCESS FLOW', () => {
return expectSaga(fetchNetworkLibrary, {
type: NetworkLibraryActions.LOAD,
LibraryKey: '100'
})
.withReducer(networkLibraryReducer) // Use reducer
.provide([
[
call(NetworkLibraryService.fetchNetworkLibrary, '100'),
fakeLibrary
] // call API NetworkLibraryService.fetchNetworkLibrary
with mocked data
])
.put({
// Checking if correct
action was executed
type: NetworkLibraryActions.LOAD_COMPLETED,
networkLibrary: fakeLibrary
})
.hasFinalState({
// checking if state in
Storage was changed
...initialState,
loadedNetworkLibrary: fakeLibrary
})
.run() // run Test
})
it('test fetchNetworkLibrary
using saga - ERROR FLOW', () => {
const error = new Error('error');
return expectSaga(fetchNetworkLibrary, {
type: NetworkLibraryActions.LOAD,
LibraryKey: '100'
})
.withReducer(networkLibraryReducer) // Use reducer
.provide([
[matchers.call.fn(NetworkLibraryService.fetchNetworkLibrary), throwError(error)],
])
.put({
type: 'ERROR_FETCHING_NP',
error: error
})
.hasFinalState({
...initialState
})
.run();
})
})
References:
Comments
Post a Comment