jest: jest.mock does not mock an ES module without Babel

šŸ› Bug Report

In an ES module Node project, with no Babel, jest.mock works when the mocked module is a node_modules package that exports CommonJS, but it isnā€™t working for me mocking an ES module exported from a file in the same project.

(Itā€™s possible that an NPM package that only exports ES modules has the same issue. I didnā€™t try that case.)

To Reproduce

Steps to reproduce the behavior:

Click Run in the repl, or hereā€™s a simple example:

// main.js
import secondary from "./secondary.js";

export default function main() {
  return secondary();
}

// secondary.js
export default function secondary() {
  return true;
}

// test.js
import { jest } from "@jest/globals";

jest.mock("./secondary.js");

let main;
let secondary;
beforeAll(async () => {
  ({ default: main } = await import("./main.js"));
  ({ default: secondary } = await import("./secondary.js"));
});

test("works", () => {
  secondary.mockReturnValueOnce(false); // TypeError: Cannot read property 'mockReturnValueOnce' of undefined
  expect(main()).toBe(false);
});

Expected behavior

jest.mock(filename) should mock the exports from filename when the test file and the Node project are both ES modules (type: "module")

Link to repl or repo (highly encouraged)

https://repl.it/repls/VerifiableOfficialZettabyte

envinfo

  System:
    OS: macOS 10.15.4
    CPU: (4) x64 Intel(R) Core(TM) i5-4278U CPU @ 2.60GHz
  Binaries:
    Node: 12.16.3 - ~/.nvm/versions/node/v12.16.3/bin/node
    Yarn: 1.21.1 - /usr/local/bin/yarn
    npm: 6.14.4 - ~/DevProjects/reaction/api-utils/node_modules/.bin/npm
  npmPackages:
    jest: ^26.0.1 => 26.0.1 

About this issue

  • Original URL
  • State: open
  • Created 4 years ago
  • Reactions: 174
  • Comments: 115 (22 by maintainers)

Commits related to this issue

Most upvoted comments

I respectfully disagree with this being labeled a feature request. Itā€™s entirely blocking of any effort to move to Jest native ES module support if any files have mocks in them, and there is no workaround that I know of (other than to continue using CommonJS through Babel, which means that ES module support is broken, hence bug).

One thing to note is that it will be impossible to mock import statements as they are evaluated before any code is executed - which means itā€™s not possible to setup any mocks before we load the dependency. So youā€™ll need to do something like this using import expressions.

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

jest.mockModule('someModule', async () => ({ foo: 'bar' }));

let someModule;

beforeAll(async () => {
  someModule = await import('someModule');
});

test('some test', () => {
  expect(someModule.foo).toBe('bar');
});

It will be a bit cleaner with top-level await

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

jest.mockModule('someModule', async () => ({ foo: 'bar' }));

const someModule = await import('someModule');

test('some test', () => {
  expect(someModule.foo).toBe('bar');
});

Any modules loaded by someModule via import statements would work though as weā€™d have time to setup our mocks before that code is evaluated.


The example in the OP follows this pattern, Iā€™m just pointing it out šŸ‘

I started working on this, and I think it makes sense to leave .mock and .doMock for CJS, and introduce a new .mockModule or something for ESM. It will require users to be explicit, and allow the factory to be async. Both of which I think are good things.

Also need to figure out isolateModules. Unfortunately it uses the module name while not being for ES modules.

@thymikee @jeysal thoughts?

As a status update, Iā€™ve opened up a PR here: #10976

Is a fix for this still on the roadmap?

@yurijmikhalevich do you have any update, please?

For anyone wanting to use this functionality I created a demo repo here (including TS support via ts-jest)

https://github.com/connorjburton/js-ts-jest-esm-mock

Alright Iā€™ve figured out how to mock the default as well

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

jest.unstable_mockModule('./MyClass', () => {
  return {
    default: function () {
      return {
        isWorking: () => false,
      };
    },
  };
});

const MyClass = (await import('./MyClass')).default;

describe('MyClass', () => {
  beforeAll(() => {});
  it('should be working', () => {
    const myClass = new MyClass();
    expect(myClass.isWorking()).toBe(false);
  });
});

Turns out @Pauan was right all along, just needed to do some extra fixes

Thanks everyone for the help! ā¤ļø

@yurijmikhalevich is that going to be merged anytime soon?

Iā€™m trying this out im didā€™t find anyway of doing partial mocking, using requireActual gives Must use import to load ES Module

And trying to do a partial import before ā€œbreaksā€ the mock is the module is already imported

Has this been fixed in v28.0.0?

Hey all, here is what I decided to do, in case anyone finds it usefulā€¦ Iā€™m allowing manual dependency injection into my module functions via arguments, to allow for mocks, but setting default argument value to the normally imported dependency. I personally feel like this is reasonable given the circumstances. Example:

//my-module.js
import { myDependency } from './dependencies';
export function doStuff(arg1, arg2, dep_myDependency = myDependency) {
    // TODO: Use dep_myDependency()
}

//my-module.test.js
import { jest, expect } from '@jest/globals';
import { doStuff } from './my-module.js';
test('Does stuff', () => {
    const mockMyDependency = jest.fn();
    doStuff('foo', 'bar', mockMyDependency);
    // Regular app usage: doStuff('foo', 'bar')
    // Expect, etc...
}

@GerkinDev Hello, Iā€™m experimenting with using pure ESM for testing, and find myself in the same situation as @jasonrberk. However, your suggestion isnā€™t working for me to mock a module (fs in this case) thatā€™s being loaded by the module being tested.

Hereā€™s a minimal example:

import { jest } from '@jest/globals'

const mockReadFileSync = jest.fn().mockImplementation(() => {
  return { version: '1.0' }
})

jest.unstable_mockModule('fs', () => {
  return {
    default: jest.fn().mockImplementation(() => {
      return { readFileSync: mockReadFileSync }
    })
  }
})

import { getVersion } from 'utils.js'

it('gets the app\'s version', () => {
  expect(getVersion()).toBe('1.0')
})

This test fails - the real fs gets used, not the mock. Do you have any idea why that might be the case? Thanks!

Edit: I dug around a bit more and see that this PR is supposed to have replaced jest.unstable_mockModule with jest.mockModule in 27.1.1. However, if I try to use that in my test with 27.4.7, I get ā€œjest.mockModule is not a functionā€, which is confusing. It feels like that probably relates to this problem, though.

@anshulsahni no workaround, apart from rewriting your code to not use mocking for the moment with the esm modules

@SimenB this is my first comment in this repo, so the first words unambiguously look like - great work!

Weā€™re in one step from transitioning of our infrastructure into ESM. The only things left are tests. Weā€™re planning to actively use top level await in our code base and there is an obstacle because weā€™re ready to compile our code to ESNext (in terms of TS) but most of our tests use jest.mock() somehow and I want to understand the current state of affairs.

After closing #9860 by #10823 there is one important topic left considering original list in #9430 - support of jest.(do|un)mock (OK, to be honest, probably there is another one - Package Exports support).

Can You explain the current status of the issue?! I mean:

  • Do we have a mechanism to use jest.mock (jest.mockModule?) with ESM now? (probably using 27.0.0-next.x?) And if so - can you provide a short working example?
  • If not - do you plan to release this or similar functionality in 27.0? And do you have a proposal? (jest.mockModule or ā€¦?)

Thanks in advance.

Thanks @connorjburton ! The key is to await import your SUT (after jest.unstable_mockModule) and not just import it. const index = await import("./index"); Then you are mocking things that are imported inside your SUT (index in case of example above)

I think the documentation is lacking the this information and it is not really obvious https://jestjs.io/docs/ecmascript-modules

so is it safe to say, that the ONLY way you can actually do this: https://jestjs.io/docs/mock-functions#mocking-modules is by using babel to convert everything back to CommonJS? Iā€™m so against having to involve babel in my relatively small project, i think it would just be easier to convert the entire project back to CommonJS and just wait for the testing frameworks to catch up.

please correct me if Iā€™m wrong in my understandingā€¦

also, as an aside, it would be really helpful to have a disclaimer on https://jestjs.io/docs/mock-functions#mocking-modules that youā€™ll still need babel for your pure Node project before you build the whole thing and then see this:

https://jestjs.io/docs/ecmascript-modules

@kevinhooke but isnā€™t that conclusion logical in general i mean the basics of the engine are the event loops so it is total clear that import() is a async operation and it will not be ready in the same cpu cycle that is a total expect able result.

If you as a ECMAScript dev get stuck on issues like that you need to learn the fundamentals about the JS Loop and the stack handling else you will shoot your self in the foot later anyway.

img

img

out of the above pictures the conclusion is clear as soon as i await something it is there after that. not before that. so import will only enqueue it while await will wait for the result hope that makes sense.

There IS (sometimes) a workaround, but it requires you to go about things in a very specific way:

outdated: there's a better way (below)
// babyMaker.mjs

import * as cp from 'child_process';

export default function babyMaker() {
  cp.default.fork(/* ā€¦ */);
}
// babyMaker.test.js

import babyMaker from './implementation.mjs';
import * as cp from 'child_process';

beforeAll(() => {
  cp.default.fork = jest.fn(() => ({});
});

In the above, cp.default.fork is a mock that returns an empty object.

Note that cp.fork cannot be mocked this way because it is a direct export (which ESM protects), and even when cp.default.fork is mocked, cp.fork still is NOT because cp.default.fork has been re-assigned to the mock; its original value (the named export fork) is unaffected. Under the hood, the child_process module is doing something like

export function fork() {}

export default {
  fork,
  // ā€¦others
};

Note that child_process is CJS under the hood, but that doesnā€™t matter: export default {ā€¦} works from ESM because the default export is an object whose properties are not protected.

// foo.mjs

function bar() {
  // ā€¦
}

export default {
  bar,
};
// qux.mjs

import foo from 'foo.mjs';

export default qux() {
  const something = foo.bar();

  // ā€¦
}
// qux.test.mjs

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

 // MUST be imported BEFORE qux
const foo = await import('foo.mjs')
  .then(({ default: foo }) => Object.defineProperties(foo, {
    bar: { value: jest.fn() }, // overwrite foo's `default.bar` with the mock
  }));

// MUST be after any/all mocks
const qux = await import('qux.mjs')
  .then(({ default: d }) => d);

This works because ESM does not protect properties of exported objects, only the export itself.

For the mock to be present in the top-level scope (eg when imported), you must use await import in the test file to enforce sequence (including the qux import!).

@SimenB before your PR gets merged, what is the work-around solution here?

@8o8inCodes When you do await import("foo") it gives you the ES6 module, not the default export. The ES6 module is an object that contains all the things which are exported from the module.

So if the module is exporting something called bar then you need to use (await import("foo")).bar to access it.

And if the module does export default then you have to access it with (await import("foo")).default.

The default export isnā€™t special, itā€™s just an ordinary export called default. When you do thisā€¦

import MyClass from "./MyClass";

That is exactly the same as doing this:

import { default as MyClass } from "./MyClass";

So you see that the default really is just an ordinary export called default.

I think it should be const MyClass = (await import('./MyClass')).default;

not related to Jest. TestDouble.js is an entirely different ES6 testing framework:

https://github.com/testdouble/testdouble.js/

https://github.com/testdouble/testdouble.js/tree/main/examples

Thanks,

Jason Berk @.***

On Sun, Aug 14, 2022 at 3:53 PM Wing Chau @.***> wrote:

@jasonrberk https://github.com/jasonrberk Hi, can you further explain whatā€™s it? I googled td.js but couldnā€™t found anything likely to be related to Jest and the ESM issue.

ā€” Reply to this email directly, view it on GitHub https://github.com/facebook/jest/issues/10025#issuecomment-1214438642, or unsubscribe https://github.com/notifications/unsubscribe-auth/AHSZMNPXYYZCSCC4XVAOANTVZFFEHANCNFSM4M6JSMOA . You are receiving this because you were mentioned.Message ID: @.***>

My previous post is not good enough to understand, so I rewrite and post about it again.

As you know, the Jest example of manual-mocks work correctly with Babel. I have tried modifying to work that example without Babel, and I found the follows:

  1. jest.mock() works: when ES Module require CommonJS module with Node.js module.createRequire(filename) method
  • e.g., FileSummarizer.cjs require Manual mock CommonJS module of fs.cjs.
  1. jest.mock() dose not work: when MS Module import ES Module.
  • e.g., when userMocked.test.js import Manual mock ES Module of models/user.mjs, user.getAuthenticated() return the value of model/user.mjs instead of that of model/__mocks__/user.mjs.

After the above the 2nd problem would be fixed, I could use jest.mock with Manual Mocks of import('node:fs/promise').

For further information, see my repository.

I am trying to find workaround for Jest ESM support without Babel.

Based on the Jest examples/manual-mocks, I modified the source and configuration files, and found the follows:

  1. I fix some bugs for test userMocked.test.js, but test fails due to the result is not match to the mockā€™s object.
  2. I fix some bugs for test file_summarizer.test.js, and test pass.

I can not find how to use jest.mock for ES Modules import.

My example project is juggernautjp/jest-manual-mocks-example, so you can modify and verify by downloading from my repository.

If someone has any idea to fix my bugs, will you please let me know?

I was unable to get TypeScript + ESM + mocks to work and ended up transpiling to CJS with Babel for tests.

For anyone still trying to have testable code with an ESM codebase (i.e., { "type": "module" } in package.json), maybe my config will work for you too.

Install the dependencies:

npm i -D @babel/core @babel/plugin-transform-runtime @babel/preset-env @babel/preset-typescript babel-jest babel-plugin-transform-import-meta

If you arenā€™t using TypeScript, then you of course donā€™t need to install @babel/preset-typescript.

jest.config.json:

{
  "roots": [
    "<rootDir>/src/"
  ],
  "setupFiles": [
    "<rootDir>/src/environment.ts"
  ],
  "moduleNameMapper": {
    "^(\\.{1,2}/.*)\\.jsx?$": "$1"
  }
}

The setupFiles are specific to my setup. You can probably remove or update that for your own.

moduleNameMapper strips the .js and .jsx extensions from module names, which is necessary because ESM import statements need the file extension, while the require statements produced by Babel do not.

babel.config.json:

{
  "presets": [
    "@babel/preset-env",
    "@babel/preset-typescript"
  ],
  "plugins": [
    "@babel/transform-runtime",
    "babel-plugin-transform-import-meta"
  ]
}

If you arenā€™t using TypeScript, then you of course donā€™t need @babel/preset-typescript.

If youā€™re using ESM, youā€™ll probably use import.meta.url at some point, which is unavailable in CJS code. The two Babel plugins above transform import.meta.url statements into something CJS can use.

For example, maybe you need __dirname, which is unavailable in ESM:

import { fileURLToPath } from 'url'
import path from 'path'

const __dirname = fileURLToPath(path.dirname(import.meta.url))

Or maybe you want to check if the module was the entry point for the Node process:

import process from 'process'
import { fileURLToPath } from 'url'

if (process.argv[1] === fileURLToPath(import.meta.url)) {
  // This module was the entry point.
}

right now Iā€™m using babel & itā€™s configured to only convert esm modules. So the whole app runs in esm modules but tests run with commonjs module system

you can show me your config please? my apps runs in esm modules and I need run my test with commonjs modules

Your callback inside of jest.unstable_mockModule does not return anything. If the intention is to return an object, you should wrap it in parenthesis:

jest.unstable_mockModule("../services/user.service.js", async () => ({
  createUser: jest.fn(),
}));

So basically I donā€™t see here any other solution except for using babel or not using esm at all? Or did I miss something?

You donā€™t need babel, see my example repo here, you only need jest

@kevinhooke I would expect it to already be loaded by the time I reference it in the following code, and not that it may or may not be loaded and introduce random behavior into my code.

Thatā€™s exactly what happens when you use static import, the dependency is guaranteed to be loaded before your code runs. For exampleā€¦

import foo from "some-module";

// It's guaranteed that "some-module" is loaded and you can use `foo`

But if you use import("some-module") then you are doing dynamic runtime loading, so you need to use await or .then in order to wait for the module to finish loading.

Most JS code should use static import, dynamic import() is a niche feature that is useful in some situations, but it is not the norm. Unit tests are one of those niche situations where import() is useful.

Not sure when it landed but jest.unstable_mockModule and dynamic import works as expected

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

jest.unstable_mockModule('@googleapis/dataflow', () => {
  return {
    dataflow: () => ({
      projects: { locations: { jobs: { list: jest.fn(), }, }, },
    }),
  };
});

// module importing @googleapis/dataflow
const { DataflowHealthCheckService } = await import(
  './dataflow-health-check.service.js'
);

@PeterKDex exactly that is what you should do it is always a good pattern to allow your module dependencies to be inject able.

Hi all, great work on the ESM implementation so far. Only took a couple of hours to get up and running with a project that uses node natively, with no code transforms

Iā€™ve found an issue with jest.unstable_mockModule where the factory function is called once per import of mocked module found in the code.

So if I have two files, both importing the mocked module, theyā€™ll each get a separate instance. And then this can cause issues if importing the mocked module into the test case, itā€™s not guaranteed which of the instances you will get. In my case, the instance imported into my test file is not the one that contains the right data in .mock.calls

So far the workaround is to reorder a few imports in my source code, such that the module making the call to the mocked module is imported last.

The ora-package exports a function that when called with its parameters returns an object that has a succeed and a start function (among others). What you currently return is what the aforementioned function returns instead of the function. It should be something like this:

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

But I donā€™t think issues on how to mock specific libraries is something that should be discussed in this issue tracker.

Are there any updates on mocking ESM without the factory function? For example, vitest supports creating mocks for ESM automatically.

Is there a roadmap which shows when jest will fully support ESM?

Then this may be a ā€œdefaultā€ issue. This is the reason I always prefer named exports šŸ˜

@jasonrberk Fact is, if you use jest, you use babel already: https://jestjs.io/docs/next/code-transformation#defaults.

Depending on your complete stack, it can be not that hard to mock modules. With typescript, ts-jest includes presets that requires just a bit of config for allow it to handle node_modules if required. Then, to work with the hoisting mechanism of babel-jest that is broken with ESM (since static imports are resolved before running the script for what Iā€™ve seen), you just have to use top-level await to import files importing the modules to mock.

Mock before async imports and do not import statically anything that may import some-dep statically

Example plagiating @SimenB above:

You have the following structure:

  • src
    • foo.ts
    • foo.spec.ts
  • node_modules
    • some-dep

In src/foo.ts:

import { depFn } from 'some-dep';

export const doStuff = (...args: any[]) => depFn(...args);

In src/foo.spec.ts:

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

// Order is important.
jest.mock('some-dep', () => ({
  depFn: jest.fn()
});

const { doStuff } = await import('./foo');
const { depFn } = await import('some-dep');

it('should pass args to `depFn`', () => {
  doStuff('a', 'b');
  expect(depFn).toHaveBeenCalledWith('a', 'b');
});

Long story short: you use jest, you use babel if you donā€™t explicitly disable transforms, AFAIK. It does most of the heavy lifting out of the box already.

If anyone is interested in giving jest.mockModule from this PR ā€” https://github.com/facebook/jest/pull/10976 ā€” a try right now, here is a small example using it: https://github.com/yurijmikhalevich/esm-project-with-working-jest-mock.

@akdev5 - Letā€™s take this conversation somewhere else since itā€™s not related to this issue. Maybe open a Stack Overflow issue and include as much detail and code snippets as you can and others might be able to help too!

@mike-weiner what happens if you add type: module in your package.json ?

A few additional tweaks to config/__tests__/config.test.js did the trick!

import {jest} from '@jest/globals'

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

const config = await import('../config').default;

describe('...', () => {
  it('ora is mocked', () => {
    const actual = config

    expect(actual).toBeUndefined() // Yes, I realize this is a terrible and incorrect unit test.
  })
})

@skilbjo - You are a life saver. Donā€™t event want to say how many hours Iā€™ve been wrangling with this.

@IgorBezanovic I just experienced the same issue you had; hereā€™s how I fixed it.

src.ts

import axios from 'axios'

const handler = async () => { 
  const result = await axios.request({ url: 'https://example.com })
  console.log({ result })
}

test.ts

jest.unstable_mockModule('axios', () => ({
  default: { request: jest.fn() },
}));

const src = await import('src');

describe('...', () => {
  it('axios is mocked', () => {
    const actual = await src.handler()

     expect(actual).toBeUndefined() // correct; if unmocked it would have been an AxiosResponse
  })
})

the trick was in test.ts

before:

import * as src from 'src'

jest.unstable_mockModule('axios', () => ({
  default: { request: jest.fn() },
}));

...

after:

// import * as src from 'src' // removed

jest.unstable_mockModule('axios', () => ({
  default: { request: jest.fn() },
}));

const src = await import('src'); // no longer `import * as src from 'src'`; and placed after `unstable_mockModule`

Hello,

My colleague and I have a problem with mocking default helper function.

We tried follow example: import { jest } from ā€˜@jest/globalsā€™;

jest.unstable_mockModule(ā€˜/helpers/exampleFunction.jsā€™, () => ({ __esModule: true, default: jest.fn().mockReturnValue(mockValue) }))

Behavior is not mocked, the function still call original helper function, does not consider the mock data.

Do you have some suggestions? Community, tnx in advance!

So, what I see here is that your default entry is not an object, itā€™s a key pointing directly to the function, can you try doing it like this: default: { nameOfFunc: jest.mockReturnValue(mockValue) } Thatā€™s how I mocked all my modules, because default export is actually an object

Also, for it to work, you need to than import this function with await statement after the mock was done, not using the normal import statement at the top of the file so for instance: const logger = await import(ā€˜src/utils/loggerā€™); And put this line after the mock is registered

@swissspidy I was following this answer by ā€œstoneā€

Regardless using ā€œnewā€ on a function should work

function Bla(){
    return {
        lol: ()=>'ops'
    }
}
new Bla() // > {lol: ʒ}

However the mocking doesnā€™t return the function, it returns: Module {Symbol(Symbol.toStringTag): 'Module'} Thatā€™s the reason it fails, but not sure why itā€™s returning the symbol

Youā€˜re returning a function in your mock instead of a class, so that error makes sense

considering to use vitest, mock esm the same way mocking cjs in jest

Replace

import MyClass from ā€˜./MyClassā€™;

With

const MyClass = await import(ā€œ./MyClassā€);

After! Unstable mockmodule

Lately I have migrated a medium project to ESM. And use ā€˜unstable_mockModuleā€™.

The order in the spec file is:

  • import stuff from jest
  • import other stuff from testing helpers if needed
  • unstable_mockModule
  • await import() sut
  • await import transitive deps for mock control.

For me it very intuitive. No hoisting no black magic. Just works. šŸ‘šŸ˜

@frank-dspeed I agree with your explanation but I disagree with your statement that is it ā€˜logical in generalā€™. Coming from other languages, if I import a dependent module I would expect it to already be loaded by the time I reference it in the following code, and not that it may or may not be loaded and introduce random behavior into my code. Iā€™m nitpicking at how ECMAScript imports work within an event loop based architecture, but ā€˜logical in generalā€™, no, it is not.

Thanks @connorjburton ! The key is to await import your SUT (after jest.unstable_mockModule) and not just import it. const index = await import("./index"); Then you are mocking things that are imported inside your SUT (index in case of example above)

I think the documentation is lacking the this information and it is not really obvious https://jestjs.io/docs/ecmascript-modules

After trying the approach from the docs, and every combination suggested in this post and other posts online for several hours, I can conclude that this is the key to getting this to work.

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

jest.mock('electron', () => ({
  app: {
    on: jest.fn(),
    whenReady: jest.fn(() => Promise.resolve()),
  },
  ipcMain: { on: jest.fn() },
  BrowserWindow: jest.fn().mockImplementation(() => ({
    loadFile: jest.fn(() => Promise.resolve()),
    on: jest.fn(),
  })),
}));

Hereā€™s the import section of my test file. Jest reports SyntaxError: The requested module 'electron' does not provide an export named 'BrowserWindow' if I have ts-jest configured useESM: true and run jest with NODE_OPTIONS=--experimental-vm-modules.

Tried different syntax and workaround but no luck at all. Itā€™s clear to me that something in Jest is broken with ESM support enabled. Frustrated as Iā€™ve converted all other code to native ESM syntax but canā€™t get Jest to work properly with it. Would be very happy to know if thereā€™s any update on this issue.

@neophyt3 if you want to test something fresh check out the new nodejs vm api it allows shiming ESModules

most of the vm.<methods> got now a importModuleDynamically parameter that allows you to set a callback for import()

hope that helps

Background

the vm module in nodejs got build to implement custom userland module systems šŸ˜ƒ

https://nodejs.org/api/vm.html#vmrunincontextcode-contextifiedobject-options

is what your looking for may god be with you

I was unable to get TypeScript + ESM + mocks to work and ended up transpiling to CJS with Babel for tests.

For anyone still trying to have testable code with an ESM codebase (i.e., { "type": "module" } in package.json), maybe my config will work for you too.

Install the dependencies:

npm i -D @babel/core @babel/plugin-transform-runtime @babel/preset-env @babel/preset-typescript babel-jest babel-plugin-transform-import-meta

If you arenā€™t using TypeScript, then you of course donā€™t need to install @babel/preset-typescript.

jest.config.json:

{
  "roots": [
    "<rootDir>/src/"
  ],
  "setupFiles": [
    "<rootDir>/src/environment.ts"
  ],
  "moduleNameMapper": {
    "^(\\.{1,2}/.*)\\.jsx?$": "$1"
  }
}

The setupFiles are specific to my setup. You can probably remove or update that for your own.

moduleNameMapper strips the .js and .jsx extensions from module names, which is necessary because ESM import statements need the file extension, while the require statements produced by Babel do not.

babel.config.json:

{
  "presets": [
    "@babel/preset-env",
    "@babel/preset-typescript"
  ],
  "plugins": [
    "@babel/transform-runtime",
    "babel-plugin-transform-import-meta"
  ]
}

If you arenā€™t using TypeScript, then you of course donā€™t need @babel/preset-typescript.

If youā€™re using ESM, youā€™ll probably use import.meta.url at some point, which is unavailable in CJS code. The two Babel plugins above transform import.meta.url statements into something CJS can use.

For example, maybe you need __dirname, which is unavailable in ESM:

import { fileURLToPath } from 'url'
import path from 'path'

const __dirname = fileURLToPath(path.dirname(import.meta.url))

Or maybe you want to check if the module was the entry point for the Node process:

import process from 'process'
import { fileURLToPath } from 'url'

if (process.argv[1] === fileURLToPath(import.meta.url)) {
  // This module was the entry point.
}

@vialoh I went with your approach to get around the lack of mocking. It looks like it worked out just fine. I will report back if I find any issues. I think this is the simplest approach until there is some kind of official solution from the Jest team.

Yeah! The true C# way. But automocking was soooooo convenient šŸ˜ƒ

out of my view the mock implementation of jest simply needs a diffrent api for esm as it needs to address shared linked modules also!

import('./my-module.mjs').then(moduleToMock => mock(moduleToMock.get))
import('./my-module.cjs').then(moduleToMock => mock(moduleToMock.default))

i think the mock implementation that now trys to find a object via a string mock(ā€˜moduleToMockā€™) is confusing anyway

simply taking a object and returning a proxy for it is enough to implement mocking.

As I mentioned above, it depends on your setup: some other preprocessors, like ts-jest, makes the thing much easier somehow (I just tried to dig in and didnā€™t found a reason. Maybe somebody else here knows how it does it almost like magic).

In your case, in pure ESM, you can replace the beginning of your test file like the following:

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

const mockPlaySoundFile = jest.fn();
jest.unstable_mockModule('./sound-player', () => {
    return {default: jest.fn().mockImplementation(() => {
        return { playSoundFile: mockPlaySoundFile };
    })};
});
const {default: SoundPlayer} = await import('./sound-player');
const {default: SoundPlayerConsumer} = await import('./sound-player-consumer');

beforeEach(() => {
  SoundPlayer.mockClear();
  mockPlaySoundFile.mockClear();
});

Sorry for the confusion, I was confused by ts-jest doing stuff I didnā€™t expect it to do, that made things work in my tests here.

@thernstig probably yes. See my comment above for a potential workaround.