Metamorphosis of testing redux-saga

    The framework redux-sagaprovides 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:

    1. The user pokes a button.
    2. A request is sent to the server, informing that the user has poked a button.
    3. The server returns the number of clicks made.
    4. The state records the number of clicks made.
    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 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 processClickthat directly processes the action and a saga watchClickthat 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-sagadevelopers, 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-planis a function expectSagathat returns the interface that describes the test. The test saga ( processClickfrom the first listing) is passed to the function itself .

    Using the method, provideyou can block server calls or other dependencies. An array from is passed to it StaticProvider’ов, which describe which method should return.

    In the block Actwe have one single method - dispatch. An action is passed to it, to which the saga will respond.

    The block assertconsists of methods call и putthat 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-planallows us to set the initial stateand transmit reducer, which should respond to the effects putgenerated 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 takeEverycannot complete the work without outside help, and redux-saga-test-planforcibly 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 the run()method silentRun().



    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-planso 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-planthe 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 .

    Also popular now: