Strategy
Before we start, we are going to talk about strategy first. If you just want to experience unit test for React app, strategy might not help too much. But if you really aim to lead a useful practice in your project, strategy determine whether you could carry it out. There are countless delightful cases of unit test practitioners, while a lot of teams fail and find it harder and harder to move on and even give it up finally.
The key for a successful unit test is the balance and tradeoff between your development team and tasks. If your development team has already been buried with feature delivery, we could cover unit test for the core and base code first. If stressing coverage for the whole project, it will lead to heavy load on unit test over functionality. On the other hand, if your development team has great capacity, it’s fine to push high-coverage on unit test.
Combined with React app, we could focus on data(state) changes first, since React follow MV* pattern. Validating app’s state means that its internal works properly in most cases. For a redux app, state validation is to test the store, namely reducers and actions. Reducers represent the app’s state while actions change app’s state with implementation of business logic. The second part is the representation of the app’s state, components. Components have a lower priority because they are related to UI view, which could be tested on system integration layer. Until now there are still no effective tools to validate rendered UI. And from my experience, UI view is the most frequently changed part on web. Therefore, it’s fine to get the job done in the final step.
Framework
Code blocks to cover
- Redux reducer
- Redux actions
- Async code
- Mock modules
- Redux async action with
redux-thunk
- Selectors
- Representational component
- Container component
Test case example for code blocks
Redux reducers
Since reducers are just pure function, we could test reducers with different parameters passed in.
// reducers: answers.js
import {
TOUR_UPDATE_ANSWERS,
} from '@/actions/types';
const answers = (state = [], { type, payload }) => {
switch (type) {
case TOUR_UPDATE_ANSWERS:
return [...payload];
default:
return state;
}
};
export { answers };
// reducers test cases
import { answers } from '@/reducers/tourClass/answer';
import * as types from '@/actions/types';
describe('answer reducer', () => {
it('should return initial state', () => {
expect(answers(undefined, {})).toEqual([]);
});
it('should handle action TOUR_UPDATE_ANSWERS', () => {
const array = [{ id: 0, name: 'test' }];
expect(
answers([], {
type: types.TOUR_UPDATE_ANSWERS,
payload: [...array],
})
).toEqual(array);
});
});
Redux pure actions
Pure actions are simple function too, therefore they are easy to test with input-output validation
// actions: index.js
export const updateTeams = teams => {
const action = {
type: SET_TEAMS,
payload: [...teams],
};
return action;
};
// test case
import * as types from '@/actions/types';
import {
updateTeams,
} from './index';
describe('redux action creators: index', () => {
it('action creator: updateTeams', () => {
const teams = [{ id: 0, name: 'team' }];
expect(updateTeams(teams)).toEqual({
type: types.SET_TEAMS,
payload: [...teams],
});
});
});
Test async code
Jest tests complete once they reach the end of their execution. Async code block will not work as you expect if you write it as sync test. To solve the issue, we could pass in argument done
for the test such that Jest will wait for the test complete before calling done
. @see Testing Asynchronous Code.
// this will not work
test('async code will not work as sync code block', () => {
function callback(data) {
expect(data).toBe('peanut butter');
}
fetchData(callback);
});
// pass in `done` instead
test('the async test will work with done passed in', done => {
function callback(data) {
expect(data).toBe('peanut butter');
done();
}
fetchData(callback);
})
If your code use promise or async/await, then just return the promise in your test case or apply async in your test case.
test('fetch data', () => {
return fetchData()
.then(data => expect(data).toEqual({ erro: 0 })
.catch(error => expect(error).toMatch('test'));
})
// or use async/await
test('fetch data', async () => {
const data = await fetchData();
expect(data).toEqual({ error: 0 });
})
Test timer
Jest provide timer mock for testing code blocks with timers, e.g. window.setTimeout
, window.setInterval
, see Timer mocks. With these util function we could easily test these code blocks.
// throttle.js
export default function throttle(fn, delay = 500, debounce) {
let timer = null;
let lastExecute = 0;
const callback = function(...args) {
const context = this;
const exec = function exec() {
if (timer) {
window.clearTimeout(timer);
timer = null;
}
fn.apply(context, args);
};
if (debounce) {
if (timer) {
window.clearTimeout(timer);
}
timer = window.setTimeout(exec, delay);
} else {
const now = Date.now();
if (now - lastExecute > delay) {
exec();
lastExecute = now;
} else if (!timer) {
timer = window.setTimeout(exec, delay);
}
}
};
return callback;
}
// throttle.spec.js
import throttle from './throttle';
describe('util function: throttle', () => {
let testFunc = jest.fn();
beforeEach(() => {
jest.clearAllTimers();
jest.useFakeTimers();
testFunc.mockReset();
});
it('should execute callback for the first time', () => {
const func = throttle(testFunc, 100);
func();
expect(testFunc).toHaveBeenCalledTimes(1);
jest.advanceTimersByTime(100);
expect(testFunc).toHaveBeenCalledTimes(1);
});
it('should throttle execution frequency', () => {
const func = throttle(testFunc, 100);
func();
jest.advanceTimersByTime(50);
func();
expect(testFunc).toHaveBeenCalledTimes(1);
jest.advanceTimersByTime(100);
expect(testFunc).toHaveBeenCalledTimes(2);
});
});
Mock dependencies
It’s common that a module depends on other modules. We always encapsulate logic in another file and import it when needed. Jest free us from mocking dependencies with jest.mock
. For more detail about how Jest mock functions and modules, see mock functions.
// index.js
import { getTeams } from '@/request/tourClazz';
export const fetchTeams = () => {
return getTeams().then(data => data);
}
// index.spec.js
import { fetchTeams } from './index';
jest.mock('@/requests/tourClazz');
import { getTeams, } from '@/requests/tourClazz';
describe('mock dependency', () => {
it('should module name imported', async () => {
getTeams.mockResolvedValue({
teams: [],
});
const data = fetchTeams();
expect(data).toEqual({ teams: [] });
})
});
Manual mock
Automatic module mocks in jest will just mock exported function by default, other exports like object, jest will bypass them and refer to the exported values. If we need to mock exported objects, then we have to make manual mock. For manual mock, see manual mock on jest doc. In detail we could achieve it by add __mocks__
folders and mocking files next to the target module, which could export anything we need to test the cases.
// clientPush.js
// we need to mock client.obsPushMsg.connect
import client from './client';
export const CLIENT_EVENTS = {
MIC_ERROR: 0,
MIC_VOL: 1,
LIVE_STATUS: 2,
SCREENSHOT: 3,
MIC_ACTION: 4,
MIC_QUALITY: 5,
MIC_INFO: 6,
};
const CLIENT_EVENT_TYPES = Object.values(CLIENT_EVENTS);
class ClientPush {
static CLIENT_EVENTS = CLIENT_EVENTS;
constructor() {
this._listeners = {};
CLIENT_EVENT_TYPES.forEach(event => {
this._listeners[event] = [];
});
if (client.obsPushMsg && client.obsPushMsg.connect) {
client.obsPushMsg.connect(msg => {
try {
const { type: event, content } = JSON.parse(msg);
if (event in this._listeners) {
const listeners = this._listeners[event];
for (const listener of listeners) {
if (typeof listener === 'function') {
listener(content);
}
}
}
} catch (error) {
console.log(error);
}
});
}
}
...
}
const clientPush = new ClientPush();
export default clientPush;
// __mocks__/client.js
const client = {
obsPushMsg: {
connect: jest.fn().mockImplementation(func => func),
},
};
export default client;
// clientPush.spec.js
jest.mock('./client');
import client from './client';
import clientPush, { CLIENT_EVENTS } from './clientPush';
const dispatchClientMsg = client.obsPushMsg.connect.mock.calls[0][0];
describe('clientPush', () => {
beforeEach(() => {
const listeners = {};
Object.values(CLIENT_EVENTS).forEach(event => {
listeners[event] = [];
});
clientPush._listeners = listeners;
});
it('should init', () => {
const listeners = clientPush._listeners;
const target = {};
Object.values(CLIENT_EVENTS).forEach(event => {
target[event] = [];
});
expect(listeners).toEqual(target);
expect(client.obsPushMsg.connect).toHaveBeenCalledTimes(1);
});
});
Redux async action with redux-thunk
There are a few points to be clear for async actions:
- Async actions return another function to be executed, namely thunk function.
- Async actions could dispatch actions with
dispatch()
. - Async actions could access store with
getState()
- Async actions could return value
Since async actions are thunk functions with dispatch
and getState
passed in, it’s difficult to mock these functions by hand. Here we apply redux-store-mock
to solve the problem. For redux-store-mock
, see https://github.com/dmitry-zaets/redux-mock-store.
We create a mocked store to test test the async action by dispatching it. The mocked store will execute the thunk function, with dispatch
and getState
injected. We mock the store state with data the thunk function needs by invoking createStore()
. And we use the mocked store to dispatch the action such that dispatch
will be injected into the thunk function. Finally, we inspect the store to validate the actions dispatched to the store. Here we don’t have to mock the reducer to handle actions, since we are testing the actions and we just need to do the least job to validate whether the thunk function works, we don’t have to do the work after the action is dispatched.
import { getFeedbackCount } from '@/request/tourClass';
// action creator
export const fetchFeedbackCount = () => async (dispatch, getStore) => {
const {
tourClass: {
clazz: { voteId },
selectedTeam: { id: teamId },
},
} = getStore();
const data = await getFeedbackCount(voteId, teamId);
dispatch({
type: TOUR_UPDATE_FEEDBACK_MAP,
payload: {
...data,
},
});
};
// test case
import configureMockStore from 'redux-mock-store';
import thunk from 'redux-thunk';
jest.mock('@/requests/tourClazz');
import { getFeedbackCount, } from '@/requests/tourClazz';
const mockStore = configureMockStore([thunk]);
describe('action creator: fetchFeedbackCount', () => {
it('should fetch and dispatch feedback', async () => {
const store = mockStore({
tourClass: {
clazz: { voteId: 1 },
selectedTeam: { id: 1 },
}
});
getFeedbackCount.mockResolvedValue({});
return store.dispatch(fetchFeedbackCount()).then(() => {
const actions = store.getActions().map(action => action);
expect(actions).toEqual([{
type: types.TOUR_UPDATE_FEEDBACK_MAP,
payload: {}
}]);
})
})
});
Selectors
Selectors and memorized selectors are pure functions similar to reducer, therefore we could test it with input-output validation.
// selector.js
export const selectStudentMap = state => state.live.studentMap;
export const selectStudentList = createSelector(
selectStudentMap,
studentMap => Object.values(studentMap)
);
export const selectOnlineStudentCount = createSelector(
selectStudentList,
studentList => studentList.filter(student => student.isOnline).length
);
// selector.spec.js
describe('selector module: live', () => {
it('selector: selectStudentMap', () => {
const state = {
live: { studentMap: {} }
};
expect(selectStudentMap(state)).toEqual({});
});
it('selector: selectStudentList', () => {
const studentMap = { id: 'studentId' };
const state = {
live: { studentMap }
};
const newState = {
live: { studentMap }
};
const list = selectStudentList(state);
expect(list).toEqual(['studentId']);
const newList = selectStudentList(newState);
expect(newList).toBe(list);
});
it('selector: selectOnlineStudentCount', () => {
const state = {
live: {
studentMap: {
'1': { id: '1', isOnline: true },
'2': { id: '2', isOnline: false }
}
}
};
const num = selectOnlineStudentCount(state);
expect(num).toEqual(1);
});
})
Test representational component
Testing representational component could be annoying or comfortable, depending on what strategy you adopt. Some teams just validate the component’s data state, checking the component’s state and props and ignoring whether it’s rendered correctly, rendering result is counting on ui testing, a higher level testing than unit testing. In such way the development team feel stressless from testing and swallow view changes quickly. Other teams may believe that component’s testing should reach to the rendered react element tree or DOM tree level, therefore they will implement a lot of test cases and assertions on the output tree. Today Jest snapshot testing and test utils save a lot of efforts for development team which adopt rendered tree validation. We could adopt the suitable strategy based on the team’s capacity.
No matter what kind of strategy we adopt, we must solve problems in component testing:
- Children components
- styled components
- Component lifecycle hooks testing
- Event handler testing
- Mocking other dependencies
For children components, shallow rendering come to rescue. With shallow rendering we could just render one-level deep of the component tree and ignore how its children components are rendered, focusing on the current component being test. Shallow rendering doesn’t really render HTML DOM in the memory, it just simulate what the current component’s render method return without interpreting descendant components.
// representational component
const RowContext = React.createContext({ gutter: 1 });
export const Layout = props => (
<RowContext.Provider value={{ gutter: props.gutter }}>
<LayoutStyle {...props}>{props.children}</LayoutStyle>
</RowContext.Provider>
);
// test case
import React from 'react';
import Enzyme, { shallow, mount, render } from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
import 'jest-styled-components'
import { Layout } from './index';
Enzyme.configure({ adapter: new Adapter() });
test('component Layout should render with default props', () => {
const wrapper = shallow(<Layout><span /></Layout>);
const tree = wrapper.children();
const props = wrapper.props();
expect(props.value.gutter).toEqual(1);
expect(props.children).not.toBeUndefined();
expect(wrapper.find('span').length).toEqual(1);
});
For styled components, we apply jest-styled-component
test utility to check the output style on the component.
import React from 'react';
import Enzyme, { shallow } from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
import 'jest-styled-components'
import { Layout } from './index';
Enzyme.configure({ adapter: new Adapter() });
test('component Layout should render with default props', () => {
const wrapper = shallow(<Layout><span /></Layout>);
const tree = wrapper.children();
expect(tree).toHaveStyleRule('display', 'flex');
expect(tree).toHaveStyleRule('margin-left', '-0.25rem');
expect(tree).toHaveStyleRule('margin-right', '-0.25rem');
});
Limitations
- When tested function depends on another function in the same module, we can not mock it with Jest since Jest mock the whole module. It might work if separating test cases.