How to Test React and Redux with Redux-Saga and ReactDnD
Modern front-end applications built with React and Redux can become quite sophisticated, especially when throwing asynchronous flows with Redux-saga and drag-and-drop functionality with ReactDnD into the mix. To maintain a fast development velocity and make refactoring safe, having a solid test suite is essential.
In this post, we‘ll take an in-depth look at strategies and best practices for thoroughly testing React/Redux applications that incorporate Redux-saga and ReactDnD. We‘ll cover everything from unit testing components, reducers, actions, and selectors, to integration testing the full stack. My goal is to equip you with both the conceptual foundations and practical examples to confidently test your app.
The React/Redux Testing Landscape
Before we jump into testing Redux-saga and ReactDnD specifically, let‘s briefly survey the React and Redux testing landscape. The React ecosystem has largely settled on Jest as the go-to test runner and assertion library. Jest provides an integrated, batteries-included experience out of the box.
For testing React components, the popular Enzyme library by Airbnb has become the de facto choice. Enzyme provides a jQuery-like interface for traversing and manipulating your rendered component trees.
On the Redux side, most of your Redux code (reducers, action creators, and selectors) are plain JavaScript functions, which are easy to test with Jest. However, asynchronous action creators that dispatch multiple actions can get tricky to test. This is where Redux-saga comes in.
An Intro to Redux-Saga
Redux-saga is a powerful library that aims to make side effects (i.e. asynchronous things like data fetching and impure things like accessing browser cache) easier to manage, more efficient to execute, and simpler to test.
The mental model is that a saga is like a separate thread in your application that‘s solely responsible for side effects. Your components dispatch actions that are listened by sagas. The sagas perform the side effect (e.g. an API call) and dispatch actions when they are done that your reducers can handle.
In practice, a saga is implemented as a generator function that yields effects. Redux-saga provides an effects library, allowing you to declaratively express complex asynchronous flows with synchronous-looking code. Each yield expression describes a simple effect, which the saga middleware executes for you.
Here‘s a simple example of a saga that listens for a USER_FETCH_REQUESTED action and performs an API call:
import { call, put, takeEvery } from ‘redux-saga/effects‘
import Api from ‘...‘
function* fetchUser(action) {
try {
const user = yield call(Api.fetchUser, action.payload.userId);
yield put({type: "USER_FETCH_SUCCEEDED", user: user});
} catch (e) {
yield put({type: "USER_FETCH_FAILED", message: e.message});
}
}
function* mySaga() {
yield takeEvery("USER_FETCH_REQUESTED", fetchUser);
}
export default mySaga;
We‘ll get into testing sagas in depth later. For now, just note how the asynchronous flow is described with synchronous-looking code using yields. This makes complex flows easier to follow, test, and debug.
ReactDnD Overview
ReactDnD is a set of React higher-order components to help you build complex drag and drop interfaces while keeping your components decoupled. It embraces unidirectional data flow and encapsulates the nuts and bolts of the drag-and-drop interaction, exposing a neat API to your components.
With ReactDnD, you express your component‘s drag and drop interactions declaratively right alongside its rendering logic. You specify what type of data your component can handle and ReactDnD takes care of the details.
Here‘s a simple example of a drag source (a component that can be dragged):
import { DragSource } from ‘react-dnd‘;
const boxSource = {
beginDrag(props) {
return {
name: props.name,
};
},
};
export default DragSource(
‘box‘,
boxSource,
(connect, monitor) => ({
connectDragSource: connect.dragSource(),
isDragging: monitor.isDragging(),
}),
)(Box);
And a drop target (a component onto which things can be dropped):
import { DropTarget } from ‘react-dnd‘;
const boxTarget = {
drop(props, monitor) {
const item = monitor.getItem();
props.handleDrop(item.name);
},
};
export default DropTarget(
‘box‘,
boxTarget,
(connect, monitor) => ({
connectDropTarget: connect.dropTarget(),
isOver: monitor.isOver(),
}),
)(Dustbin);
Testing ReactDnD components requires some specific techniques which we‘ll cover. The library does provide test utilities to make it easier.
Unit Testing React Components
Let‘s start with unit testing your presentational React components. I generally recommend a combination of snapshot tests for catching unintended changes and explicit assertions for the most important props and logic.
Here‘s an example using Jest and Enzyme:
import React from ‘react‘;
import { shallow } from ‘enzyme‘;
import MyComponent from ‘./MyComponent‘;
describe(‘MyComponent‘, () => {
it(‘should render correctly‘, () => {
const wrapper = shallow(
<MyComponent foo="bar" />
);
expect(wrapper).toMatchSnapshot();
});
it(‘should render an h1 with the foo prop‘, () => {
const wrapper = shallow(
<MyComponent foo="bar" />
);
expect(wrapper.find(‘h1‘).text()).toEqual(‘bar‘);
});
it(‘should call the onClick handler when clicked‘, () => {
const onClickMock = jest.fn();
const wrapper = shallow(
<MyComponent onClick={onClickMock} />
);
wrapper.find(‘button‘).simulate(‘click‘);
expect(onClickMock).toHaveBeenCalled();
});
});
A few tips:
- Shallow render your components to keep the tests focused on the component under test and not its children
- Assert on props, event handlers, and the presence/absence of subcomponents, not implementation details
- Snapshot testing is great, but be intentional about what you snapshot. Prefer explicit assertions for the most important parts of the rendered output
Testing Redux Reducers, Actions, and Selectors
Reducers, action creators, and selectors are pure functions, which makes testing them straightforward. You simply call the function with some inputs and make assertions on the outputs.
Here‘s an example of testing a reducer:
import myReducer from ‘./myReducer‘;
describe(‘myReducer‘, () => {
it(‘should return the initial state‘, () => {
expect(myReducer(undefined, {})).toEqual({
foo: ‘bar‘,
});
});
it(‘should handle MY_ACTION‘, () => {
expect(
myReducer(undefined, {
type: ‘MY_ACTION‘,
payload: ‘baz‘,
})
).toEqual({
foo: ‘baz‘,
});
});
});
And an action creator:
import { myAction } from ‘./myActions‘;
describe(‘myAction‘, () => {
it(‘should create an action to update foo‘, () => {
const expectedAction = {
type: ‘MY_ACTION‘,
payload: ‘baz‘,
};
expect(myAction(‘baz‘)).toEqual(expectedAction);
});
});
Selectors can be tested in a similar fashion by asserting on the return value given certain inputs.
Testing Sagas with Redux-Saga-Test-Plan
Testing sagas requires a bit more setup, but the library redux-saga-test-plan makes it relatively painless. It provides a declarative and intuitive way to test that your sagas yield the right effects given certain actions.
Here‘s an example of testing the fetchUser saga from earlier:
import { expectSaga } from ‘redux-saga-test-plan‘;
import * as matchers from ‘redux-saga-test-plan/matchers‘;
import Api from ‘...‘;
import { fetchUser } from ‘./sagas‘;
describe(‘fetchUser saga‘, () => {
it(‘should fetch the user and dispatch a success action‘, () => {
const fakeUser = { id: 123, name: ‘Bob‘ };
return expectSaga(fetchUser, { payload: { userId: 123 }})
.provide([
[matchers.call.fn(Api.fetchUser), fakeUser]
])
.put({type: "USER_FETCH_SUCCEEDED", user: fakeUser})
.run();
});
it(‘should dispatch a failure action if the API call fails‘, () => {
const error = new Error(‘API Not Found‘);
return expectSaga(fetchUser, { payload: { userId: 123 }})
.provide([
[matchers.call.fn(Api.fetchUser), throwError(error)]
])
.put({type: "USER_FETCH_FAILED", message: ‘API Not Found‘})
.run();
});
});
The key points:
- Use expectSaga to declare the saga under test and the action to run it with
- Provide mock implementations for any Effects using the provide method
- Assert on the Effects yielded by the saga using matchers and effects like put
- Call run to execute the saga test
Redux-saga-test-plan offers a comprehensive set of matchers and effect assertions, allowing you to test even complex sagas with ease.
Integration Testing the Stack
While unit tests provide the foundation, it‘s also important to write some integration tests to ensure that your Redux store, React components, sagas, and ReactDnD components are working together correctly.
For these tests, you‘ll want to mount your connected components, interact with them, and assert on both the rendered UI and the final state of the Redux store.
Here‘s an example using enzyme-redux:
import React from ‘react‘;
import { mount } from ‘enzyme‘;
import { createStore, applyMiddleware } from ‘redux‘;
import createSagaMiddleware from ‘redux-saga‘;
import { Provider } from ‘react-redux‘;
import rootReducer from ‘../../reducers‘;
import rootSaga from ‘../../sagas‘;
import MyComponent from ‘./MyComponent‘;
describe(‘MyComponent integration‘, () => {
const sagaMiddleware = createSagaMiddleware();
const store = createStore(
rootReducer,
applyMiddleware(sagaMiddleware)
);
sagaMiddleware.run(rootSaga);
it(‘should fetch and display users‘, () => {
const wrapper = mount(
<Provider store={store}>
<MyComponent />
</Provider>
);
expect(wrapper.find(‘li‘)).toHaveLength(0);
wrapper.find(‘button‘).simulate(‘click‘);
expect(wrapper.find(‘li‘)).toHaveLength(2);
expect(store.getState().users).toHaveLength(2);
});
});
For testing drag and drop interactions, you can use the simulate method from ReactDnD‘s test backend:
import React from ‘react‘;
import { mount } from ‘enzyme‘;
import { DndProvider } from ‘react-dnd‘;
import TestBackend from ‘react-dnd-test-backend‘;
import Box from ‘./Box‘;
import Dustbin from ‘./Dustbin‘;
describe(‘Drag and Drop‘, () => {
it(‘should let you drag the box into the dustbin‘, () => {
const dustbin = mount(
<DndProvider backend={TestBackend}>
<Dustbin />
</DndProvider>,
);
const box = mount(
<DndProvider backend={TestBackend}>
<Box name="Drag me" />
</DndProvider>,
);
const boxNode = box.getDOMNode();
const dustbinNode = dustbin.getDOMNode();
TestBackend.simulateBeginDrag([boxNode]);
TestBackend.simulateHover([dustbinNode]);
TestBackend.simulateDrop();
expect(dustbin.state(‘lastDroppedItem‘)).toEqual(‘Drag me‘);
});
});
Test Organization and Coverage
As your application grows, it‘s important to maintain a well-organized test suite with sufficient coverage. Here are a few recommendations:
- Mirror your source folder structure in your test folder for easy navigation
- Use descriptive test names that reflect the component/function under test and the specific scenario
- Aim for covering the key behaviors and edge cases, rather than striving for 100% line coverage
- Use code coverage tools to identify blind spots and continuously improve your tests
In terms of tooling, Jest has built-in coverage reporting which you can enable with a simple flag:
jest --coverage
This will generate an HTML report showing you the coverage percentages and a breakdown of which lines are covered.
Conclusion
Testing a React/Redux application with sagas and drag-and-drop is a multi-faceted endeavor, but with the right approach and tools, it‘s very achievable. By diligently unit testing your components, reducers, actions, selectors, and sagas, and rounding it out with some integration tests, you can significantly increase your confidence in the correctness of your application.
The libraries and techniques covered in this post – Enzyme for component testing, Jest for assertions and coverage, redux-saga-test-plan for saga testing, and the ReactDnD test utilities – provide a robust foundation for your testing needs.
Remember, the goal is not to write tests for the sake of writing tests, but to verify and document the key behaviors of your system. Focus on the high-value tests that will catch bugs, prevent regressions, and serve as living documentation.
Happy testing!