wix-incubator / redux-testkit

Complete and opinionated testkit for testing Redux projects (reducers, selectors, actions, thunks)
MIT License
227 stars 20 forks source link

Redux Testkit

Complete and opinionated testkit for testing Redux projects (reducers, selectors, actions, thunks)


What tests are we going to write?

This library mostly provides syntactic sugar and makes testing Redux fun with less boilerplate. You can naturally test all Redux constructs without this library, but you will miss out on features like automatic immutability checks.


Installation

npm install redux-testkit --save-dev
npm install jest --save-dev


Recipe - Unit testing reducers

import { Reducer } from 'redux-testkit';
import uut from '../reducer';

describe('counter reducer', () => {

  it('should have initial state', () => {
    expect(uut()).toEqual({ counter: 0 });
  });

  it('should handle INCREMENT action on initial state', () => {
    const action = { type: 'INCREMENT' };
    const result = { counter: 1 };
    Reducer(uut).expect(action).toReturnState(result);
  });

  it('should handle INCREMENT action on existing state', () => {
    const action = { type: 'INCREMENT' };
    const state = { counter: 1 };
    const result = { counter: 2 };
    Reducer(uut).withState(state).expect(action).toReturnState(result);
  });

  it('should return the same state after accpeting a non existing action', () => {
    const action = { type: 'NON_EXISTING' };
    const state = { counter: 1 };
    Reducer(uut).withState(state).expect(action).toStayTheSame();
  });

});

A redux reducer is a pure function that takes an action object, with a type field, and changes the state. In almost every case the state object itself must remain immutable.

Reducer(reducer).withState(state).expect(action).toReturnState(result)

See some examples of this API

Reducer(reducer).withState(state).expect(action).toStayTheSame()

Reducer(reducer).withState(state).expect(action).toChangeInState(changes)

The added value of this API compared to toReturnState is when your state object is very large and you prefer to reduce the boilerplate of preparing the entire result by yourself.

See some examples of this API

Reducer(reducer).withState(state).execute(action)

The added value of this API compared to the others is that it allows you to run your own custom expectations (which isn't recommended).

See some examples of this API


Recipe - Unit testing selectors

import { Selector } from 'redux-testkit';
import * as uut from '../reducer';

describe('numbers selectors', () => {

  it('should select integers from numbers state', () => {
    const state = { numbers: [1, 2.2, 3.14, 4, 5.75, 6] };
    const result = [1, 4, 6];
    Selector(uut.getIntegers).expect(state).toReturn(result);
  });

});

A redux selector is a pure function that takes the state and computes some derivation from it. This operation is read-only and the state object itself must not change.

Selector(selector).expect(state, ...args).toReturn(result)

See some examples of this API

Selector(selector).execute(state, ...args)

The added value of this API compared to the others is that it allows you to run your own custom expectations.

See some examples of this API


Recipe - Unit testing thunks

import { Thunk } from 'redux-testkit';
import * as uut from '../actions';
import redditService from '../../../services/reddit';
jest.mock('../../../services/reddit');

describe('posts actions', () => {

  beforeEach(() => {
    jest.resetAllMocks();
  });

  it('should clear all posts', () => {
    const dispatches = Thunk(uut.clearPosts).execute();
    expect(dispatches.length).toBe(1);
    expect(dispatches[0].getAction()).toEqual({ type: 'POSTS_UPDATED', posts: [] });
  });

  it('should fetch posts from server', async () => {
    redditService.getPostsBySubreddit.mockReturnValueOnce(['post1', 'post2']);
    const dispatches = await Thunk(uut.fetchPosts).execute();
    expect(dispatches.length).toBe(3);
    expect(dispatches[0].getAction()).toEqual({ type: 'POSTS_LOADING', loading: true });
    expect(dispatches[1].getAction()).toEqual({ type: 'POSTS_UPDATED', posts: ['post1', 'post2'] });
    expect(dispatches[2].getAction()).toEqual({ type: 'POSTS_LOADING', loading: false });
  });

  it('should filter posts', () => {
    const state = { loading: false, posts: ['funny1', 'scary2', 'funny3'] };
    const dispatches = Thunk(uut.filterPosts).withState(state).execute('funny');
    expect(dispatches.length).toBe(1);
    expect(dispatches[0].getAction()).toEqual({ type: 'POSTS_UPDATED', posts: ['funny1', 'funny3'] });
  });

});

A redux thunk wraps a synchronous or asynchronous function that performs an action. It can dispatch other actions (either plain objects or other thunks). It can also perform side effects like accessing servers.

Thunk(thunk).withState(state).execute(...args)

See some examples of this API

Available expectations over a dispatch
// when a plain object action was dispatched
expect(dispatches[0].isPlainObject()).toBe(true);
expect(dispatches[0].getType()).toEqual('LOADING_CHANGED');
expect(dispatches[0].getAction()).toEqual({ type: 'LOADING_CHANGED', loading: true });

// when another thunk was dispatched
expect(dispatches[0].isFunction()).toBe(true);
expect(dispatches[0].getName()).toEqual('refreshSession'); // the function name, see note below
Being able to expect dispatched thunk function names

This is relevant when the tested thunk dispatches another thunk. In order to be able to test the name of the thunk that was dispatched, you will have to provide an explicit name to the internal anonymous function in the thunk implementation. For example:

export function refreshSession() {
  return async function refreshSession(dispatch, getState) {
    // ...
  };
}
Limitations when testing thunks that dispatch other thunks

These limitations may seem annoying, but they stem from best practices. If they disrupt your test, it's usually a sign of a code smell in your implementation. Fix the implementation, don't fight to test a bad practice.


Recipe - Integration tests for the entire store

import { createStore, applyMiddleware, combineReducers } from 'redux';
import thunk from 'redux-thunk';
import { FlushThunks } from 'redux-testkit';

import * as reducers from '../reducers';
import * as uut from '../posts/actions';
import * as postsSelectors from '../posts/reducer';
import redditService from '../../services/reddit';
jest.mock('../../services/reddit');

describe('posts store integration', () => {

  let flushThunks, store;

  beforeEach(() => {
    jest.resetAllMocks();
    // create a redux store with flushThunks added as the first middleware
    flushThunks = FlushThunks.createMiddleware();
    store = createStore(combineReducers(reducers), applyMiddleware(flushThunks, thunk));
  });

  it('should select posts', () => {
    expect(postsSelectors.getSelectedPost(store.getState())).toEqual([]);
    store.dispatch(uut.selectPost('post1'));
    expect(postsSelectors.getSelectedPost(store.getState())).toEqual(['post1']);
    store.dispatch(uut.selectPost('post2'));
    expect(postsSelectors.getSelectedPost(store.getState())).toEqual(['post1', 'post2']);
  });

  it('should fetch posts from server', async () => {
    redditService.getPostsBySubreddit.mockReturnValueOnce(['post1', 'post2']);
    expect(postsSelectors.getPostsLoading(store.getState())).toBe(false);
    expect(postsSelectors.getPosts(store.getState())).toEqual([]);
    await store.dispatch(uut.fetchPosts());
    expect(postsSelectors.getPostsLoading(store.getState())).toBe(false);
    expect(postsSelectors.getPosts(store.getState())).toEqual(['post1', 'post2']);
  });

  it('should test a thunk that dispatches another thunk', async () => {
    expect(postsSelectors.isForeground(store.getState())).toBe(false);
    await store.dispatch(uut.initApp()); // this dispathces thunk appOnForeground
    await flushThunks.flush(); // wait until all async thunks resolve
    expect(postsSelectors.isForeground(store.getState())).toBe(true);
  });

});

Integration test for the entire store creates a real redux store with an extra flushThunks middleware. Test starts by dispatching an action / thunk. Expectations are set over the final state using selectors.

flushThunks = FlushThunks.createMiddleware()

flushThunks.flush()
flushThunks.reset()


Building and testing this library

This section is relevant only if you want to contribute to this library or build it locally.

npm install
npm run build
npm run test


License

MIT