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.
- My project repository: Barrierless
- Commit for testing: 4b8f1dd
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
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
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;
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.
-
env.jest
(for test environment variables):
GROQ_API_KEY=
GEMINI_API_KEY=
-
jest.setup.js
(loads the variables):
import * as dotenv from "dotenv";
dotenv.config({ path: "env.jest" });
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", () => ({}));
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
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.");
}
}),
}));
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 });
...
}
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.",
);
}
});
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");
});
});
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(),
);
// ...
}
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");
However, this would not not work, with the error:
TypeError: lang6393.iso6393.find is not a function
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",
},
],
}));
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");
});
Lessons Learned
-
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. - Dynamically Importing Modules: In ESM, dynamic imports after mock setup ensure that Jest’s mocks apply correctly.
- 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)