Introduction
Hello friends. Unit testing has long been an indispensable part of testing the software we write as good as expected or not. Side-effect related functions such as API requests should be written as tests because of the importance of strict logic. For native React apps, we often use the Redux saga middleware to handle this side-effect. There are many methods for writing unit tests with saga. Today I will introduce quite simple methods, using only very basic methods and libraries from redux-saga
and Jest
.
A bit about the redux-saga and the generator function
If you forgot the redux-saga, you can review it here
When using redux-saga, we will declare the saga in the generator function ( function*
). That type of function can be executed, pause returns results and continue to execute thanks to the keyword “yield”. The Generator function returns an iterator
object that has a next()
to get the results returned at the point where the iterator is paused.
1 2 3 4 5 | { value: Any, done: true|false } |
As a result, the generator function has the ability to pause before terminating and can continue to run at another time. For example, if you need to get any value in the store before calling the API, or read some file in the app’s directory and then go to call the API, this time the generator function helps us to catch the disagreement. jogging synchronously. So when testing we can also call to check the results in the order we want.
Jest
Jest is a Javascript library, and is seen as the default test library for React apps. Although Jest can also be used to test UI components, in this article I will only talk about functional testing. In addition to providing a mechanism to call a defined function, Jest also helps us to fake functions referenced from a module or a file. If your test function calls some other asynchronous function, fake support of these asynchronous functions will help you temporarily pass them more focus on the function you are testing. There are many documents written about Jest, you can find and read more with Jest library here
Just rambling like that, now let’s go to the main part.
Test a simple generator function
Let’s take the simplest example for the generator function as follows:
1 2 3 4 5 | function* myGenerator(i) { yield i; // line 1 yield i + 10; // line 2 } |
This function has 2 keywords yield
. When declaring an iterator object to call that function with the syntax:
1 2 | const testGenerator = myGenerator(5) |
At this point, the function has not started to execute. We have to call the iterator’s next () method, then it will actually run until it comes to yield, it will stop at that statement.So The first time the function will stop at line 1 and return the main result is i
, the next call next () will return i + 10
and still stop at line 2. We have to call next()
again, this time the iteratior object will return done
results at the same time. This generator will terminate.
Then we check is also very simple as follows:
1 2 3 4 5 6 7 | it('myGenerator test', () => { const testGenerator = myGenerator(5) expect(testGenerator.next().value).toEqual(5) expect(testGenerator.next().value).toEqual(15) expect(testGenerator.next().done).toBeTruthy() }) |
Test a watcher of a Saga
One of our familiar syntax after defining a saga is that we will need a watcher to observe an action in order to execute the saga. Let’s say we have a saga calling API that gets a list of devices. The first are the actions for this request as follows:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | export const onGetListDeviceAction = (categoryId) => { return { type: 'GET_LIST_DEVICE', categoryId }; }; export const getListDeviceSuccessAction = (devices) => { return { type: 'GET_LIST_DEVICE_SUCCESS', devices }; }; export const getListDeviceErrorAction = () => { return { type: 'GET_LIST_DEVICE_ERROR' }; }; |
Now we have a watcher observing the action type GET_LIST_DEVICE
and a saga that executes the API call, if successful we will put action getListDeviceSuccessAction()
and if an error occurs we will put action getListDeviceErrorAction()
. Here we are assuming that the Api.requestDevices () function is a promise
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | import { put, takeEvery } from 'redux-saga/effects'; import { getListDeviceSuccessAction, getListDeviceErrorAction } from './actions'; import Api from './api'; function* getListDevice({ categoryId }) { try { const devices = yield Api.requestDevices(categoryId); yield put(getListDeviceSuccessAction(devices)); } catch (error) { yield put(getListDeviceErrorAction()); } } export default function* deviceListSaga() { yield takeLatest('GET_LIST_DEVICE', getListDevice); } |
Now we will see if watcher deviceListSaga
observes the GET_LIST_DEVICE
action and calls getListDevice
function.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | import { takeLatest } from 'redux-saga/effects'; import deviceListSaga, { getListDevice } from './saga'; describe('test deviceListSaga watcher', () => { const genObject = deviceListSaga(); it('should wait for latest GET_LIST_DEVICE action and call getListDevice', () => { const generator = genObject.next(); expect(generator.value).toEqual( takeLatest('GET_LIST_DEVICE', getListDevice), ); }); it('should be done on next iteration', () => { expect(genObject.next().done).toBeTruthy(); }); }); |
Because our watcher is just a generator function, when initialized
1 2 | const genObject = deviceListSaga(); |
then nothing has happened yet. When we call next the first time, it will execute and will return the value which is the takeLatest
effect and not finished. The second call next () actually terminates this function.
Test an entire Saga
Now, we need to run the getListDevice
saga (still just a generator function). When running a generator function, meeting the yield keyword, the iterator will stop, so now we will use the runSaga method of redux-saga itself to run the entire thread of getListDevice
. However, when running to yield requestDevices(categoryId)
, because this is a promise, the iterator will not know what result will get. To make sure the entire thread runs out, we have to make requestDevices()
pass through. In the example above, the promise requestDevices(categoryId)
is either successful or an error occurs. We’ll test the success case first, by requestDevices(categoryId)
Promise requestDevices(categoryId)
returns a resolve ():
1 2 3 4 5 6 7 8 9 | import * as api from './api'; requestDevices = jest .spyOn(api, 'requestDevices') .mockImplementation(() => { return Promise.resolve(devices); }); }); |
And now let’s examine all of the saga together. The syntax of runSaga( options, saga, …args )
includes:
options
: is an object that helps us define options such as dispatch, getState, channel …saga
: is the saga we want to testargs
: the parameter for the saga we need to test
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 | import { runSaga } from 'redux-saga'; import { getListDevice } from './saga'; import { requestDevices } from './api'; import { devices } from './__mocks__/devices'; import { getListDeviceSuccessAction } from './actions'; const args = { categoryId: 1 }; describe('getListDevice success', () => { let requestDevices; beforeEach(() => { requestDevices = jest .spyOn(api, 'requestDevices') .mockImplementation(() => { return Promise.resolve(devices); }); }); it('should call API and dispatch success action', async (done) => { const dispatched = []; await runSaga( { dispatch: (action) => dispatched.push(action) }, getListDevice, args, ).toPromise(); expect(requestDevices).toHaveBeenCalledTimes(1); expect(dispatched).toEqual([getListDeviceSuccessAction(devices)]); done(); }, 500); afterEach(() => { requestDevices.mockClear(); }); }); |
We will wait for the runSaga to finish running in an async function before going to check the desired results. In this example, we have configured the option:
1 2 | { dispatch: (action) => dispatched.push(action) } |
Since the effect put(action)
is the dispatch(action)
of redux, when running saga, if any effect put
aciton is called, we will save that action in the array and then end the process then check if the desired action is obtained. call or not. And after the runSaga finishes, we test the action called by
1 2 | expect(dispatched).toEqual([getListDeviceSuccessAction(devices)]) |
If you want to mock Promise requestDevices(categoryId)
return an error, we just need as simple as follows:
1 2 3 4 5 6 7 8 9 10 | requestDevices = jest .spyOn(api, 'requestDevices') .mockImplementation(() => { return Promise.reject(); }); // rồi kiểm tra... expect(dispatched).toEqual([getListDeviceErrorAction()]); |
Among the saga you will probably refer to the store
to get something value before proceeding. So now we will call runSaga
with the option to add getState
to ensure the entire thread will run as expected.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | function* getListDevice({ categoryId, }) { try { const deviceState = yield select(deviceState); const { isLoading } = deviceState // do something } catch (error) { } } await runSaga({ dispatch: (action) => dispatched.push(action), getState: () => ({ deviceState: { isLoading: true } }), }, getListDevice, args, ).toPromise(); |
You probably didn’t know it yet – when used with typescript
Error when not pressed for saga style
To make it more concise, the above code I have not written Typescript. If the project you use Typescript, then please manually modify and declare the type for the syntax in the saga. And if you call runSaga with the above syntax and get the type error in your saga as follows Argument of type '({ <arg> }: <Action>) => Generator<Promise<any> | PutEffect<> | PutEffect<>, void, [...]>' is not assignable to parameter of type 'Saga<any[]>'.
Then press your saga into Saga<any[]>
now
1 2 3 4 5 6 | await runSaga( { dispatch: (action) => dispatched.push(action) }, getListDevice as Saga<any[]>, // <- ép kiểu saga của bạn ở đây args, ).toPromise(); |
How to test a saga containing another saga?
I am sure that in your saga processing, you will need to call another saga (or generator function), such as the following
1 2 3 4 5 6 7 8 9 10 | export function* editDevice({ deviceId) { try { const deviceToSave = yield removeImage(deviceId); // removeImage là một saga nào đó của bạn // continue to do something } catch (err) { // handle error } } |
With the generator function removeImage
is declared somewhere in your project:
1 2 3 4 5 | export function* removeImage(deviceId: number) { // do something return deviceObject; } |
At this time removeImage()
is a generator function you write, not Promise as the original example. No matter how many other saga calls in this saga, in short, you just need to pass through the commands that call them to continue running our main Saga. So instead of mocking a Promise, we also just need to mock the saga to return the expected results, right? Now I will declare the removeImage
mock so that the editDevice
saga editDevice
run smoothly:
1 2 3 4 5 6 | function* mockRemoveImage( // Khai báo 1 generator function deviceId: number, ): Generator<any, any, unknown> { return {id: 99, name: 'Test Device'}; } |
Then continue to simply use jest to mock Saga only
1 2 3 4 5 6 7 8 | beforeEach(() => { jest .spyOn(yourFileIncludeRemoveImageFunction, 'removeImage') .mockImplementation(() => { return mockRemoveImage(99); // return về generator function đã khai báo ở trên }); }); |
Conclude
This post is based on my team’s personal experience applied to the react-native project. Hope this post can be of some help for you in unit test array with redux-saga for react app. In the article, if you make a mistake, please point it to me. Thank you for reading.
Reference source:
https://medium.com/@13gaurab/unit-testing-sagas-with-jest-29a8bcfca028