Short answer: mock it. =)
Long answer: I do personally prefer to use as much of real code (i.e. without Test Doubles) in tests as possible. But sometimes it's just doesn't worth it and you have to fall back to mocking.
In cases like one you described there are several things you may want/need to check in your tests:
- That some sub-thunk was actually dispatched from your thunk under test.
- That thunk under test deals correctly with the result of a sub-thunk it dispatches.
- That thunk under test deals correctly with the updated state, which was updated by dispatched sub-thunk.
Depending on which combination of stuff above you want to test different strategies may be used. For instance, there is not need to check if sub-thunk were dispatched in case your thunk under test relies on sub-thunk's result: just mock sub-thunk so that it return specific data which will affect behavior of thunk under test in a specific, sensible way (see auth mock for details in the snippet below).
Let's consider the following example to illustrate possible mocking strategy. Imagine you have to implement authorization feature. Let's say your server authorizes user via http end-point and, in case of success, sends authorization token back which is used later to open websocket connection. Let's assume you designed that feature in the following way:
There is connect thunk, which dispatches auth sub-thunk with user's login and password, which in turn sends given credentials via http. When server responds auth thunk stores received token in a store (just for illustrative reasons) and resolves. When auth resolves, connect dispatches otherStuff thunk which will, well, do some other stuff with token. And at the end connect opens socket connection via wsApi.
// ======= connect.js =======
import { auth, getToken } from './auth';
import * as wsApi from './ws';
import { otherStuff } from './other-stuff';
export const connect = (login, password) => (dispatch, getState) => {
// ...
return dispatch(auth(login, password))
.then(() => {
const token = getToken(getState());
dispatch(otherStuff(token));
wsApi.connect(token);
});
// ...
};
// ======= auth.js =======
import * as httpApi from './http';
const saveToken = token => ({ type: 'auth/save-token', payload: token });
export const auth = (login, password) =>
dispatch =>
httpApi.login(login, password)
.then(token => dispatch(saveToken(token)));
export const getToken = state => state.auth.token;
export default (state = {}, action) => action.type === 'auth/save-token' ? { token: action.payload } : state;
// ======= other-stuff.js =======
export const otherStuff = token => (dispatch) => {
// ...
};
What we are gonna do is to mock two thunks: auth and otherStuff. connect is highly dependent on auth so we will make sure auth is called just by the checking connect behavior depending on what mock behavior we passed to auth. The case with otherStuff is a bit more complex. There is not way to check it was actually dispatched other then implementing custom middleware, which will log all dispatched actions. All in all test will look as follows (I am using jest for mocking):
import { createStore, applyMiddleware, combineReducers } from 'redux';
import thunk from 'redux-thunk';
import { connect } from './connect';
import { auth, getToken } from './auth';
import { otherStuff } from './other-stuff';
import * as wsApi from './ws';
const authReducer = require.requireActual('./auth').default;
jest.mock('./auth');
jest.mock('./ws');
jest.mock('./other-stuff');
const makeSpyMiddleware = () => {
const dispatch = jest.fn();
return {
dispatch,
middleware: store => next => action => {
dispatch(action);
return next(action);
}
};
};
describe('connect', () => {
let store;
let spy;
beforeEach(() => {
jest.clearAllMocks();
spy = makeSpyMiddleware();
store = createStore(authReducer, {}, applyMiddleware(spy.middleware, thunk));
auth.mockImplementation((login, password) => () => {
if (login === 'user' && password == 'password') return Promise.resolve();
return Promise.reject();
});
});
test('happy path', () => {
getToken.mockImplementation(() => 'generated token');
otherStuff.mockImplementation(token => ({ type: 'mocked/other-stuff', token }));
return store.dispatch(connect('user', 'password')).then(() => {
expect(wsApi.connect).toHaveBeenCalledWith('generated token');
expect(spy.dispatch).toHaveBeenCalledWith({ type: 'mocked/other-stuff', token: 'generated token'});
});
});
test('auth failed', () => {
return store.dispatch(connect('user', 'wrong-password')).catch(() => {
expect(wsApi.connect).not.toHaveBeenCalled();
});
});
});
If you need any comments on the given snippets, feel free to ask.