Jest’s native ESM support has been a game-changer, but getting it right, especially when dealing with mixed CJS/ESM environments or older Node.js versions, can still trip you up. The core issue is how Jest, historically a CommonJS-centric tool, resolves and executes ES Module import and export statements.
Common Causes and Fixes for Native ESM Support in Jest
-
jest-circusNot Found or Incorrect Jest Version:- Diagnosis: A common error message is
ReferenceError: Cannot find module 'jest-circus'. This often stems from an outdated Jest installation or a conflict between Jest and its internal dependencyjest-circuswhich handles the ESM execution. - Fix: Ensure you have a recent version of Jest installed. For Jest v28+, native ESM support is built-in. Run
npm install --save-dev jest@latestoryarn add --dev jest@latest. - Why it works: Newer Jest versions bundle
jest-circuscorrectly and have the necessary infrastructure to parse and run ESM test files.
- Diagnosis: A common error message is
-
type: "module"inpackage.jsonConflicts with CJS Dependencies:- Diagnosis: You’ll see errors like
TypeError [ERR_UNKNOWN_MODULE_FORMAT]: Unknown file format: .jsor unexpectedrequire()failures when Jest tries to import a CommonJS module that was incorrectly interpreted as ESM. This happens when yourpackage.jsondeclares"type": "module", making all.jsfiles ESM by default, but you still have CJS dependencies that don’t play nice. - Fix:
- Option A (Recommended for new projects): Keep
"type": "module"and explicitly rename CJS files to.cjsor use.mjsfor ESM files if you need to interop with CJS. - Option B (For existing CJS projects): Remove
"type": "module"from yourpackage.json. Then, explicitly mark your test files as ESM by renaming them to.mjs. - Option C (Less ideal): If you must keep
"type": "module", configure Jest’stransformto handle.jsfiles.
- Option A (Recommended for new projects): Keep
- Why it works: This ensures Node.js (and by extension, Jest) correctly identifies module types. By defaulting to ESM (
"type": "module") and using.cjsfor CJS, you create a clear separation. Conversely, removing"type": "module"makes.jsfiles CJS by default, requiring explicit.mjsfor ESM tests.
- Diagnosis: You’ll see errors like
-
transformConfiguration for.jsor.mjsFiles:- Diagnosis: Errors like
SyntaxError: Unexpected token 'export'orSyntaxError: Unexpected token '<'(when parsing HTML instead of JS) indicate Jest isn’t transforming your ESM syntax. This is common if you’re using a Babel transform for ESM. - Fix: Configure Jest to use its built-in ESM transformer or a Babel transform.
- Native ESM (Jest 28+): Ensure
testEnvironment: "node"(orjsdom) and no customtransformfor.jsor.mjsfiles if they are your ESM tests. Jest will handle them natively. - Babel Transform: If you need Babel for older Node versions or specific transpilation, configure
jest.config.js:
Make sure you have// jest.config.js module.exports = { transform: { '^.+\\.(js|mjs|ts|tsx)$': ['babel-jest', { // Your Babel presets/plugins here, e.g.: presets: [['@babel/preset-env', { targets: { node: 'current' } }]], plugins: ['@babel/plugin-transform-runtime'], }], }, // ... other config };@babel/preset-envandbabel-jestinstalled.
- Native ESM (Jest 28+): Ensure
- Why it works: The
transformoption tells Jest which preprocessor to use for specific file extensions. For native ESM, Jest handles it automatically. For Babel, it ensures that ESM syntax is transpiled into a format Jest understands.
- Diagnosis: Errors like
-
testEnvironmentSetting:- Diagnosis: Errors like
ReferenceError: document is not defined(when running in a Node environment) orReferenceError: require is not defined(when trying torequirein a browser-likejsdomenvironment) can occur if yourtestEnvironmentdoesn’t match your module type or test needs. - Fix: Set
testEnvironment: "node"in yourjest.config.jsif your tests primarily interact with Node.js APIs or if you’re relying on Node’s native ESM resolution. If your tests are DOM-heavy, use"jsdom". Jest v28+ supports ESM in both environments.// jest.config.js module.exports = { testEnvironment: "node", // or "jsdom" // ... other config }; - Why it works: The
testEnvironmentdictates the global scope and available APIs for your tests."node"provides Node.js globals and resolution, while"jsdom"provides a simulated browser DOM. Native ESM support in Jest v28+ is designed to work correctly within these environments.
- Diagnosis: Errors like
-
moduleNameMapperand ESM Resolution:- Diagnosis: If you’re using
moduleNameMapperto alias modules and encounterModule not founderrors for ESM imports, the mapping might be incorrect or not respecting ESM resolution rules. - Fix: Ensure your
moduleNameMapperregex correctly targets the files and that the replacement path is valid. For ESM, Jest’s internal resolver should handle most cases, but complex mappings might need adjustment. Example:// jest.config.js module.exports = { moduleNameMapper: { '^@/(.*)$': '<rootDir>/src/$1', }, // ... other config }; - Why it works:
moduleNameMapperintercepts module resolution. For ESM, Jest’s resolver still applies after the mapping, so the target of the alias must be a resolvable module path.
- Diagnosis: If you’re using
-
--experimental-vm-modulesFlag (for older Node versions or specific setups):- Diagnosis: If you’re running Jest via a custom script that invokes Node.js directly and encountering ESM errors, you might be missing the Node.js flag that enables ESM support.
- Fix: When running Jest from the command line or in a script, prepend the Node.js flag:
Note: This flag is generally not needed for Jest v28+ when runningnode --experimental-vm-modules node_modules/jest/bin/jest.js --config jest.config.js # Or if using a package.json script: # "scripts": { # "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js" # }jestdirectly, as Jest manages its own Node.js execution context. It’s more for manual Node.js invocations or older Jest versions. - Why it works: The
--experimental-vm-modulesflag is a Node.js runtime option that enables the experimental ES Module support within Node.js itself, which Jest leverages.
After fixing these, the next hurdle is often dealing with dynamic import() calls in tests, which can require specific Jest configurations or Jest mocks to handle properly.
Understanding Jest’s Native ESM Support
The most surprising thing about Jest’s native ESM support is that it doesn’t fundamentally change how you write your tests, but rather how Jest executes them. You can write import and export statements directly in your test files, and Jest (v28+) will handle them without needing a separate transpilation step like Babel for basic ESM syntax.
Let’s see it in action with a simple module and a test.
src/math.js (ES Module)
// src/math.js
export function add(a, b) {
return a + b;
}
export function subtract(a, b) {
return a - b;
}
src/math.test.js (ES Module Test)
// src/math.test.js
import { add, subtract } from './math.js'; // Note the .js extension is often required for native ESM resolution
describe('Math Functions', () => {
test('should add two numbers correctly', () => {
// This is a standard Jest test, but it imports from an ESM module
expect(add(5, 3)).toBe(8);
});
test('should subtract two numbers correctly', () => {
expect(subtract(10, 4)).toBe(6);
});
});
jest.config.js (Minimal configuration for native ESM)
// jest.config.js
module.exports = {
// Jest v28+ supports ESM out of the box.
// Ensure your test files are .js or .mjs and your package.json
// has "type": "module" or you are correctly handling CJS interop.
// For Node.js environment (most common for backend/utility tests)
testEnvironment: 'node',
// If you have both .js (CJS) and .mjs (ESM) files and want Jest
// to treat .mjs as ESM and .js as CJS, this is usually automatic
// with "type": "module" in package.json.
// If "type": "module" is NOT in package.json, .js is CJS, and you'd
// use .mjs for your ESM tests.
// If you're explicitly using .mjs for your test files:
// testMatch: ['**/__tests__/**/*.mjs', '**/*.test.mjs'],
// If your source files are also ESM and you need Jest to resolve them:
// If package.json has "type": "module", Jest will treat .js as ESM.
// If not, you might need to specify module extensions or use .mjs.
// moduleFileExtensions: ['js', 'jsx', 'ts', 'tsx', 'json', 'node', 'mjs'],
};
Running the Test:
If your package.json has "type": "module", Jest will generally pick up src/math.js as an ES Module and src/math.test.js will import it correctly.
npm test
# or
yarn test
Internal Mechanism:
When Jest encounters an import statement in a file it’s processing for tests, it checks the file’s type. If it’s an ES Module (determined by Node.js’s module resolution, often influenced by package.json’s "type": "module" or file extensions like .mjs), Jest uses Node.js’s built-in VM module (vm.Module) or a similar internal mechanism to parse and execute the module. It doesn’t need to pass the file through Babel for basic import/export syntax. This is significantly faster and more accurate for modern JavaScript.
The testEnvironment setting is crucial. When set to "node", Jest runs your tests within a Node.js context, allowing it to leverage Node’s native ESM loader. When set to "jsdom", it simulates a browser environment, and Jest uses a process that can handle ESM within that simulated DOM.
For Jest to correctly resolve imported file paths in ESM, you often need to include the file extension (e.g., ./math.js instead of ./math). This is a standard behavior in Node.js ESM resolution.
One critical detail is how Jest handles the exports field in package.json. When resolving modules, Jest respects the exports map if present, which allows package authors to define specific entry points for different environments (e.g., CommonJS vs. ESM). If your project or a dependency uses exports, Jest’s ESM resolver will correctly pick the appropriate subpath based on the current resolution context.
The next step is often integrating ESM tests with TypeScript, which requires a specific Jest transformer and tsconfig setup.