Unit and Integration Testing in Redux Saga by Example

hero image



Redux is an extremely useful state manager. Among the many "plugins", Redux-Saga is my favorite. On a React-Native project I'm currently working on, I had to deal with a lot of side effects. They would give me headaches if I put them in ingredients. With this tool, creating complex branching logical flows becomes a simple task. But what about testing? As easy as using a library? While I cannot give you an exact answer, I will show you a real-life example of the problems I am facing.



If you are not familiar with testing sagas, I recommend reading a separate page in the documentation. In the following examples I am using redux-saga-test-planas this library gives the full power of integration testing along with unit testing.



A little about unit testing



Unit testing is nothing more than testing a small piece of your system , usually a function, that needs to be isolated from other functions and, more importantly, from the API.



, . - API , . , , , , ( ).


//    
import {call, put, take} from "redux-saga/effects";

export function* initApp() {
    //    
    //    
    yield put(initializeStorage());
    yield take(STORAGE_SYNC.STORAGE_INITIALIZED);

    yield put(loadSession());
    let { session } = yield take(STORAGE_SYNC.STORAGE_SESSION_LOADED);

    //   
    if (session) {
        yield call(loadProject, { projectId: session.lastLoadedProjectId });
    } else {
        logger.info({message: "No session available"});
    }
}


//    
import {testSaga} from "redux-saga-test-plan";

it("      `loadProject`", () => {
    const projectId = 1;
    const mockSession = {
        lastLoadedProjectId: projectId
    };

    testSaga(initApp)
        // `next`       `yield`
        //      ,
        //      `yield`

        //       
        //(   -  )
        .next()
        .put(initializeStorage())

        .next()
        .take(STORAGE_SYNC.STORAGE_INITIALIZED)

        .next()
        .put(loadSession())

        .next()
        .take(STORAGE_SYNC.STORAGE_SESSION_LOADED)

        //  ,    
        .save(" ")

        //  ,     `yield take...`
        .next({session: mockSession})
        .call(loadProject, {projectId})

        .next()
        .isDone()

        //    
        .restore(" ")

        // ,    ,
        //     
        .next({})
        .isDone();
});


. - API, , jest.fn.



, !





. , . , , , , . , , ? , (reducers)? , .





, :



//    
import {call, fork, put, take, takeLatest, select} from "redux-saga/effects";

//  
export default function* sessionWatcher() {
    yield fork(initApp);
    yield takeLatest(SESSION_SYNC.SESSION_LOAD_PROJECT, loadProject);
}

export function* initApp() {
    //       
    yield put(initializeStorage());
    yield take(STORAGE_SYNC.STORAGE_INITIALIZED);

    yield put(loadSession());
    let { session } = yield take(STORAGE_SYNC.STORAGE_SESSION_LOADED);

    //   
    if (session) {
        yield call(loadProject, { projectId: session.lastLoadedProjectId });
    } else {
        logger.info({message: "  "});
    }
}

export function* loadProject({ projectId }) {
    //        
    yield put(loadProjectIntoStorage(projectId));
    const project = yield select(getProjectFromStorage);

    //  ,        
    try {
        yield put({type: SESSION_ASYNC.SESSION_PROJECT_LOADED_ASYNC, project});
        yield fork(saveSession, projectId);
        yield put(loadMap());
    } catch(error) {
        yield put({type: SESSION_SYNC.SESSION_ERROR_WHILE_LOADING_PROJECT, error});
    }
}

export function getProjectFromStorage(state) {
    //      
}

export function* saveSession(projectId) {
    // ....   API
    yield call(console.log, " API...");
}


sessionWatcher, , initApp , id. , , . , :



  • API, .


//    
import { expectSaga } from "redux-saga-test-plan";
import { select } from "redux-saga/effects";
import * as matchers from "redux-saga-test-plan/matchers";

it("         ", () => {
    //  
    const projectId = 1;
    const anotherProjectId = 2;
    const mockedSession = {
        lastLoadedProjectId: projectId,
    };
    const mockedProject = "project";

    //  `sessionWatcher`
    // `silentRun`         
    //      
    return (
        expectSaga(sessionWatcher)
            //   
            .provide([
                //    `select` ,  
                // `getProjectFromStorage`      `mockedProject`
                //            ,
                //      `select`,
                //       

                //     
                //  Redux-Saga,  
                [select(getProjectFromStorage), mockedProject],

                //    `fork` ,   `saveSession` 
                //     (undefined)
                //        ,
                //  

                //     Redux Saga Test Plan
                [matchers.fork.fn(saveSession)],
            ])

            //    
            //      ,    

            //  
            .put(initializeStorage())
            .take(STORAGE_SYNC.STORAGE_INITIALIZED)
            //  ,       `take`  `initApp`
            //       
            .dispatch({ type: STORAGE_SYNC.STORAGE_INITIALIZED })

            .put(loadSession())
            .take(STORAGE_SYNC.STORAGE_SESSION_LOADED)
            .dispatch({ type: STORAGE_SYNC.STORAGE_SESSION_LOADED, session: mockedSession })

            //   ,  `initApp`
            .put(loadProjectFromStorage(projectId))
            .put({ type: SESSION_ASYNC.SESSION_PROJECT_LOADED_ASYNC, project: mockedProject })
            .fork(saveSession, projectId)
            .put(loadMap())

            //  ,    `takeLatest`  `sessionWatcher`
            //     
            //   ,  `sessionWatcher`
            .dispatch({ type: SESSION_SYNC.SESSION_LOAD_PROJECT, projectId: anotherProjectId })
            .put(loadProjectFromStorage(anotherProjectId))
            .put({ type: SESSION_ASYNC.SESSION_PROJECT_LOADED_ASYNC, project: mockedProject })
            .fork(saveSession, anotherProjectId)
            .put(loadMap())

            //  ,      
            .silentRun()
    );
});


. , , — . waitSaga, .



, , — provide , . ( ) select Redux Saga , getProjectFromStorage. , , Redux Saga Test Plan. , , saveSession, . , API.



. , , , . (dispatch) .



silentRun, : , - , .





, provide redux-saga-test-plan/providers, .



//    
import {expectSaga} from "redux-saga-test-plan";
import {select} from "redux-saga/effects";
import * as matchers from "redux-saga-test-plan/matchers";
import * as providers from "redux-saga-test-plan/providers";

it("       ", () => {
    const projectId = 1;
    const mockedSession = {
        lastLoadedProjectId: projectId
    };
    const mockedProject = "project";
    const mockedError = new Error(",  -   !");

    return expectSaga(sessionWatcher)

        .provide([
            [select(getProjectFromStorage), mockedProject],
            //    
            [matchers.fork.fn(saveSession), providers.throwError(mockedError)]
        ])

        //  
        .put(initializeStorage())
        .take(STORAGE_SYNC.STORAGE_INITIALIZED)
        .dispatch({type: STORAGE_SYNC.STORAGE_INITIALIZED})

        .put(loadSession())
        .take(STORAGE_SYNC.STORAGE_SESSION_LOADED)
        .dispatch({type: STORAGE_SYNC.STORAGE_SESSION_LOADED, session: mockedSession})

        //   ,  `initApp`
        .put(loadProjectFromStorage(projectId))
        .put({type: SESSION_ASYNC.SESSION_PROJECT_LOADED_ASYNC, project: mockedProject})
        //    
        .fork(saveSession, projectId)
        // ,    
        .put({type: SESSION_SYNC.SESSION_ERROR_WHILE_LOADING_PROJECT, error: mockedError})

        .silentRun();
});




, , (reducers). redux-saga-test-plan . -, :



const defaultState = {
    loadedProject: null,
};

export function sessionReducers(state = defaultState, action) {
    if (!SESSION_ASYNC[action.type]) {
        return state;
    }
    const newState = copyObject(state);

    switch(action.type) {
        case SESSION_ASYNC.SESSION_PROJECT_LOADED_ASYNC: {
            newState.loadedProject = action.project;
        }
    }

    return newState;
}


-, , withReducer, ( , withState). hasFinalState, .



//    
import {expectSaga} from "redux-saga-test-plan";
import {select} from "redux-saga/effects";
import * as matchers from "redux-saga-test-plan/matchers";

it("         ", () => {
    const projectId = 1;
    const mockedSession = {
        lastLoadedProjectId: projectId
    };
    const mockedProject = "project";
    const expectedState = {
        loadedProject: mockedProject
    };

    return expectSaga(sessionWatcher)
        //     , 
        //          `withState`
        .withReducer(sessionReducers)

        .provide([
            [select(getProjectFromStorage), mockedProject],
            [matchers.fork.fn(saveSession)]
        ])

        //  
        .put(initializeStorage())
        .take(STORAGE_SYNC.STORAGE_INITIALIZED)
        .dispatch({type: STORAGE_SYNC.STORAGE_INITIALIZED})

        .put(loadSession())
        .take(STORAGE_SYNC.STORAGE_SESSION_LOADED)
        .dispatch({type: STORAGE_SYNC.STORAGE_SESSION_LOADED, session: mockedSession})

        //   ,  `initApp`
        .put(loadProjectFromStorage(projectId))

        //      ,   ,
        //       
        // .put({type: SESSION_ASYNC.SESSION_PROJECT_LOADED_ASYNC, project: mockedProject})
        .fork(saveSession, projectId)
        .put(loadMap())

        //   
        .hasFinalState(expectedState)

        .silentRun();
});


Medium.



. , .




All Articles