redux-saga-integration-test

1.4.0 • Public • Published

Utilities to test sagas, reducers and selectors in integration

Why?

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.

Usage

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

API

mockedEffects

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

wire

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 of mapDispatchToProps. All functions in the object will be wrapped in a promise so you can easily access the props 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 by mapStateToProps

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) => {});

mocks

The format of mocks is

[
  [originalFunction, mockedFunction],
  [anotherFunction, anotherMock],
]

sagas

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

Readme

Keywords

none

Package Sidebar

Install

npm i redux-saga-integration-test

Weekly Downloads

1

Version

1.4.0

License

MIT

Unpacked Size

23.2 kB

Total Files

7

Last publish

Collaborators

  • piuccio