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 puts 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

Popular posts from this blog

Logging w/ MetroLog in (UWP) Application

Redux Containers Unit Testing in React with TypeScript Projects