DEV Community

Cover image for Exploring unit and integration testing in Redux Saga
Oleg
Oleg

Posted on • Edited on

2 1

Exploring unit and integration testing in Redux Saga

Redux is an extremely useful library that facilitates managing of an app's state. Among many middlewares, Redux-Saga fits me the best as in a React-Native project I'm working at the moment, I've had to deal with lots of side effects that would bring me endless headaches in case I put them in components. With the tool, the creation of complex flows becomes straightforward. But what about the testing? Is this as smooth as the usage of the library? While I can't give you the exact answer, I'm to show you the real example of problems that I faced.

If you're not familiar with testing of sagas, I recommend reading a dedicated page in the docs. In the following examples, I use redux-saga-test-plan as it gives me the full strength of integration testing alongside with unit testing.

A bit about unit testing

Unit testing is nothing more than testing of a small piece of your system, usually a function, that has to be isolated from other functions and, which is more important, from APIs.

Going ahead I'd say that I haven't seen the point of unit testing in my project yet. I moved all the business logic and APIs abstractions into external modules to let the sagas handle only the flow of the application. So I haven't had those big sagas that I couldn't split into smaller, clearly see what they do (they're quite self-explanatory).

// Only important imports
import {call, put, take} from "redux-saga/effects";
export function* initApp() {
// Init storage and get session from the storage
yield put(initializeStorage());
yield take(STORAGE_SYNC.STORAGE_INITIALIZED);
yield put(loadSession());
let { session } = yield take(STORAGE_SYNC.STORAGE_SESSION_LOADED);
// Load the last project from the session
if (session) {
yield call(loadProject, { projectId: session.lastLoadedProjectId });
} else {
logger.info({message: "No session available"});
}
}
view raw sagaToTest.js hosted with ❤ by GitHub
// Only important imports
import {testSaga} from "redux-saga-test-plan";
it("should load the session and call `loadProject` saga ", () => {
const projectId = 1;
const mockSession = {
lastLoadedProjectId: projectId
};
testSaga(initApp)
.next()
.put(initializeStorage())
.next()
.take(STORAGE_SYNC.STORAGE_INITIALIZED)
.next()
.put(loadSession())
.next()
.take(STORAGE_SYNC.STORAGE_SESSION_LOADED)
// Save position to return here in the future
.save("before check")
// Provide object that'll be returned from `yield take...`
.next({session: mockSession})
.call(loadProject, {projectId})
.next()
.isDone()
// Go back where the label was introduced
.restore("before check")
// No session
.next({})
.isDone();
});

There you can see the usual way to check our effect creators. If there had been any API calls that, I would've mocked them using jest.fn.

As we've done with tedious work, let's proceed to the main course.

Integration testing

The significant drawbacks of unit testing are external calls. You need to mock them. In case your sagas consist only of these calls and no logic, testing step by step, while abstracting all dependencies, becomes a dull task. But what if we only want to check the flow without dealing with each of the effects. What if we need to test sagas in the context of the state, with the use of reducers. I have excellent news, that's exactly what I wanted to share with you.

Test multiple sagas

Let's consider the following example, which is an adapted version of the code from my project:

// Only important imports
import {call, fork, put, take, takeLatest, select} from "redux-saga/effects";
// Root saga
export default function* sessionWatcher() {
yield fork(initApp);
yield takeLatest(SESSION_SYNC.SESSION_LOAD_PROJECT, loadProject);
}
export function* initApp() {
// Init storage and get session from the storage
yield put(initializeStorage());
yield take(STORAGE_SYNC.STORAGE_INITIALIZED);
yield put(loadSession());
let { session } = yield take(STORAGE_SYNC.STORAGE_SESSION_LOADED);
// Load the last project from the session
if (session) {
yield call(loadProject, { projectId: session.lastLoadedProjectId });
} else {
logger.info({message: "No session available"});
}
}
export function* loadProject({ projectId }) {
// Load project from the storage and try to process it
yield put(loadProjectIntoStorage(projectId));
const project = yield select(getProjectFromStorage);
// Save project to the state, save project into the session, load map
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) {
// gets project
}
export function* saveSession(projectId) {
// .... calls external api or whatever
yield call(console.log, "Calling api...");
}
view raw sagasToTest.js hosted with ❤ by GitHub

Here we have the root saga sessionWatcher that initialize application by calling initApp right away after loading and also waits for the action to load a project by id. The project is loaded from storage after which, we save the project to the state and call an external function that saves the session and loads a map. The example shows all sorts of problems we can stumble upon during the testing process: working with multiple sagas, accessing the state, calling APIs that we'd like to avoid.

// Only important imports
import { expectSaga } from "redux-saga-test-plan";
import { select } from "redux-saga/effects";
import * as matchers from "redux-saga-test-plan/matchers";
it("should init the app and load the last project from session", () => {
// Prepare objects for tests
const projectId = 1;
const anotherProjectId = 2;
const mockedSession = {
lastLoadedProjectId: projectId,
};
const mockedProject = "project";
// Test `sessionWatcher` saga
return (
expectSaga(sessionWatcher)
// Mock effects
.provide([
// Mock every select effect creator that uses getProject selector and return mockedProject instead
// Use the `select` effect creator from Redux Saga to match
[select(getProjectFromStorage), mockedProject],
// Mock every fork effect that calls saveSession function
// Use `fork.fn` matcher from Redux Saga Test Plan
[matchers.fork.fn(saveSession)],
])
// Order doesn't matter
// We list only effect creators that we expect to see and can omit others
// Test app initialization
.put(initializeStorage())
.take(STORAGE_SYNC.STORAGE_INITIALIZED)
// dispatch any actions your saga will `take`
// dispatched actions MUST be in order
.dispatch({ type: STORAGE_SYNC.STORAGE_INITIALIZED })
.put(loadSession())
.take(STORAGE_SYNC.STORAGE_SESSION_LOADED)
.dispatch({ type: STORAGE_SYNC.STORAGE_SESSION_LOADED, session: mockedSession })
// Test project loading that is called by `initApp`
.put(loadProjectFromStorage(projectId))
.put({ type: SESSION_ASYNC.SESSION_PROJECT_LOADED_ASYNC, project: mockedProject })
.fork(saveSession, projectId)
.put(loadMap())
// You can even dispatch an action that will be handled by sessionWatcher and test project loading again!
// Test project loading that is called by `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())
// Suppress timeout
.silentRun()
);
});

The test above tests all the sagas and split into few parts. The first part introduces objects that we'll use to test our sagas, the second, is testing itself. We call expectSaga that'll run the root saga and tests it against the checks listed below it.

The first function we see is provide, which uses matchers to locate the effect creators that will be mocked. The first tuple (pair of values) uses the effect creator from the Redux Saga library and matches exactly to select effect that calls getProjectFromStorage selector. If we want more flexibility, we can use matchers that are provided by Redux Saga Test Plan library as we do in the second tuple, where we say to match by function, ignoring its arguments. This mechanism allows us to avoid accessing the store or calling some functions and much, much more that I don't list here.

After that, we have a chain of effect cheks. Note that we don't have to put them in the specific order or include all the effects but rather list the effects that we expect to see. However, calls to dispatch must be in order.

The chain ends with silentRun function that does three things: runs our test, suppress timeout error and return a promise.

Simulate an error

To simulate an error, we can use already familiar providers and a helper function from redux-saga-test-plan/providers to replace an effect with an error.

// Only important imports
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("should init the app and handle error during project loading", () => {
const projectId = 1;
const mockedSession = {
lastLoadedProjectId: projectId
};
const mockedProject = "project";
const mockedError = new Error("Whoops, something went wrong!");
// Test `sessionWatcher` saga
return expectSaga(sessionWatcher)
// Mock effects
.provide([
[select(getProjectFromStorage), mockedProject],
[matchers.fork.fn(saveSession), providers.throwError(mockedError)]
])
// Test app initialization
.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})
// Test project loading that is called by `initApp`
.put(loadProjectFromStorage(projectId))
.put({type: SESSION_ASYNC.SESSION_PROJECT_LOADED_ASYNC, project: mockedProject})
// Expect error here
.fork(saveSession, projectId)
// Test error handling
.put({type: SESSION_SYNC.SESSION_ERROR_WHILE_LOADING_PROJECT, error: mockedError})
.silentRun();
});

Reducers and state

But what about the state, how would we test all together with reducers. With the library, the task is a no-brainer. Firstly, we need to introduce our reducer:

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;
}

Secondly, we change our test a bit by adding withReducer, which allows us using dynamic state (you can provide state without reducer by calling withState), and hasFinalState, which compares the state with the expected one, functions.

// Only important imports
import {expectSaga} from "redux-saga-test-plan";
import {select} from "redux-saga/effects";
import * as matchers from "redux-saga-test-plan/matchers";
it("should init the app and load the last project into state", () => {
const projectId = 1;
const mockedSession = {
lastLoadedProjectId: projectId
};
const mockedProject = "project";
const expectedState = {
loadedProject: mockedProject
};
// Test `sessionWatcher` saga
return expectSaga(sessionWatcher)
// You may connect root reducer to dynamically change state
// or specify static state via `withState`
.withReducer(sessionReducers)
.provide([
[select(getProjectFromStorage), mockedProject],
[matchers.fork.fn(saveSession)]
])
// Test app initialization
.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})
// Test project loading that is called by `initApp`
.put(loadProjectFromStorage(projectId))
// We can omit this check as it changes the state which we check at the end
// .put({type: SESSION_ASYNC.SESSION_PROJECT_LOADED_ASYNC, project: mockedProject})
.fork(saveSession, projectId)
.put(loadMap())
// Test final state
.hasFinalState(expectedState)
.silentRun();
});

More on using the magic library here. Hope you enjoyed the reading, thank you.

Sentry image

Hands-on debugging session: instrument, monitor, and fix

Join Lazar for a hands-on session where you’ll build it, break it, debug it, and fix it. You’ll set up Sentry, track errors, use Session Replay and Tracing, and leverage some good ol’ AI to find and fix issues fast.

RSVP here →

Top comments (0)

Billboard image

The Next Generation Developer Platform

Coherence is the first Platform-as-a-Service you can control. Unlike "black-box" platforms that are opinionated about the infra you can deploy, Coherence is powered by CNC, the open-source IaC framework, which offers limitless customization.

Learn more

👋 Kindness is contagious

Please leave a ❤️ or a friendly comment on this post if you found it helpful!

Okay