We recently moved from Ava to Jest for our testing framework and we saw both speedups and welcome new features.
Benefits:
Negatives:
You need to know a couple different ways to mock things and when to use each, hopefully this can address that though.
The Ava test scripts in the package.json
have been replaced with the following:
// basic testing
"test": "jest",
// debugging tests
"test:debug": "node --inspect-brk node_modules/.bin/jest --runInBand",
// do not show anything output to console, hides warnings
"test:silent": "jest --silent",
// enter Jest's interactive test watching
"test:watch": "jest --watch",
// only run tests related to files staged files
"test:change": "jest --onlyChanged",
// stop running the tests once an error is encountered
"test:failfast": "jest --bail",
// Run the tests and output coverage using at most 4 workers since travis has limited
// concurrency
"coverage": "npm run test:silent -- --coverage --maxWorkers=4",
// Jest will output coverage data to `lcov.info`, send that to coveralls
"coveralls": "npm run coverage && cat ./coverage/lcov.info | coveralls",
All the commands support sending a path as an argument for running specific tests. My preference
is for doing npm run test:silent
because it makes seeing the tests that passed easier,
we could consider using it as the default going forward.
What the heck is node --inspect-brk node_modules/.bin/jest --runInBand
?
You can visit the node docs here.
inspect-brk
basically enables the inspctor and then we run jest with the runInBand
flag,
which makes tests run synchronously since the debugging doesn’t work well with multiple processes.
It is described in more detail here.
Steps to debugging:
npm test:debug
optionally with a path to the specific filechrome://inspect
and click the node session listed theretest:debug
Note that this requires node > 8 so to get this feature and general performance improvements bump to
node v8.7.0
from https://nodejs.org/en/
Jest has good documentation. The core mocking features are:
jest.fn
jest.spyOn
jest.mock
jest.mockClear
jest.mockRestore
jest.doMock
jest.resetModules
as well as lifecycle hooks like beforeEach
, afterEach
, beforeAll
, afterAll
and test groupings
using describe
.
jest.mock
is used to mock out the implementation of a module.
It takes a second argument that is the replacement implementation, which is what we’re now using in our Test/Setup.js
like we were with mockery to replace implementations:
jest.mock('@nerdwallet/nwa', () => ({
NWA: () => ({
track: (eventName, eventProps, callback) => {
if (callback) process.nextTick(callback);
},
trackPageView: () => jest.fn(),
generatePageViewId: () => jest.fn(),
setGlobalProp: () => jest.fn(),
enableLogger: () => jest.fn(),
getGlobalProps: () => jest.fn(),
getUserProps: () => jest.fn(),
}),
}));
jest.fn
returns a mock function that is used in the Tests/Setup.js
a lot as the mock out
the implementation of modules. You can pass it a return value like shown here:
const mockFn = jest.fn();
mockFn();
expect(mockFn).toHaveBeenCalled();
// With a mock implementation:
const returnsTrue = jest.fn(() => true);
console.log(returnsTrue()); // true;
I don’t use it much outside of the test setup file though because it doesn’t provide a way to restore the original implementation of functions, so you’d need to keep a reference to the original.
jest.spyOn
is what I recommend using most, it creates a mock function similar to jest.fn
but also
tracks calls to the function and supports mockClear
and mockRestore
for reseting calls to the mock and restoring the original implementation of what you mocked.
In the test-apptentive
file I used it before all tests to mock out NWApptentive
:
jest.spyOn(NWApptentive, 'engage').mockReturnValue(Promise.resolve(true));
jest.spyOn(NWApptentive, 'setUserInfo').mockReturnValue(Promise.resolve(true));
jest.spyOn(Utilities, 'logError');
First specify the module, then the method to mock off of it as a string and use mock methods like mockReturnValue
or mockImplementation
or any others from here.
test('apptentive middleware should clear user\'s info on signout', () => {
const action = signout.success();
store.dispatch(action);
expect(NWApptentive.setUserInfo).toHaveBeenCalledWith(null, null);
});
afterEach(() => {
NWApptentive.engage.mockClear();
NWApptentive.setUserInfo.mockClear();
Utilities.logError.mockClear();
});
In between tests here I use mockClear
to reset calls to them or sometimes mockRestore
if you need the original functionality in a different test in that file. You don’t need to restore it if you’re done with it for the file.
When using babel-jest, calls to mock will automatically be hoisted to the top of the code block. Use this method if you want to explicitly avoid this behavior.
There are only a couple cases in our code where we need different module implementations between tests in the same file and if you do then use doMock
which is similar to mock but does not get hoisted and interfere with other tests.
One example is the Platform
module:
describe('Android < 5.0', () => {
beforeAll(() => {
const mockVersion = AndroidSdkVersions['5.0'] - 1;
jest.doMock('Platform', () => ({ OS: 'android', Version: mockVersion }));
});
afterAll(() => {
jest.resetModules();
});
test('Should return false if access token not present', () => {
state.auth.hasAccessToken = false;
expect(isAuthSessionReady(state)).toBe(false);
});
test('Should return true if access token present', () => {
expect(isAuthSessionReady(state)).toBe(true);
});
});
Here I want to set the Platform just for the Android section in the test file. I can use the lifecycle hooks of beforeAll and afterAll for this test group.
After the test group we need to do call jest.resetModules
to clear the module registry cache and restore the implementation so that iOS can similarly use doMock
.
Do not just try to do another doMock
on top of the old one.
Here we setup some event listeners in the middleware that we want to call in our tests.
beforeAll(() => {
addEventListenerSpy = jest.spyOn(PushNotificationIOS, 'addEventListener');
deviceInfoSpy = jest.spyOn(DeviceInfo, 'getModel');
});
test('Should not set pending permission due to simulator notifications error', () => {
deviceInfoSpy.mockReturnValue('Simulator');
const registerHandler = addEventListenerSpy.mock.calls[1][1];
registerHandler();
expect(store.getActions()).toEqual([pushDeviceRegistered()]);
});
Just access the mock object off of the spy to examine or execute the calls.
Jest supports snapshot testing:
Instead of rendering the graphical UI, which would require building the entire app, you can use a test renderer to quickly generate a serializable value for your React tree.
The first time the snapshot test is added it saves the output which you can inspect. Any time you run the tests it will diff the new output against what it has saved and if it has changed you have the choice to update it if you intended for that change or reflect on what went wrong.
Here is a snapshot for a component I did as an example:
beforeAll(() => {
jest.spyOn(Selectors, 'getCurrentRoute').mockReturnValue('route');
});
afterAll(() => {
Selectors.getCurrentRoute.mockRestore('Selectors');
});
it('should render correctly', () => {
const tree = renderer.create(
<GoogleSignInButton store={store} />
).toJSON();
expect(tree).toMatchSnapshot();
});
And it can easily be done for reducers too:
test('START getDebtGoalDetails', () => {
expect(debtReducer(INITIAL_STATE, getDebtGoalDetails.start())).toMatchSnapshot();
});
test('SUCCESS getDebtGoalDetails', () => {
const payloadData = {
payload: {
test_key: 3,
},
};
expect(debtReducer(INITIAL_STATE, getDebtGoalDetails.success(payloadData))).toMatchSnapshot();
});
test('FAIL getDebtGoalDetails', () => {
expect(debtReducer(INITIAL_STATE, getDebtGoalDetails.fail())).toMatchSnapshot();
});