
Redux é um gerenciador de estado extremamente útil. Entre os muitos "plugins", Redux-Saga é o meu favorito. Em um projeto React-Native no qual estou trabalhando atualmente, tive que lidar com muitos efeitos colaterais. Eles me dariam dor de cabeça se eu os colocasse nos ingredientes. Com esta ferramenta, criar fluxos lógicos ramificados complexos torna-se uma tarefa simples. Mas e quanto aos testes? Tão fácil quanto usar uma biblioteca? Embora não possa lhe dar uma resposta exata, vou lhe mostrar um exemplo da vida real dos problemas que estou enfrentando.
Se você não está familiarizado com o teste de sagas, recomendo a leitura de uma página separada na documentação. Nos exemplos a seguir, estou usando, redux-saga-test-planpois esta biblioteca oferece todo o poder do teste de integração junto com o teste de unidade.
Um pouco sobre teste de unidade
O teste de unidade nada mais é do que testar uma pequena parte do seu sistema , geralmente uma função, que precisa ser isolada de outras funções e, mais importante, da 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.
. , .