Moving from Ava to Jest

October 27, 2017

We recently moved from Ava to Jest for our testing framework and we saw both speedups and welcome new features.

What do we Gain?

Benefits:

Negatives:

You need to know a couple different ways to mock things and when to use each, hopefully this can address that though.

New Testing Scripts

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.

Debugging Tests

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:

  1. To use it drop a debugger in the file you want to go to
  2. Run npm test:debug optionally with a path to the specific file
  3. Open chrome://inspect and click the node session listed there
  4. Now with this tab open it will take you to the inspector every time you run test:debug
  5. Debug like normally!

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/

How to Test with Jest

Jest has good documentation. The core mocking features are:

as well as lifecycle hooks like beforeEach, afterEach, beforeAll, afterAll and test groupings using describe.

Using jest.mock

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(),
  }),
}));

Using 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.

Using jest.spyOn

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.

Example Test

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.

jest.doMock

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:

Another Example

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.

jest.resetModules

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.

Example of Accessing Spies

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.

Snapshots

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.

Component Snapshot: GoogleSignInButton

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();
});

Reducer Snapshot: Debt

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();
});

Useful References