Real-world Unit Testing with Meteor and Jest
Blog

Real-world Unit Testing with Meteor and Jest

Unit tests are my favorite tests. They can run in milliseconds, and they make me write better code. But they can also be pretty challenging to set up. Meteor can also present some unique challenges to testing. When we were testing with Mocha, I happily followed a pattern developed by Pete Corey using testdouble.js to stub out Meteor (I will use the terms “mock” and “stub” interchangeably here, but there are differences). This is a great approach, and it also illustrates a couple of key points that many people new to unit testing don’t always realize:

Unit tests should be fast. Unit tests for a small project should complete in tens of milliseconds or faster (with exceptions for some data-intensive functions). This is important because unit tests are designed to be run a lot (I often run them in watch mode to guide my work in real-time). That by definition means that you can’t use any network, database, or file system resources. And generally, that will guide you to the correct way to unit test anyway, because your aren’t isolating units of code if they access outside resources.

Don’t test the framework. It’s not your job to see if there’s a bug in Meteor; they’ve got it covered. Your job is to make sure Meteor gets called correctly, and that when it provides results to your code, your code handles them as expected. I would go as far as to say that even using minimongo in unit tests is generally not a good idea even though it’s fast enough. Stubbing out Meteor used to be impossible. I know — I was involved in the Velocity testing initiative back in the day. As an example, at one point we tried to port the automocking functionality from Jest, but Meteor’s dependencies were not easy to walk, and loading individual components without Meteor’s global objects was very hard. With the move to Meteor 1.3, though, we could start to use standard JS modules with import, and that opened the doors to using standard JavaScript tools. Hurray!

Moving to Jest

Just having “naked” Mocha tests without Velocity or other wrappers was a huge win for speed and the ability to make use of the broader JavaScript ecosystem. But earlier this year, OK GROW! started experimenting with Jest. With our heavy use of React and React Native, it was becoming an obvious choice community-wise, and a couple of our developers coming to JavaScript from Ruby also were experiencing a lot of frustration with the fragmentation and excessive configuration that Mocha was requiring. I had misgivings, and I was afraid dealing with Meteor would be more awkward, but it turns out that Jest has powerful ways to deal with even Meteor’s meteor/package imports.

Our code under Test: admins making simulated trades for users

Instead of testing a simplistic (a, b) => a + b function, let’s try to imagine something more real-world. Say you have a stock trading platform, providing both real and simulated trades to its users. The trade simulator is simplified and doesn’t handle certain things, so we have a function to allow an administrator to make a simulated trade on behalf of a user. For example, all owners of Whole Foods had to be converted to Amazon stock when Amazon acquired them, so the admin would need to sell WFM and buy AMZN on a certain date at certain prices. Here’s the code for the file placeTradeForUser.js.

// This function allows admins to place arbitrary trades for a
// user or group of users, useful for correcting problems or
// dealing with company acquisitions where one stock
// is converted into another for all owners.

import { Meteor } from 'meteor/meteor';
import { placeOrder } from './tradeSimulator';
export default placeTradeForUser = async orderSpec => {
  const user = Meteor.users.findOne({
    username: orderSpec.username
  });

  // Throw an error if the user is not a simulated user
  // (we don't want to ever issue trades to a live account!)
  if (user.tradingAccount.provider !== 'simulator') {
    throw new Error(
      `user ${user.username} is not a simulated account.`
    );
  }

  const transactionId = await placeOrder(orderSpec);
  return transactionId;
};

Placing trades for someone else is useful on a simulator, but you wouldn’t want to have any chance of triggering trades on the live platform! What we want to test is that whether it will correctly throw that error (you might want other protections as well 😃, but we’ll stick with a simplified example).

The test code

Here’s the content of placeTradeForUser.test.js. Note the name will put it right next to the file under test in your directory structure. This makes it easy for you to work with both files at once.

jest.mock('meteor/meteor');
import { __setUsersQueryResult } from 'meteor/meteor';

jest.mock('./tradeSimulator', () => ({
  placeOrder: jest.fn()
}));
import { placeOrder } from './tradeSimulator';

import placeTradeForUser from './placeTradeForUser';

describe('placeTradeForUser()', () => {
  test('allow trades on provider `simulator`', async () => {
    __setUsersQueryResult({
      username: 'testuser',
      tradingAccount: {
        provider: 'simulator',
      },
    });
    await placeTradeForUser({});
    expect(tradeSimulator.placeOrder).toBeCalled();
  });
  test('disallow trades on provider `fidelity`', async () => {
    __setUsersQueryResult({
      username: 'testuser',
      tradingAccount: {
        provider: 'fidelity',
      },
    });
    const result = placeTradeForUser({});
    await expect(result).rejects.toEqual(
      new Error('user testuser is not a simulated account.')
    );
  });
});

There’s a lot to look at here, but first notice that our two test statements. In the first, we expect(tradeSimulator.previewOrder).toBeCalled(). We don’t look at the result; we’re just making sure the other module is called normally if the account is simulated. The second test uses a live trading account, and although the test is a little harder to read, you can gather that it should return a rejected promise with an error message (it’s a rejected promise because placeTradeForUser() is an async function – otherwise you could use the nicer expect(result).toThrow(new Error(...))). A better way to expect rejected promises is on the way.

So great, we have a useful test! But the code we are testing accesses placeOrder(), an outside function which comes via a named import, and more importantly, it makes a database query via Meteor.user. Meteor imports will cause Jest to throw an error because Jest expects the import path to point to an npm module that isn’t there. Most of this code is standard Jest mocking (see the docs here and here), but two things deserve mention.

Mocking named imports

This is an ES6 trick rather than Meteor-specific, but since we’re at the front of the charge toward modern JavaScript, we’ll sometimes run into things that are still designed around how require works. If we were using a default export (e.g., import placeOrder from './placeOrder';), we could substitute our own implementation, but we are using a named import (import { placeOrder } from './tradeSimulator' – note the { braces }), and ES6 module imports will throw an error if mutated (nor can we move it to an arbitrary object, as Jest won’t pick it up and stub it in for us). Luckily, jest.mock() can handle this. If we pass the path of the file to mock and a function returning the mocked exports, we can import { placeOrder } normally (hat tip to Jesse Rosenberger for showing me this).

Mocking meteor/meteor

Everything up to this point is just standard Jest code, but Meteor still imports Meteor packages via the meteor/packageName convention, which Meteor knows how to process, but which Jest can’t. (Note: this part will hopefully go out of date with a future version of Meteor as Meteor works its way to being 100% npm-based.) Luckily, Jest provides us with the tools we need (hat tip to StackExchange user chmanie ). In this case, in the real app we’d be accessing a database, and we need the value it returns – and control of that value – to do our test. First we need to tell Jest where to find our mocks, and luckily you can do that in your jest.config.jsfile or package.json. We communicate this to Jest with

{
  moduleNameMapper: {
    '^meteor/(.*)': '<rootDir>/.meteorMocks/index.js',
  },
}

Then we can put our mock code in .meteorMocks/index.js:

let usersQueryResult = [];
export function __setUsersQueryResult(result) {
  usersQueryResult = result;
}
export const Meteor = {
  users: {
    findOne: jest.fn().mockImplementation(() => usersQueryResult),
    find: jest.fn().mockImplementation(() => ({
      fetch: jest.fn().mockReturnValue(usersQueryResult),
      count: jest.fn(),
    })),
  },
};
export const Mongo = {
  Collection: jest.fn().mockImplementation(() => ({
    _ensureIndex: (jest.fn()),
  })),
};

This gives us a closure that allows us to set an arbitrary result usersQueryResult to be returned by our mocked function as well as the private __setUsersQueryResult() that our tests can import to change that value. In addition, it implements some of the structure of the Meteor object. Note that we don’t have to implement everything, only things that are used by our function under test. We could omit find, fetch or count in this example, but it can also be used by other tests that may need those things.

Importing other Meteor packages

The Mongo mock object in the above code is also not needed in our example, but it will successfully stub out a collection definition if needed, including indexes., e.g. this file:

import { Meteor } from 'meteor/meteor';
import { Mongo } from 'meteor/mongo';
const Performance = new Mongo.Collection('performance');
Performance._ensureIndex({
  userId: 'text',
});
export default Performance;

See how that worked? new Mongo.Collection() will return an object that has a function _ensureIndex, again allowing us to compile without errors. That’s it. You are now ready to test anything, anywhere.

If you’re interested in learning more about JavaScript testing and its benefits, join us at Assert(js) Conf on February 22, 2018!

We'll share what we've learned, get tips and info in your inbox occasionally