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-plan
as 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();
});