Metamorphosis of testing redux-saga
The framework
Take the simplest clicker as an example. The data flow and the meaning of the application will be as follows:
In our work, we use Typescript, so all the examples will be in this language.
As you may have already guessed, we will implement all this with the help
In this simple example, we declare a saga
So we have the simplest saga. It sends a request to the server
For testing, we need to lock the server call and somehow check whether exactly what came from the server went into the reducer.
Since sagas are generator functions, the most obvious way to test would be the method
The test was concise, but what does it test? In fact, it simply repeats the code of the saga method, that is, any change in the saga will have to change the test.
After encountering this problem, we decided to google it and suddenly realized that we were not the only ones and far from the first. Directly in the documentation for
From the proposed list we took the library
The test constructor in
Using the method,
In the block
The block
It all ends with a method
First, we fix the last one: we make a state test from a behavior test. This fact will help us, which
In this test, we no longer verify that any effects were triggered. We check the final state after execution, and that’s fine.
We managed to get rid of the implementation of the saga, now let's try to make the test more understandable. This is easy if replaced
But what if we also got a reverse click operation (let's call it unclick), and now our sag file looks like this:
Suppose we need to test that when the click and unclick actions are called in state, the result of the last trip to the server is written to state. Such a test can also be easily done with
Please note that now we are testing
However, if we run this test as is, we will get the corning:
This is due to the effect
Where without pitfalls ... At the time of this writing, the latest version of redux-saga: 1.0.2. At the same time,
If you want TypeScript, you have to install the version from the beta channel:
and turn off the tests from the build. To do this, in the tsconfig.json file, you need to specify the path "./src/**/*.spec.ts" in the "exclude" field.
Despite this, we consider
The source code of the example on GitHub .
redux-saga
provides a bunch of interesting patterns for working with side effects, but as true bloody enterprise developers, we need to cover our entire code with tests. Let's figure out how we will test our sagas. Take the simplest clicker as an example. The data flow and the meaning of the application will be as follows:
- The user pokes a button.
- A request is sent to the server, informing that the user has poked a button.
- The server returns the number of clicks made.
- The state records the number of clicks made.
- The UI is updated, and the user sees that the number of clicks has increased.
- ...
- PROFIT.
In our work, we use Typescript, so all the examples will be in this language.
As you may have already guessed, we will implement all this with the help
redux-saga
. Here is the code for the entire sagas file:export function* processClick() {
const result = yield call(ServerApi.SendClick)
yield put(Actions.clickSuccess(result))
}
export function* watchClick() {
yield takeEvery(ActionTypes.CLICK, processClick)
}
In this simple example, we declare a saga
processClick
that directly processes the action and a saga watchClick
that creates the processing loop action’ов
.Generators
So we have the simplest saga. It sends a request to the server
(эффект call)
, receives the result and passes it to reducer (эффект put)
. We need to somehow test whether the saga is transmitting exactly what it receives from the server. Let's get started. For testing, we need to lock the server call and somehow check whether exactly what came from the server went into the reducer.
Since sagas are generator functions, the most obvious way to test would be the method
next()
found in the prototype generator. When using this method, we have the opportunity to both receive the next value from the generator and transfer the value to the generator. Thus, we get out of the box the opportunity to get wet calls. But is everything so rosy? Here is a test I wrote on bare generators:it('should increment click counter (behaviour test)', () => {
const saga = processClick()
expect(saga.next().value).toEqual(call(ServerApi.SendClick))
expect(saga.next(10).value).toEqual(put(Actions.clickSuccess(10)))
})
The test was concise, but what does it test? In fact, it simply repeats the code of the saga method, that is, any change in the saga will have to change the test.
Such a test does not help in development.
Redux-saga-test-plan
After encountering this problem, we decided to google it and suddenly realized that we were not the only ones and far from the first. Directly in the documentation for
redux-saga
developers, they offer a look at several libraries created specifically to satisfy testing fans. From the proposed list we took the library
redux-saga-test-plan
. Here is the code for the first version of the test I wrote with it:it('should increment click counter (behaviour test with test-plan)', () => {
return expectSaga(processClick)
.provide([
call(ServerApi.SendClick), 2]
])
.dispatch(Actions.click())
.call(ServerApi.SendClick)
.put(Actions.clickSuccess(2))
.run()
})
The test constructor in
redux-saga-test-plan
is a function expectSaga
that returns the interface that describes the test. The test saga ( processClick
from the first listing) is passed to the function itself . Using the method,
provide
you can block server calls or other dependencies. An array from is passed to it StaticProvider’ов
, which describe which method should return. In the block
Act
we have one single method - dispatch
. An action is passed to it, to which the saga will respond. The block
assert
consists of methods call и put
that check whether the corresponding effects were caused during the work of the saga. It all ends with a method
run()
. This method runs the test directly.The advantages of this approach:
- It checks if the method was called, and not the sequence of calls;
- moki clearly describe which function gets wet and what returns.
However, there is work to do:
- there is more code;
- the test is difficult to read;
- this is a test for behavior, which means that it is still connected with the implementation of the saga.
The last two strokes
Condition test
First, we fix the last one: we make a state test from a behavior test. This fact will help us, which
test-plan
allows us to set the initial state
and transmit reducer
, which should respond to the effects put
generated by the saga. It looks like this:it('should increment click counter (state test with test-plan)', () => {
const initialState = {
clickCount: 11,
return expectSaga(processClick)
.provide([
call(ServerApi.SendClick), 14]
])
.withReducer(rootReducer, initialState)
.dispatch(Actions.click())
.run()
.then(result => expect(result.storeState.clickCount).toBe(14))
})
In this test, we no longer verify that any effects were triggered. We check the final state after execution, and that’s fine.
We managed to get rid of the implementation of the saga, now let's try to make the test more understandable. This is easy if replaced
then()
by async/await
:it('should increment click counter (state test with test-plan async-way)', async () => {
const initialState = {
clickCount: 11,
}
const saga = expectSaga(processClick)
.provide([
call(ServerApi.SendClick), 14]
])
.withReducer(rootReducer, initialState)
const result = await saga.dispatch(Actions.click()).run()
expect(result.storeState.clickCount).toBe(14)
})
Integration tests
But what if we also got a reverse click operation (let's call it unclick), and now our sag file looks like this:
export function* processClick() {
const result = yield call(ServerApi.SendClick)
yield put(Actions.clickSuccess(result))
}
export function* processUnclick() {
const result = yield call(ServerApi.SendUnclick)
yield put(Actions.clickSuccess(result))
}
function* watchClick() {
yield takeEvery(ActionTypes.CLICK, processClick)
}
function* watchUnclick() {
yield takeEvery(ActionTypes.UNCLICK, processUnclick)
}
export default function* mainSaga() {
yield all([watchClick(), watchUnclick()])
}
Suppose we need to test that when the click and unclick actions are called in state, the result of the last trip to the server is written to state. Such a test can also be easily done with
redux-saga-test-plan
:it('should change click counter (integration test)', async () => {
const initialState = {
clickCount: 11,
}
const saga = expectSaga(mainSaga)
.provide([
call(ServerApi.SendClick), 14],
call(ServerApi.SendUnclick), 18]
])
.withReducer(rootReducer, initialState)
const result = await saga
.dispatch(Actions.click())
.dispatch(Actions.unclick())
.run()
expect(result.storeState.clickCount).toBe(18)
})
Please note that now we are testing
mainSaga
, not individual action handlers. However, if we run this test as is, we will get the corning:
This is due to the effect
takeEvery
- this is a message processing cycle that will work while our application is open. Accordingly, a test in which it is called takeEvery
cannot complete the work without outside help, and redux-saga-test-plan
forcibly terminates the operation of such effects 250 ms after the start of the test. This timeout can be changed by calling expectSaga.DEFAULT_TIMEOUT = 50.If you do not want to receive such vorings, one for each test with a complex effect, just use the method instead of therun()
methodsilentRun()
.
Underwater rocks
Where without pitfalls ... At the time of this writing, the latest version of redux-saga: 1.0.2. At the same time,
redux-saga-test-plan
so far he only knows how to work with it on JS. If you want TypeScript, you have to install the version from the beta channel:
npm install redux-saga-test-plan@beta
and turn off the tests from the build. To do this, in the tsconfig.json file, you need to specify the path "./src/**/*.spec.ts" in the "exclude" field.
Despite this, we consider
redux-saga-test-plan
the best library for testing redux-saga
. If you have a project redux-saga
, perhaps it will be a good choice for you. The source code of the example on GitHub .