Unit- und Integrationstests in Redux Saga am Beispiel

Heldenbild



Redux ist ein äußerst nützlicher State Manager. Unter den vielen "Plugins" ist Redux-Saga mein Favorit. Bei einem React-Native-Projekt, an dem ich gerade arbeite, musste ich mich mit vielen Nebenwirkungen auseinandersetzen. Sie würden mir Kopfschmerzen bereiten, wenn ich sie in Zutaten geben würde. Mit diesem Tool wird das Erstellen komplexer verzweigter logischer Abläufe zu einer einfachen Aufgabe. Aber was ist mit Testen? So einfach wie die Bibliothek zu benutzen? Obwohl ich Ihnen keine genaue Antwort geben kann, werde ich Ihnen ein reales Beispiel für die Probleme zeigen, mit denen ich konfrontiert bin.



Wenn Sie mit dem Testen von Sagen nicht vertraut sind, empfehle ich, eine separate Seite in der Dokumentation zu lesen . In den folgenden Beispielen verwende ich, redux-saga-test-planda diese Bibliothek die volle Leistung von Integrationstests zusammen mit Unit-Tests bietet.



Ein wenig über Unit-Tests



Unit-Tests sind nichts anderes als das Testen eines kleinen Teils Ihres Systems , normalerweise einer Funktion, die von anderen Funktionen und vor allem von der API isoliert werden muss.



, . - 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