Redux est un gestionnaire d'état extrêmement utile. Parmi les nombreux "plugins", Redux-Saga est mon préféré. Sur un projet React-Native sur lequel je travaille actuellement, j'ai dû faire face à de nombreux effets secondaires. Ils me donneraient des maux de tête si je les mettais dans les ingrédients. Avec cet outil, la création de flux logiques de branchement complexes devient une tâche simple. Mais qu'en est-il des tests? Aussi simple que d'utiliser une bibliothèque? Bien que je ne puisse pas vous donner une réponse exacte, je vais vous montrer un exemple concret des problèmes auxquels je suis confronté.
Si vous n'êtes pas familier avec les tests de sagas, je vous recommande de lire une page séparée dans la documentation. Dans les exemples suivants, j'utilise redux-saga-test-plan
cette bibliothèque qui donne toute la puissance des tests d'intégration avec les tests unitaires.
Un peu sur les tests unitaires
Les tests unitaires ne sont rien de plus que de tester une petite partie de votre système , généralement une fonction, qui doit être isolée des autres fonctions et, plus important encore, de l'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();
});