⬆️ ⬇️

Testing saga: library redux-saga-test-plan

The redux-saga framework provides a bunch of interesting patterns for working with side effects, but as true bloody-enterprise developers, we have to cover all our code with tests. Let's see how we test our sagas.







Take the simplest clicker as an example. The data flow and the meaning of the application will be as follows:



  1. User pokes a button.
  2. A request is sent to the server informing that the user has clicked a button.
  3. The server returns the number of clicks made.
  4. The number of clicks made is recorded in the state.
  5. The UI is updated, and the user sees that the number of clicks has increased.
  6. ...
  7. PROFIT.


In our work we use Typescript, so all the examples will be in this language.

')

As you have probably already guessed, we will implement all of this with redux-saga . I will give here the code of the whole saga 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 processClick saga, which directly processes the action and the watchClick saga, which creates a cycle of action processing.



Generators



So, we have the simplest saga. It sends a request to the server ( call) , receives the result and sends it to the reducer ( put) . We need to somehow test whether the saga sends exactly what it receives from the server. Let's get started



For testing, we will need to lock the server call and somehow check whether the reducer went exactly what came from the server.



Since sagas are function-generators, the most obvious way to test will be the next() method, which is in the prototype generator. When using this method, we have the ability to both get the next value from the generator, and transfer the value to the generator. In this way, we get a chance to get out of the box. But is everything so rosy? Here is a test that 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 is concise, but what does it test? In fact, he simply repeats the code of the method of the saga, that is, with any change of the saga will have to change and test.



Such a test does not help in the development.


Redux-saga-test-plan



After facing this problem, we decided to google it and suddenly realized that we were not the only ones and not the first. Right in the documentation for redux-saga developers offer a look at several libraries created specifically to satisfy testing fans.



From the list we took the library redux-saga-test-plan . Here is the code for the first version of the test that I wrote with its help:



 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's constructor in redux-saga-test-plan is the expectSaga function, which returns an interface that describes the test. The tested saga is passed to the function itself ( processClick from the first listing).



Using the provide method, you can block server calls or other dependencies. An array from StaticProvider' transferred to it, which describe which method should return.



In the Act block, we have one single method - dispatch . The action is passed to it, to which the saga will react.



The assert block consists of the call put methods, which check whether the corresponding effects were caused during the saga.



This ends with the run() method. This method starts the test directly.



Advantages of this approach:



  • checks if the method was called, not the sequence of calls;
  • moki clearly describe what function is mocking and what is returned.


However, there is work to do:



  • the code has become more;
  • test difficult to read;
  • This is a test of behavior, which means it is still associated with the implementation of the saga.


Last two strokes



State test



First, fix the last: we will make a test for the state from the test for behavior. This will be helped by the fact that the test-plan allows us to specify an initial state and pass a reducer that must respond to the effects of 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 caused. We check the final state after the execution, and that's fine.



We managed to get rid of the implementation of the saga, now we will try to make the test more understandable. This is easy if you replace then() with 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



And what if we also had a reverse click operation (let's call it unclick), and now our saga 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 a successive invocation of the click and unclick action actions in the state will record the result of the last hike to the server. Such a test can also be easily done with the help of 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, now we are testing mainSaga , not individual mainSaga handlers.



However, if we run this test as it is, we get a Vorning:







This is due to the takeEvery effect - this is a message loop that will work while our application is open. Accordingly, a test in which takeEvery is called takeEvery not complete work without assistance, and the redux-saga-test-plan forcibly terminates 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 versions, one for each test with a complex effect, simply use the silentRun() method instead of the run() method.





Underwater rocks



Where without the pitfalls ... At the time of this writing, the latest version of redux-saga: 1.0.2. At the same time, redux-saga-test-plan is able to work with it only on JS so far.



If you want TypeScript, you will have to install a version from the beta channel:

npm install redux-saga-test-plan@beta

and turn off tests from build. To do this, you need to enter the path "./src/**/*.spec.ts" in the "exclude" field in the tsconfig.json file.



Despite this, we consider redux-saga-test-plan the best library for testing redux-saga . If you have redux-saga in the project, it may be a good choice for you.



The source code of the example on GitHub .

Source: https://habr.com/ru/post/458500/



All Articles