Michael Weiner

January 22, 2024

Developer from the Future [#1]: Mock-ing Third-Party Libraries in an ESM with Jest Unstable Mocking

As a full-time developer and someone who likes working on various side projects, I often find myself looking for advice from those who have gone before me. I find that some of my best learning happens from watching and reading what others have already created, how they went about it, and their pain points. Other times, I just need a pointer in the right direction to get started. 

"Developer from the Future" is a new series that I'm starting to journal my own learning. I hope to create a series of blogs with useful explanations, code patterns, and snippets that future developers can use and learn from. These entries are likely to be more technical, so if that is not your thing - no worry! I still plan on doing all of my other types of writing.

Background

I have created a CLI, wlbot (short for WeatherLink Bot), to make interfacing with an API for internet connected weather stations easier. In an effort to be able to automate deployments of future releases of this CLI, I wanted unit testing on 100% (or near 100%) of my code base that could be run to ensure functionality prior to a new release or on PRs from open-source contributions.

The tricky part about unit testing my CLI is that it uses several third-partly libraries (e.g. Ora). To unit test my code, I don't need to test the functionality of another library's code. I just want to test my code, which means I needed to figure out how I could "mock" each library.

I decided to use Jest as the test framework. I quickly discovered that Jest doesn't play nicely with mock-ing libraries when your code base is an ECMAScript Module (ESM). Jest does have some documentation on a work around, but that wasn't a huge help for me to get my unit testing off the ground.

Writing a Test

The following code comes directly from my CLI. This function below is a CLI command the user can run to ensure the necessary environment variables needed by my CLI are set properly. It's a good example to get your feet wet with unit testing and mock-ing because the function isn't doing a ton of logic, but it captures the essence of why a mock is needed.

import chalk from 'chalk';
import ora from 'ora';

export default (options) => {
  const spinner = ora('Reading Environment Variables').start();

  let evValues = {
    "WEATHER_LINK_API_KEY": process.env.WEATHER_LINK_API_KEY || "",
    "WEATHER_LINK_API_SECRET": process.env.WEATHER_LINK_API_SECRET || "",
    "WEATHER_LINK_BASE_API_URL": process.env.WEATHER_LINK_BASE_API_URL || "",
  }

  if (!options.list) {
    for (const [key, value] of Object.entries(evValues)) {
      evValues[key] = evValues[key].slice(0, 3) + "*".repeat(value.length - evValues[key].slice(0, 3).length);
    }
  }

  spinner.succeed(chalk.green.bold(`Environment Variables Retrieved`));
  console.log(JSON.stringify(evValues, undefined, 2));
  return;
};

The function starts an Ora spinner, reads a couple of environment variables, marks the Ora spinner as successful, and returns the environment variable values to the user.

Let's break down the unit test I wrote:

import { jest } from '@jest/globals'

const oraSucceedMock = jest.fn();
const oraStartMock = jest.fn(() => ({ succeed: oraSucceedMock }));

jest.unstable_mockModule('ora', () => ({
  default: () => ({
    start: oraStartMock,
  })
}));

const { default: config } = await import('../../commands/config.js');

describe('wlbot config', () => {

  const logMock = jest.spyOn(console, "log").mockImplementation(() => { });
  const backupEnv = process.env;

  beforeEach(() => {
    jest.resetModules();
  })

  afterEach(() => {
    jest.clearAllMocks();
    process.env = backupEnv;
  });

  test.each([
    {
      name: 'Env Vars Correctly Protected without List Option',
      options: {},
      wipeEnvs: false,
      expected: {
        WEATHER_LINK_API_KEY: "sam******",
        WEATHER_LINK_API_SECRET: "sam*********",
        WEATHER_LINK_BASE_API_URL: "htt****************************"
      },
    },
    {
      name: 'Env Vars Correctly Exposed with List Option',
      options: { list: true },
      wipeEnvs: false,
      expected: {
        WEATHER_LINK_API_KEY: "sampleKey",
        WEATHER_LINK_API_SECRET: "sampleSecret",
        WEATHER_LINK_BASE_API_URL: "https://api.weatherlink.com/v2/"
      },
    },
    {
      name: 'Correctly Handle Missing Environment Variables',
      options: {},
      wipeEnvs: true,
      expected: {
        WEATHER_LINK_API_KEY: "",
        WEATHER_LINK_API_SECRET: "",
        WEATHER_LINK_BASE_API_URL: ""
      },
    },
  ])
    ('$name', async ({ options, wipeEnvs, expected }) => {
      if (wipeEnvs) { process.env = {}; }

      config(options);

      expect(oraStartMock).toHaveBeenCalledTimes(1);
      expect(logMock).toHaveBeenCalled();
      expect(oraSucceedMock).toHaveBeenCalledTimes(1);
      expect(logMock).toHaveBeenCalledWith(JSON.stringify(expected, undefined, 2));
    });
});

There are three main components of this unit test that I want to highlight:

import { jest } from '@jest/globals'

const oraSucceedMock = jest.fn();
const oraStartMock = jest.fn(() => ({ succeed: oraSucceedMock }));

jest.unstable_mockModule('ora', () => ({
  default: () => ({
    start: oraStartMock,
  })
}));

const { default: config } = await import('../../commands/config.js');

This took the most time for me to understand. After reading Jest's documentation about 15 times and getting a great pointer in this GitHub issue, I was finally able to connect the dots.

The first line in the code above imports Jest in the "ESM way." In my case, this is required since my CLI is an ECMAScript Module. Next, we are creating stubs for certain functions of the Ora library. This allows me to "hijack" the Ora library code but still ensure that my CLI is correctly calling the third-party library. Using the "unstable_mockModule" feature I can tell Jest which functions from the Ora library I want to mock and what functions it should substitute in their place.  The last line is where I dynamically import my own code that I want to test.

beforeEach(() => {
  jest.resetModules();
})

afterEach(() => {
  jest.clearAllMocks();
  process.env = backupEnv;
});

This code is small, yet mighty. Before every unit test case we want to reset all modules that Jest has under its control, and after each test case we want to clear the properties of any mocks that were created. This helps to ensure that our test cases are not going to interfere with each other.

expect(oraStartMock).toHaveBeenCalledTimes(1);
expect(logMock).toHaveBeenCalled();
expect(oraSucceedMock).toHaveBeenCalledTimes(1);
expect(logMock).toHaveBeenCalledWith(JSON.stringify(expected, undefined, 2));

This last piece of code is what actually tests my underling CLI command. You will see references to several mocks. Using Jest, I confirm that my code called the Ora library to start a CLI spinner, that my functions correctly called console.log, called the Ora library to mark the spinner as succeeded, and that my function returned the correct output.

Once I started to get a handle on this code pattern, I was able to scaffold the pattern to the rest of my CLI. Although this is a rather small example, I hope the power of mock-ing and stub-ing is becoming clear. As the developer you can control the input and output of third-party libraries to artificially and repeatably test your code under all possible situations and scenarios.

Resources

Below is a list of resources that you might find helpful as you work to unit test your own code. 

About Michael Weiner

Hey, visitor! I'm Michael, a software engineer based in Minnesota, USA. I am an IBMer working on IBM Cloud Kubernetes Service. Feel free to poke around some of my work at michaelweiner.org. Below are some of my personal thoughts on business and my experiences in the computer science industry. Thanks for reading!