How to test redux saga with jest?

Jplus2 picture Jplus2 · Dec 30, 2017 · Viewed 12.6k times · Source

Just new in react , react-redux/saga and jest

consider:

-----The Componnent ()----

componentDidMount() {

    this.props.actions.initTodos(
        axios,
        ajaxURLConstants.WP_GET_TODOS,
        appStateActions.setAppInIdle,
        appStateActions.setAppInProcessing,
        todosActions.todosInitialized
    );

}

So when my TodoApp component did mount, it will dispatch the INIT_TODOS action which then my root saga is listening , and when it caught it, will spawn the appropriate worker saga to act accordingly.

-----The Corresponding Worker Saga-----

export function* initTodosSaga( action ) {

    try {

        yield put( action.setAppInProcessing() );

        let response = yield call( action.axios.get , action.WP_GET_TODOS );

        if ( response.data.status === "success" )
            yield put( action.todosInitialized( response.data.todos ) );
        else {

            console.log( response );
            alert( response.data.error_msg );

        }

    } catch ( error ) {

        console.log( "error" , error );
        alert( "Failed to load initial data" );            

    }

    yield put( action.setAppInIdle() );

}

-----The Test So Far-----

import todos             from "../../__fixtures__/todos";
import { initTodosSaga } from "../todosSaga";

test( "saga test" , () => {

    let response = {
            status : "success",
            todos
        },
        action = {
            axios : {
                get : function() {

                    return new Promise( ( resolve , reject ) => {

                        resolve( response );

                    } );

                }
            },
            WP_GET_TODOS       : "dummy url",
            setAppInIdle       : jest.fn(),
            setAppInProcessing : jest.fn(),
            todosInitialized   : jest.fn()
        };

    let initTodosSagaGen = initTodosSaga( action );

    initTodosSagaGen.next();

    expect( action.setAppInIdle ).toHaveBeenCalled();

} );

-----The Test Result-----

enter image description here

So the important part is this

console.error node_modules\redux-saga\lib\internal\utils.js:240

uncaught at check put(action): argument action is undefined

but I have console.log the action i passed on my test inside the worker saga and indeed it is not undefined

what am I missing?

Thanks in advance.

----------Update------------

Ok notice on the top that it is complaining on this line of code

yield put( action.setAppInIdle() );

Which is outside the try catch block , so i made a couple of changes

1.) I moved the code above inside the try catch block, just after the else statement of

if ( response.data.status === "success" )

please check initTodosSaga code above

Then on my saga test, i test for

expect( action.setAppInProcessing ).toHaveBeenCalled();

instead of the setAppInIdle spy function

and this is the test result

enter image description here

so the test passed! but still it is complaining about the action being undefined

now what is interesting is if in my saga test, if I test for this now

expect( action.setAppInProcessing ).toHaveBeenCalled();
expect( action.setAppInIdle ).toHaveBeenCalled();

This is the result

enter image description here

so now it still complains about the action still undefined ( I have not included in my screenshot, but still same as above )

plus the second assert i have about the setAppInIdle spy function was not called, but the setAppInProcessing did pass!

I hope this additional info helps in resolving this question.

Answer

Jplus2 picture Jplus2 · Dec 31, 2017

It seems it is very difficult to test redux saga without any aid of an external library

For me I used https://github.com/jfairbank/redux-saga-test-plan

This library is very good.

So here is my tests now

--------------------Test 1---------------------

So for this test, I passed along the action payload almost everything the saga needs for it to function, ex. axios , action creator functions, etc... more like following the principle of dependency injection so its easy to test.

-----TodoApp Component-----

componentDidMount() {

    this.props.actions.initTodos(
        axios,
        ajaxURLConstants.WP_GET_TODOS,
        appStateActions.setAppInIdle,
        appStateActions.setAppInProcessing,
        todosActions.todosInitialized,
        todosActions.todosFailedInit
    );

}

So when the component did mount it fires an action that my root saga listens and catches and then spawns the appropriate worker saga to act accordingly

again notice I pass along all necessary data that the worker saga would need to operate properly on the actions payload.

-----initTodoSaga (Worker Saga)-----

export function* initTodosSaga( action ) {

    try {

        yield put( action.setAppInProcessing() );

        let response = yield call( action.axios.get , action.WP_GET_TODOS );

        if ( response.data.status === "success" )
            yield put( action.todosInitialized( response.data.todos ) );
        else {

            console.log( response );
            alert( response.data.error_msg );

            yield put( action.todosFailedInit( response ) );

        }

    } catch ( error ) {

        console.log( "error" , error );
        alert( "Failed to load initial data" );

        yield put( action.todosFailedInit( error ) );

    }

    yield put( action.setAppInIdle() );

}

-----Saga Test-----

import { expectSaga }    from "redux-saga-test-plan";
import { initTodosSaga } from "../todosSaga";

test( "should initialize the ToDos state via the initTodoSaga" , () => {

    let response = {

            data : {
                status : "success",
                todos
            }

        },
        action = {
            axios : {
                get : function() {

                    return new Promise( ( resolve , reject ) => {

                        resolve( response );

                    } );

                }
            },
            WP_GET_TODOS       : "dummy url",
            setAppInIdle       : appStateActions.setAppInIdle,
            setAppInProcessing : appStateActions.setAppInProcessing,
            todosInitialized   : todosStateActions.todosInitialized,
            todosFailedInit    : todosStateActions.todosFailedInit
        };

    // This is the important bit
    // These are the assertions
    // Basically saying that the actions below inside the put should be dispatched when this saga is executed
    return expectSaga( initTodosSaga , action )
        .put( appStateActions.setAppInProcessing() )
        .put( todosStateActions.todosInitialized( todos ) )
        .put( appStateActions.setAppInIdle() )
        .run();

} );

and my test pass yay! :) now to show you the error message when a test fails, I will comment out this line of code in my initTodosSaga

yield put( action.setAppInIdle() );

so now the assertion

.put( appStateActions.setAppInIdle() )

should fail now

enter image description here

so it outputs put expectation unmet which makes sense as the action we expected to be fired didn't

--------------------Test 2--------------------

Now this test is for a saga in which it imports some things it needs to operate unlike my First test where I feed axios, action creators inside the action payload

This saga imported axios, action creators it needs to operate

Thankfully Redux Saga Test Plan have some helper functions to "feed" dummy data into the saga

I will just skip the component that fires the action that the root saga is listening, its not important, I will just paste directly the saga and the saga test

----addTodoSaga----

/** global ajaxurl */
import axios                from "axios";
import { call , put }       from "redux-saga/effects";
import * as appStateActions from "../actions/appStateActions";
import * as todosActions    from "../actions/todosActions";

export function* addTodoSaga( action ) {

    try {

        yield put( appStateActions.setAppInProcessing() );

        let formData = new FormData;

        formData.append( "todo" , JSON.stringify( action.todo ) );

        let response = yield call( axios.post , ajaxurl + "?action=wptd_add_todo" , formData );

        if ( response.data.status === "success" ) {

            yield put( todosActions.todoAdded( action.todo ) );
            action.successCallback();

        } else {

            console.log( response );
            alert( response.data.error_msg );

        }

    } catch ( error ) {

        console.log( error );
        alert( "Failed to add new todo" );

    }

    yield put( appStateActions.setAppInIdle() );

}

-----The Test-----

import axios          from "axios";
import { expectSaga } from "redux-saga-test-plan";
import * as matchers  from "redux-saga-test-plan/matchers";
import * as appStateActions   from "../../actions/appStateActions";
import * as todosStateActions from "../../actions/todosActions";
import { addTodoSaga } from "../todosSaga";

test( "should dispatch TODO_ADDED action when adding new todo is successful" , () => {

   let response = {
            data : { status : "success" }
        },
        todo = {
            id        : 1,
            completed : false,
            title     : "Browse 9gag tonight"
        },
        action = {
            todo,
            successCallback : jest.fn()
        };

    // Here are the assertions
    return expectSaga( addTodoSaga , action )
        .provide( [
            [ matchers.call.fn( axios.post ) , response ]
        ] )
        .put( appStateActions.setAppInProcessing() )
        .put( todosStateActions.todoAdded( todo ) )
        .put( appStateActions.setAppInIdle() )
        .run();

} );

So the provide function allows you to mock a function call and at the same time provide dummy data that it should return

and that's it, I'm able to test now my sagas! yay!

one more thing, when I run a test for my saga that results in executing a code with alert code

ex.

alert( "Earth is not flat!" );

I got this on the console

Error: Not implemented: window.alert

and a bunch of stack trace below it, so maybe its because the alert object is not present on node? how do I hide this? just add on the comment if you guys have an answer.

I hope this helps anyone