Partially Mocking an ES Module with Jest and Node.js

Disclaimer: This article has nothing to do with C++. Also, I do not have a huge amount of experience with the Node.js ecosystem. This information is provided here because of the issues I had reading outdated, and often incorrect, “solutions” on forums and blogs. I hope someone finds it useful.

Suppose you have a database.js module which interacts with a database server to query and update users. This is abstracted away from the client user.js module, which is what your Node.js/Express app communicates with. Now suppose you want to write unit tests for user.js without accessing the real database. You could create a “testing” database, but a better way may be to “mock” database.js. This is where the testing framework imports user.js, while providing its own functions which override those in database.js for the duration of the test suite.

I’m not going to try to describe all of Jest here, and I’m assuming also that the reader is familiar with ESM and the import keyword together with both named and default exports. The method which works for me is as follows:

  • Create a test file user.test.js which imports the Jest framework
  • Import the module you want to mock (database.js) dynamically with await import()
  • Redefine the exports as desired within jest.unstable_mockModule()
  • Import the module you want to unit-test (user.js), again dynamically
  • Write the tests with test('what it tests', async () => { test here... });
  • Create a suitable package.json and jest.config.json
  • Set environment variable (important – still necessary with v21.6): NODE_OPTIONS=--experimental-vm-modules
  • Run npm test in the project directory

Here is the sample user.test.js demonstrating the above:

import { jest } from '@jest/globals';

const mockModule = await import('./database.js');

jest.unstable_mockModule('./database.js', () => ({
  ...mockModule,
  default: jest.fn(() => 1),
  fetchFromDatabase: jest.fn().mockResolvedValue({ id: 42, name: 'John Doe', email: 'john.doe@example.com' }),
}));

const { getUser, makeUser, getTotalUsers } = await import('./user.js');

test('getUser() returns correct name and email', async () => {
  const user = await getUser(1);
  expect(user.user).toBe('John Doe');
  expect(user.email).toBe('john.doe@example.com');
});

test('getTotalUsers() returns correct number', async () => {
  const numberOfUsers = getTotalUsers();
  expect(numberOfUsers).toBe(1);
});

test('makeUser() is callable', async() => {
  const newUser = await makeUser('Jane Doe', 'jane.doe@example.com');
  expect(newUser).toBe(undefined);
});

Line 6 is the spread operator which imports all of the exports of database.js into the mock. Line 7 redefines the default export getNumberOfUsers() to always return 1. Line 8 defines the result of fetchFromDatabase() (which is a Promise as it is an async function) to always be resolved as a dummy user. (Should mocking all the exports be required, lines 3 and 6 are not needed.)

Here is user.js:

import getNumberOfUsers, { fetchFromDatabase, generateUserId } from './database.js';

export async function getUser(userId) {
  const user = await fetchFromDatabase(userId);
  return { user: user.name, email: user.email };
}

export async function makeUser(user, email) {
  const newUser = { id: generateUserId(), user: user, email: email };
  // store this somehow
  return newUser;
}

export function getTotalUsers() {
  return getNumberOfUsers();
}

And here is the dummy database.js:

const db_credentials = { username: 'dbuser', password: '$$hashed_password$$' };

// We want to mock this function
export async function fetchFromDatabase(id) {
  // Query database here
  return [{ id: 1, name: 'Alice', email: 'alice@example.com' },
    { id: 2, name: 'Bob', email: 'bob@example.com' },
    { id: 3, name: 'Charlie', email: 'charlie@example.com' }][id];
}

// We don't want to mock this function
export function generateUserId() {
  const date = new Date();
  return date.getTime();
}

// This is the default export, which we do want to mock
function getNumberOfUsers() {
  // Query database here
  return 3;
}

export default getNumberOfUsers;

Finally a minimalist jest.config.json which prevents harmful rewriting of our test module:

{
  "transform": {}
}

And a package.json setting the necessary options:

{
  "type": "module",
  "scripts": {
    "test": "jest"
  }
}

Now you can run (Windows):

set NODE_OPTIONS=--experimental-vm-modules
npm test

Or (everything else):

NODE_OPTIONS=--experimental-vm-modules npm test

The output should hopefully look something like:

Finally, the source is available to download from here (as a .zip), and the version of Jest used was 29.7.0. (Node.js latest stable v21.6.1 and npm 10.2.4). This process is likely to change, so if you are writing tests for a larger project and don’t want them to break in the future, you may wish to install Jest as a development dependency with npm install --save-dev jest, which will install Jest in a node_modules folder within your project.

If you have any issues or additions to make to the above, please leave a comment; I shall try to keep this article updated as support for ESM with Jest matures.

Leave a comment