Utilities to test sagas, reducers and selectors in integration
Often when you're using any combination of react, redux, redux-saga, reselect you end up with the following structure for components
- Actions file, contains simple functions that take parameters and generate an action object
- Reducer file, contains your reducer that modifies the store depending on some actions
- Selector file, with some selectors to get the data from the store
- Saga file, with the side effects that implement your business logic and dispatch actions
See an example here
MyComponent/index.js
import React, { PureComponent } from 'react';
import { connect } from 'react-redux';
import { createStructuredSelector } from 'reselect';
// Local
import { doSomething } from './actions';
import { makeSelectResult } from './selectors';
export class MyComponent extends PureComponent {
render() {
return <div onClick={this.props.doSomething}>{this.props.result}</div>;
}
}
export const mapStateToProps = createStructuredSelector({
result: makeSelectResult(),
});
export function mapDispatchToProps(dispatch) {
return {
doSomething: () => dispatch(doSomething()),
};
}
export default connect(mapStateToProps, mapDispatchToProps)(MyComponent);
MyComponent/actions.js
import {
SOMETHING,
SOME_OTHER_THING,
} from './constants';
export function doSomething() {
return {
type: SOMETHING,
};
}
export function doSomethingElse(value) {
return {
type: SOME_OTHER_THING,
value,
};
}
MyComponent/constants.js
export const SOMETHING = 'MyComponent/SOMETHING';
export const SOME_OTHER_THING = 'MyComponent/SOME_OTHER_THING';
MyComponent/reducer.js
import { fromJS } from 'immutable';
import {
SOMETHING,
SOME_OTHER_THING,
} from './constants';
const initialState = fromJS({});
export default function reducer(state = initialState, action) {
switch (action.type) {
case SOMETHING:
return state.set('loading', true);
case SOME_OTHER_THING:
return state
.set('value', fromJS(action.value))
.set('loading', false);
default:
return state;
}
}
MyComponent/sagas.js
import { call, takeLatest, put } from 'redux-saga/effects';
import { doSomethingElse } from './actions';
import { SOMETHING } from './constants';
export function* doTheAction(action) {
const response = yield call(fetch, 'https://api.example.com');
const json = yield call([response, response.json]);
yield put(doSomethingElse(json.value));
}
export function* defaultSaga() {
yield takeLatest(SOMETHING, doTheAction),
}
export default [defaultSaga];
MyComponent/selectors.js
import { createSelector } from 'reselect';
import { STORE_DOMAIN } from './constants';
const selectDomain = () => (state) => state.get(STORE_DOMAIN);
export const makeSelectResult = () => createSelector(
selectDomain(),
(state) => state.get('value')
);
Writing unit tests for each of these files is tedious and often useless.
Action creators are so trivial that don't need testing. Reducers are often simple as well, they take an action and save the value in the store. Testing selectors often implies you populate the store with your state and assert that the selected value is correct. Testing sagas is very simple with generators but often your tests are too couple to the implementation, changing the order of your calls means you'll have to change the tests effectively duplicating your work.
Even when you write unit tests for each of your files, the code might not work as expected because maybe your sagas are calling the action with the wrong order of parameters or you reducer is storing data in a different place from the selector.
A better approach is to test everything together in integration. (My opinion)
redux-saga-integration-test
allows you to do just that, connect all moving parts and test the state props after dispatching action, while mocking the side effects in your sagas.
MyComponent/tests/integration.test.js
import { wire, mockedEffects } from 'redux-saga-integration-test';
import { createStructuredSelector } from 'reselect';
import { takeEvery } from 'redux-saga/effects';
import {
STORE_DOMAIN,
SOMETHING,
doSomething,
} from '../constants';
import * as component from '../index';
import sagas from '../sagas';
import reducer from '../reducer';
jest.mock('redux-saga/effects', () => mockedEffects);
describe('component integration', () => {
it('does what I expect', () => {
const mockFetch = jest.fn(() => Promise.resolve({
json: () => Promise.resolve({ value: 1 }),
}));
const { functions } = wire({
component,
reducer: {
[STORE_DOMAIN]: reducer,
},
sagas,
mocks: [
[fetch, mockFetch],
],
});
return functions.doSomething().then((props) => {
expect(mockFetch).toHaveBeenCalledWith('https://api.example.com');
expect(props).toEqual({
value: 1,
});
});
});
});
You can also test that certain actions have been dispatched
import { wire, mockedEffects } from 'redux-saga-integration-test';
import { takeEvery, put } from 'redux-saga/effects';
jest.mock('redux-saga/effects', () => mockedEffects);
const LOAD = 'LOAD_ACTION';
/* Actions */
function putAction() {
return { type: 'PUT_ACTION' };
}
/* Sagas */
function* putSomething(action) {
yield put(putAction(action.value));
}
function* sagas() {
yield takeEvery(LOAD, putSomething);
}
describe('put actions', () => {
it('dispatches the expected action', () => {
const { dispatch } = wire({
sagas,
});
const action = { type: LOAD, value: 1 };
return dispatch(action).then(() => {
expect(put).toHaveBeenCalledWith(putAction(1));
});
});
it('calls the expected saga', () => {
const mockPutSomething = jest.fn();
const { dispatch } = wire({
sagas,
mocks: [
[putSomething, mockPutSomething],
],
});
const action = { type: LOAD, value: 1 };
return dispatch(action).then(() => {
expect(mockPutSomething).toHaveBeenCalledWith(action);
});
});
});
The lines
import { mockedEffects } from 'redux-saga-integration-test';
jest.mock('redux-saga/effects', () => mockedEffects);
Allows redux-saga-integration-test
to intercept your calls to redux-saga
and mock the functions with side effects.
After calling jest.mock
, import { put } from 'redux-saga/effects';
returns a jest mock function that you can use to assert things like expect(put).toHaveBeenCalledWith(action);
;
The main function is wire
which takes the inputs
const { functions, dispatch, props } = wire({
component, // Object with `mapStateToProps` and `mapDispatchToProps`
initialStore, // Initial state loaded in the store, should be a regular object and will be converted into an immutable object
mocks, // Array of mocked functions, see the format later
ownProps, // Second argument passed to `mapDispatchToProps`
params, // Shorthand version for `ownProps: { params: {} }`, useful together with react router
reducer, // Either a function or an object used to create a combined reducer
sagas, // Array of sagas
});
The resulting object contains
-
functions
: the result ofmapDispatchToProps
. All functions in the object will be wrapped in a promise so you can easily access theprops
after calling either one of them -
dispatch
: the store dispatch function wrapped in a promise. Useful if you need to dispatch some action as a setup step before calling your functions -
props
: function returning the props computed bymapStateToProps
For example, if your mapDispatchToProps
looks like
export function mapDispatchToProps(dispatch) {
return {
doSomething: () => dispatch(doSomething()),
};
}
then you can call the following and receive the props
after your action completes
const { functions } = wire();
functions.doSomething().then((props) => {});
The format of mocks
is
[
[originalFunction, mockedFunction],
[anotherFunction, anotherMock],
]
The property sagas
can be either
- an array of function generators
- an array of objects
{ fn: [Function generator], args: [Array of arguments] }
When using array of objects the args
will be passed to sagaMiddleware
when the saga is registered