How to test redux saga with jest?
Asked Answered
A

2

13

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.

Aubert answered 30/12, 2017 at 10:46 Comment(5)
The error is not in initTodosSaga, it's in deleteTodoSaga, can you edit your question to include the code of that saga and corresponding test, please?Spectatress
@PatrickHund ok question edited, I am not sure why the deleteTodoSaga is being complained here by jest as I have not created a test for that saga yet. Also the deleteTodoSaga is not yet refactored, I am planning to pass everything on the action payload so that it is easy to test the saga, coz almost everything the saga needs is already in the action (dependency injection), hence, what I did in initTodosSaga.Aubert
@PatrickHund I've edited the question, I just replaced the image screenshot above. I have commented out all other worker sagas (and its usages) except for the initTodosSaga to avoid confusion. The screenshot now shows that it is complaining in the initTodosSaga. I've rerun the test and indeed it still complains about action being undefined, now inside the initTodosSagaAubert
Alright, I'm afk right now, hopefully can take a look laterSpectatress
Thanks very much, really appreciate it, really eager to learn. Happy Holidays mate.Aubert
A
1

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

Aubert answered 31/12, 2017 at 6:52 Comment(6)
I'm glad you figured it out, I've posted an answer without the redux-saga-test-plan, which I wasn't aware of. I've learned something today! 😀Spectatress
The error message “Error: Not implemented: window.alert” is because the Jest tests are not running in a browser, but are run with Node.js using JSDom (github.com/tmpvar/jsdom), which doesn't have an alert functionSpectatress
Ah I see, ok so no way to hide this on the log then? anyways thanks for the info.Aubert
I'm not sure, with the version that I have written (see my answer), I don't have this problem, but that may also be because of the way I have set up my test.Spectatress
you could try something like window.alert = jest.fn();Spectatress
Yes @PatrickHund adding the window.alert = jest.fn(); inside my tests removed that console error, Thanks mate, took me a while to reply, been afk for a while, half to handle some personal matters.Aubert
S
2

Here is a working version of your test:

import todos from '../../__fixtures__/todos';
import { initTodosSaga } from '../todosSaga';
import { put, call } from 'redux-saga/effects';

test('saga test', () => {
    const response = {
        data: {
            status: 'success',
            todos
        }
    };
    const action = {
        axios: {
            get() {}
        },
        WP_GET_TODOS: 'dummy url',
        setAppInIdle: jest.fn().mockReturnValue({ type: 'setAppInIdle' }),
        setAppInProcessing: jest.fn().mockReturnValue({ type: 'setAppInProcessing' }),
        todosInitialized: jest.fn().mockReturnValue({ type: 'todosInitialized' })
    };
    let result;

    const initTodosSagaGen = initTodosSaga(action);

    result = initTodosSagaGen.next();
    expect(result.value).toEqual(put(action.setAppInProcessing()));
    expect(action.setAppInProcessing).toHaveBeenCalled();
    result = initTodosSagaGen.next();
    expect(result.value).toEqual(call(action.axios.get, action.WP_GET_TODOS));
    result = initTodosSagaGen.next(response);
    expect(action.todosInitialized).toHaveBeenCalled();
    expect(result.value).toEqual(put(action.todosInitialized(response.data.todos)));
    result = initTodosSagaGen.next();
    expect(action.setAppInIdle).toHaveBeenCalled();
    expect(result.value).toEqual(put(action.setAppInIdle()));
});

Some notes:

  • You don't actually have to let the mock Axios.get return anything
  • With the expect statements, I'm comparing the yield of the generator to what I expect the generator to do (i.e. execute put and call statements)
  • The data property was missing from your mock response
Spectatress answered 31/12, 2017 at 11:4 Comment(2)
Thanks for this man! I'll check this out later, appreciate it.Aubert
Isn't it a little silly to basically copy the saga and assert every yield is equal to its only possible action? It's basically implementation testing that will always be successful.Acting
A
1

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

Aubert answered 31/12, 2017 at 6:52 Comment(6)
I'm glad you figured it out, I've posted an answer without the redux-saga-test-plan, which I wasn't aware of. I've learned something today! 😀Spectatress
The error message “Error: Not implemented: window.alert” is because the Jest tests are not running in a browser, but are run with Node.js using JSDom (github.com/tmpvar/jsdom), which doesn't have an alert functionSpectatress
Ah I see, ok so no way to hide this on the log then? anyways thanks for the info.Aubert
I'm not sure, with the version that I have written (see my answer), I don't have this problem, but that may also be because of the way I have set up my test.Spectatress
you could try something like window.alert = jest.fn();Spectatress
Yes @PatrickHund adding the window.alert = jest.fn(); inside my tests removed that console error, Thanks mate, took me a while to reply, been afk for a while, half to handle some personal matters.Aubert

© 2022 - 2024 — McMap. All rights reserved.