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
- Install the package from npm
npm install redux-testkit --save-dev
- Make sure you have a test runner installed, we recommend jest
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);
});
});
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)
-
Runs the reducer
on current state
providing an action
. Calling withState()
is optional, if not provided, initial state is used. Makes sure the returned state is result
.
-
Also verifies immutability - that state
did not mutate. Why is this important? see example bug
See some examples of this API
Reducer(reducer).withState(state).expect(action).toChangeInState(changes)
-
Runs the reducer
on current state
providing an action
. Calling withState()
is optional, if not provided, initial state is used. Makes sure the part that changed in the returned state matches changes
and the rest of the state hasn't changed. The format of changes
is partial state, even a deep internal object - it is compared to returned state after merging the changes with the original state (objects are deep merged, arrays are replaced).
-
Also verifies immutability of the state
.
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)
-
Runs the reducer
on current state
providing an action
. Calling withState()
is optional, if not provided, initial state is used.
-
Returns the returned state so you can run expectations manually. It's not recommended to use this API directly because you usually won't verify that parts in the returned state that were not supposed to change, indeed did not change.
-
Also verifies immutability of the state
.
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)
-
Runs the selector
function on a given state
. If the selector takes more arguments, provide them at ...args
(the state is always assumed to be the first argument of a selector). Makes sure the returned result is result
.
-
Also verifies that state
did not mutate. Why is this important? see example bug
See some examples of this API
Selector(selector).execute(state, ...args)
-
Runs the selector
function on a given state
. If the selector takes more arguments, provide them at ...args
(the state is always assumed to be the first argument of a selector).
-
Returns the returned state so you can run expectations manually.
-
Also verifies that state
did not mutate.
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';
describe('posts actions', () => {
it('should clear all posts', () => {
const dispatches = await 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 () => {
jest.mock('../../../services/reddit');
const redditService = require('../../../services/reddit');
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 = await 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)
-
Runs the thunk thunk
on current state
given optional arguments ...args
. Calling withState()
is optional, no need to provide it if the internal thunk implementation doesn't call getState()
.
-
Returns an awaitable array of dispatches performed by the thunk (shallow, these dispatches are not executed). You can run expectations over them manually. Always await
on the result to get the actual dispatches array.
-
Also verifies that state
did not mutate. Why is this important? see example bug
See some examples of this API
Available expectations over a dispatch
expect(dispatches[0].isPlainObject()).toBe(true);
expect(dispatches[0].getType()).toEqual('LOADING_CHANGED');
expect(dispatches[0].getAction()).toEqual({ type: 'LOADING_CHANGED', loading: true });
expect(dispatches[0].isFunction()).toBe(true);
expect(dispatches[0].getName()).toEqual('refreshSession');
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
-
If the tested thunk dispatches another thunk, the other thunk is not executed. Different thunks should be considered as different units. Executing another unit should be part of an integration test, not a unit test.
-
If the tested thunk dispatches another thunk, you cannot set expectations on the arguments given to the other thunk. Different thunks should be considered as different units. Testing the interfaces between them should be part of an integration test, not a unit test.
-
If the tested thunk dispatches another thunk, you cannot mock the return value of the other thunk. Relying in your implementation on the return value of another thunk is considered bad practice. If you must test that, you should probably be changing your implementation.
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 postsSelectors from '../posts/reducer';
import * as uut from '../posts/actions';
describe('posts store integration', () => {
let store, flushThunks, redditService;
beforeEach(() => {
jest.resetAllMocks();
jest.resetModules();
jest.mock('../../services/reddit');
redditService = require('../../services/reddit');
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());
await flushThunks.flush();
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()
-
Creates flushThunks
middleware which should be applied to the store on creation. This middleware is useful for the case where one thunk dispatches another thunk. It allows to wait until all of the thunk promises have been resolved.
-
Returns a flushThunks
instance which has the following methods:
flushThunks.flush()
- Flushes all asynchronous thunks. Run
await
on this method to wait until all dispatched thunk promises are resolved.
flushThunks.reset()
- Call this method to reset the list of thunk promises observed by
flushThunks
.
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