DEV Community

Vinh Nhan
Vinh Nhan

Posted on

A Guide to Setting Up Jest for JavaScript Testing with ESM

Hello!! 👋

In this blog, I’ll walk through why I chose Jest as my testing framework, the detailed setup process, handling ESM module challenges, mocking, and environment variable management. By the end of this guide, you’ll have a clear approach to setting up Jest in projects using JavaScript with ESM.


Why Jest?

For JavaScript projects, Jest is a robust testing framework, known for its rich feature set and smooth developer experience. Jest integrates well with JavaScript and Node.js applications, making it easy to write tests with features like automatic mocking and code coverage.

Since Jest is one of the most popular testing frameworks for JavaScript, it has an active community, strong documentation, and compatibility with other popular tools. This support makes Jest an ideal choice for my project.

Setting Up Jest for an ESM-Based Project

Setting up Jest for a project using ECMAScript Modules (ESM) presented some unique challenges. Jest was primarily designed for CommonJS (CJS), so a few additional steps are necessary to make it work smoothly with ESM.

Step 1: Install Jest

To start, install Jest as a development dependency:

npm install --save-dev jest
Enter fullscreen mode Exit fullscreen mode

Step 2: Configure Jest for ESM

Since Jest’s ESM support is still evolving, we need to use a special command to run Jest in ESM mode:

node --experimental-vm-modules node_modules/jest/bin/jest.js
Enter fullscreen mode Exit fullscreen mode

Step 3: Set Up jest.config.js

Next, create a jest.config.js file to define Jest’s configuration. This setup cleans up mocks between tests, collects coverage data, and configures environment variables specifically for testing.

Here’s my jest.config.js:

/** @type {import('jest').Config} */
const config = {
  clearMocks: true,
  collectCoverage: true,
  coverageDirectory: "coverage",
  setupFiles: ["<rootDir>/jest.setup.js"], // path to a setup module to configure the testing environment before each test
  transform: {},
  verbose: true,
};

export default config;
Enter fullscreen mode Exit fullscreen mode

Explanation:

  • clearMocks: Automatically resets all mocks between tests, ensuring a fresh state for each test.
  • collectCoverage: Enables code coverage tracking.
  • coverageDirectory: Specifies the output directory for coverage reports.
  • setupFiles: Loads a setup file before tests, which is useful for setting environment variables.
  • verbose: Provides detailed test output, helpful for debugging.

Step 4: Set Up Environment Variables for Tests

To avoid using production environment variables in tests, I created a separate env.jest file for testing environment variables. Then, I used dotenv in Jest’s setup file to load these variables.

  1. env.jest (for test environment variables):
   GROQ_API_KEY=
   GEMINI_API_KEY=
Enter fullscreen mode Exit fullscreen mode
  1. jest.setup.js (loads the variables):
   import * as dotenv from "dotenv";
   dotenv.config({ path: "env.jest" });
Enter fullscreen mode Exit fullscreen mode

Handling ESM-Specific Mocking Challenges in Jest

Jest’s documentation primarily covers CommonJS, so ESM-specific details can be a bit sparse. Instead of jest.mock(), ESM uses jest.unstable_mockModule(), which requires a factory function to create mocks.

Example: Mocking a Module in ESM

jest.unstable_mockModule("groq-sdk", () => ({}));
Enter fullscreen mode Exit fullscreen mode

Key Insight: Dynamic Imports for ESM

Since ESM evaluates import statements before other code, static imports load modules before Jest has a chance to apply mocks (read more about it here). To ensure that the mock implementation is applied, dynamically import the module after the mock setup.

jest.unstable_mockModule("groq-sdk", () => ({
  ...
}));

const { Groq } = await import("groq-sdk"); // using await to dynamically import the module
Enter fullscreen mode Exit fullscreen mode

Mocking a Class Using Jest (ESM)

In my project, I needed to mock the Groq class from the groq-sdk module. Here’s how I created a mock that accepts an API key object and checks the validity of it:

jest.unstable_mockModule("groq-sdk", () => ({
  // we put the class's constructor implementation inside the factory function
  Groq: jest.fn().mockImplementation((apiKeyObj) => {
    if (apiKeyObj.apiKey === MOCKED_GROQ_API_KEY) {
      return {
        chat: {
          completions: {
            create: mockCreate,
          },
        },
      };
    } else {
      throw new Error("401: Unauthorized. Invalid API key.");
    }
  }),
}));
Enter fullscreen mode Exit fullscreen mode

Here’s an example of how I tested the Groq class with Jest and ESM.

My original source code creates a Groq instance by passing the API key from an environment variable:

import { Groq } from "groq-sdk";
import * as dotenv from "dotenv";
dotenv.config(); 
import process from "node:process";

const GROQ_API_KEY = process.env.GROQ_API_KEY;

export async function getGroqChatCompletion(
  fileContent,
  targetLang,
  providerModel,
) {
...
const groq = new Groq({ apiKey: GROQ_API_KEY });
...
}
Enter fullscreen mode Exit fullscreen mode

In a test, a Groq object can be instantiated using the mocked Groq class above, like how it would be done realistically. For instance:

test("GROQ API key is invalid, throw error", async () => {
  try {
    const groq = new Groq({ apiKey: "dummy_key" });
  } catch (error) {
    expect(error.message).toMatch(
      "401: Unauthorized. Invalid API key.",
    );
  }
});
Enter fullscreen mode Exit fullscreen mode

Handling Environment Variables in Jest

To work with environment variables dynamically during tests, I adjusted them before each test and imported the testing module afterward. This approach lets the module reference the updated variable, rather than the original value from env.jest.

import process from "node:process";
const originalEnv = process.env;

const MOCKED_GROQ_API_KEY = "123";

describe("getGroqChatCompletion() tests", () => {
  beforeEach(() => {
    jest.resetModules(); // clears the cache
    process.env = { ...originalEnv };
  });

  afterAll(() => {
    process.env = originalEnv; // ensure original values are restored after all tests
  });

  test("valid arguments, return translated content", async () => {
    process.env = { ...originalEnv, GROQ_API_KEY: MOCKED_GROQ_API_KEY }; // set new value to GROQ_API_KEY

    const { getGroqChatCompletion } = await import("../../src/ai/groq_ai.js"); // import the testing module that uses the new GROQ_API_KEY

    const content = await getGroqChatCompletion("Hello", "english", MODEL); // "123" is used here, as opposed to the value from env.jest
    expect(content).toBe("Mocked response: Hello");
  });
});
Enter fullscreen mode Exit fullscreen mode

Mocking Array Modules

For some modules, such as iso-639-3, I had to mock the module as an array to simulate its actual structure.

Source code utils.js:

import * as lang6393 from "iso-639-3";

export function getIso639LanguageCode(language) {
  // ....
  const language6393 = lang6393.iso6393.find(
    (lang) => lang.name.toLowerCase() === language.toLowerCase(),
  );
  // ...
}
Enter fullscreen mode Exit fullscreen mode

My first attempt of mocking iso6393 was:

// wrong implementation
const mockFind = jest.fn().mockImplementation(() => {});

jest.unstable_mockModule("iso-639-3", () => ({
  find: mockFind;
}));

const { iso6393 } = await import("iso-639-3");
const { getIso639LanguageCode } = await import("./utils.js");
Enter fullscreen mode Exit fullscreen mode

However, this would not not work, with the error:

TypeError: lang6393.iso6393.find is not a function
Enter fullscreen mode Exit fullscreen mode

To understand the module, I referred to the source code of iso-639-3 (under node_modules/iso-639-3/iso6393.js), and learned that iso6393 is an array of objects. Therefore, the modified mock implementation is:

jest.unstable_mockModule("iso-639-3", () => ({
  __esModule: true,
  iso6393: [
    {
      name: "Zulu",
      type: "living",
      scope: "individual",
      iso6393: "aaa",
    },
  ],
}));
Enter fullscreen mode Exit fullscreen mode

With the iso6393 array mocked properly, the find() method can be called on ios6393. I could then test functions that depend on this module.

test("language is not found from ISO 639-1, return ISO 639-3 language code", async () => {
  const language = "Zulu";
  expect(getIso639LanguageCode(language)).toBe("aaa");
});
Enter fullscreen mode Exit fullscreen mode

Lessons Learned

  1. Understanding Module Structures: Knowing the actual structure of each module greatly simplifies the mocking process. For instance, iso-639-3 is an array of objects, which I initially tried to mock incorrectly. Looking at the module’s source helped me identify the correct format.
  2. Dynamically Importing Modules: In ESM, dynamic imports after mock setup ensure that Jest’s mocks apply correctly.
  3. Testing with Environment Variables: It’s essential to reset environment variables and cache between tests to ensure consistent results.

Reflections on Testing with Jest

Testing with Jest for ESM projects is challenging, especially due to limited support and extra steps for module mocking and import management. But testing reveals valuable insights, highlights bugs, and forces logical refinements. Now that I’ve worked with Jest’s more advanced features, I look forward to implementing it more in future projects.


With this guide, I hope you can set up Jest effectively for ESM-based JavaScript projects, streamline your testing process, and handle challenges with module mocking and environment management. Testing with Jest is invaluable for ensuring code quality and stability, and the skills you gain here will benefit all your future development efforts.

Top comments (0)