jest
jest copied to clipboard
[Bug]: jest.mock not working with ESM support activated
Version
28.1.3
Steps to reproduce
- Clone my repo: https://github.com/iamWing/jest-esm-mock-issue
npm Inpm run startto verify Electron app shows up without issuenpm tto run jest for the error
Expected behavior
Expecting Jest to mock functions app.on, app.whenReady and component BrowserWindow from electron, then execute the test cases with the mocked functions.
Actual behavior
Encountered TypeError: Cannot read properties of undefined (reading 'whenReady') when trying to run the test cases.
One possible reason I can think of is the imported module is being executed before jest.mock('electron', ...) in main.spec.js, hence Node is executing the line app.whenReady() in main.cjs with the non-mocked version.
Additional context
I originally tested with TypeScript & ts-jest with ESM support activated. The error I encountered there is very similar, so that I believe the error is from jest instead of ts-jest. See my comment on #10025 (https://github.com/facebook/jest/issues/10025#issuecomment-1214297747)
Environment
System:
OS: macOS 12.5
CPU: (16) x64 Intel(R) Core(TM) i9-9880H CPU @ 2.30GHz
Binaries:
Node: 16.16.0 - /usr/local/opt/node@16/bin/node
Yarn: 1.22.19 - /usr/local/bin/yarn
npm: 8.13.2 - /usr/local/bin/npm
npmPackages:
jest: ^28.1.3 => 28.1.3
Thanks for repo. Easy to fix. Change your test file like this:
import { jest } from "@jest/globals";
- import { BrowserWindow } from "electron";
- import { exportedForTests } from "../src/main.cjs";
jest.mock("electron", () => ({
app: {
on: jest.fn(),
whenReady: jest.fn(() => Promise.resolve()),
},
BrowserWindow: jest.fn().mockImplementation(() => ({
loadFile: jest.fn(() => Promise.resolve()),
on: jest.fn(),
})),
}));
+ const { BrowserWindow } = await import("electron");
+ const exportedForTests = await import("../src/main.cjs");
// ...
As you suspected – ESM evaluates import statements before looking at the code. So you have to mock before importing. Same applies for for modules which have to load mocked modules (in this case it is the main.cjs which loads BrowserWindow).
This pattern worked with CJS, because babel-jest transformer is moving jest.mock calls at the top of the file (above any require statement). Hoisting magic does not work with ESM, because any static import will be always evaluated first. Solution: use dynamic import().
Thanks for repo. Easy to fix. Change your test file like this:
import { jest } from "@jest/globals"; - import { BrowserWindow } from "electron"; - import { exportedForTests } from "../src/main.cjs"; jest.mock("electron", () => ({ app: { on: jest.fn(), whenReady: jest.fn(() => Promise.resolve()), }, BrowserWindow: jest.fn().mockImplementation(() => ({ loadFile: jest.fn(() => Promise.resolve()), on: jest.fn(), })), })); + const { BrowserWindow } = await import("electron"); + const exportedForTests = await import("../src/main.cjs"); // ...As you suspected – ESM evaluates
importstatements before looking at the code. So you have tomockbefore importing. Same applies for for modules which have to load mocked modules (in this case it is themain.cjswhich loadsBrowserWindow).This pattern worked with CJS, because
babel-jesttransformer is movingjest.mockcalls at the top of the file (above anyrequirestatement). Hoisting magic does not work with ESM, because any staticimportwill be always evaluated first. Solution: use dynamicimport().
Hi @mrazauskas, thanks for your reply. I've tried using dynamic import but it seems that the BrowserWindow is still not mocked.
The following test resulted in error:
import { jest } from '@jest/globals';
jest.mock('electron', () => ({
app: {
on: jest.fn(),
whenReady: jest.fn(() => Promise.resolve()),
},
BrowserWindow: jest.fn().mockImplementation(() => ({
loadFile: jest.fn(() => Promise.resolve()),
on: jest.fn(),
})),
}));
const { BrowserWindow } = await import('electron');
const exportedForTests = await import('../src/main.cjs');
test('Private props exported for unit tests', () => {
expect(exportedForTests).toBeDefined();
});
test('func createWindow()', () => {
const { createWindow } = exportedForTests;
createWindow();
expect(BrowserWindow).toHaveBeenCalledTimes(1);
});
Error:
● func createWindow()
expect(received).toHaveBeenCalledTimes(expected)
Matcher error: received value must be a mock or spy function
Received has value: undefined
23 |
24 | createWindow();
> 25 | expect(BrowserWindow).toHaveBeenCalledTimes(1);
| ^
26 | });
27 |
at Object.<anonymous> (tests/main.spec.js:25:25)
Also tried using the unstable_mockModule API but it doesn't work on electron, as I think electron is still not an ES module(?).
Ah.. The are different solution, but perhaps it would be the best to require() CJS modules:
+ import { createRequire } from "node:module";
import { jest } from "@jest/globals";
+ const require = createRequire(import.meta.url);
jest.mock("electron", () => ({
app: {
on: jest.fn(),
whenReady: jest.fn(() => Promise.resolve()),
},
BrowserWindow: jest.fn().mockImplementation(() => ({
loadFile: jest.fn(() => Promise.resolve()),
on: jest.fn(),
})),
}));
- const { BrowserWindow } = await import("electron");
+ const { BrowserWindow } = require("electron");
const exportedForTests = await import("../src/main.cjs");
test("Private props exported for unit tests", () => {
expect(exportedForTests).toBeDefined();
});
test("func createWindow()", () => {
const { createWindow } = exportedForTests;
createWindow();
expect(BrowserWindow).toHaveBeenCalledTimes(1);
});
Also works:
const { default: electron } = await import("electron");
// inside `test()`
expect(electron.BrowserWindow).toHaveBeenCalledTimes(1);
Or (least elegant for my eye):
const { BrowserWindow } = (await import("electron")).default;
I agree that it’s probably better to import CJS modules by using required(). Perhaps we should update the ECMAScript Modules to include things we discussed here so there’s less confusion for anyone attempting to enable ESM support. Maybe the mock document as well.
This discussion was helpful, but I can't seem to replicate it in my own ESM project with Node v16.16.0 and jest v28.1.3.
// myModule.js
import { execSync } from 'node:child_process';
export default function myFunc() {
console.log(execSync('ls package.json').toString());
}
// myModule.test.js
import { createRequire } from "node:module";
import { jest } from '@jest/globals';
const require = createRequire(import.meta.url);
jest.mock('node:child_process');
const { execSync } = require('node:child_process');
const myFunc = (await import('../src/myModule.js')).default;
afterEach(() => jest.resetAllMocks());
test('execSync mock should be working', () => {
execSync('echo');
expect(execSync).toHaveBeenCalledWith('echo')
});
test('myFunc should call mocked execSync', () => {
myFunc();
expect(execSync).toHaveBeenCalledWith('ls package.json');
});
My test fails because the module under test still didn't get the mocked version of execSync. I get the same failure if I selectively mock execSync (like mrazauskas does), with jest.mock('node:child_process, () => ({ execSync: jest.fn() })).
$ NODE_OPTIONS=--experimental-vm-modules jest tests/myModule.test.js
(node:486995) ExperimentalWarning: VM Modules is an experimental feature. This feature could change at any time
(Use `node --trace-warnings ...` to show where the warning was created)
console.log
package.json
at myFunc (src/myModule.js:4:11)
FAIL tests/myModule.test.js
✓ execSync mock should be working (4 ms)
✕ myFunc should call execSync (45 ms)
● myFunc should call execSync
expect(jest.fn()).toHaveBeenCalledWith(...expected)
Expected: "ls"
Number of calls: 0
19 | myFunc();
20 |
> 21 | expect(execSync).toHaveBeenCalledWith('ls');
| ^
22 | });
23 |
at Object.<anonymous> (tests/myModule.test.js:21:20)
Test Suites: 1 failed, 1 total
Tests: 1 failed, 1 passed, 2 total
It does seem to work if I use a spy instead, which must be created before importing the module under test:
const cp = require('child_process');
const execSpy = jest.spyOn(cp, 'execSync').mockImplementation(() => {});
const myFunc = (await import('../src/myModule.js')).default;
@iamWing Good idea. I think it would be great to add an example with jest.unstable_mockModule() (see https://github.com/facebook/jest/issues/10025#issuecomment-1016690087) and your case with electron to ECMAScript Modules guide. Perhaps a link from jest.mock() would be enough for now? I mean, ESM in Jest is still considered experimental, so keeping docs in ECMAScript Modules guide makes sense. Or?
@mildmojo
Did you try like this:
- import { createRequire } from "node:module";
import { jest } from "@jest/globals";
- const require = createRequire(import.meta.url);
- jest.mock("node:child_process");
+ jest.unstable_mockModule("node:child_process", () => ({
+ execSync: jest.fn(),
+ }));
- const { execSync } = require("node:child_process");
+ const { execSync } = await import("node:child_process");
const myFunc = (await import("../src/myModule.js")).default;
// ...
@mrazauskas Thank you! That got me moving. I guess unstable_mockModule is undocumented, but needed instead of .mock() for ESM modules? I found the PR where it was added; seems transitional.
I also needed to make sure that if I provided a mock implementation, I manually restored that implementation after jest.resetAllMocks(). My working test code looks like this:
// myModule.test.js
import { createRequire } from "node:module";
import { jest } from '@jest/globals';
const require = createRequire(import.meta.url);
jest.unstable_mockModule('node:child_process', () => ({
execSync: jest.fn(() => 'foobar'),
}));
const { execSync } = await import('node:child_process');
const myFunc = (await import('../src/myModule.js')).default;
afterEach(() => {
jest.resetAllMocks();
execSync.mockImplementation(() => 'foobar');
});
test('execSync mock should be working', () => {
execSync('echo');
expect(execSync).toHaveBeenCalledWith('echo')
});
test('myFunc should call mocked execSync', () => {
myFunc();
expect(execSync).toHaveBeenCalledWith('ls package.json');
});
@iamWing Good idea. I think it would be great to add an example with
jest.unstable_mockModule()(see https://github.com/facebook/jest/issues/10025#issuecomment-1016690087) and your case withelectronto ECMAScript Modules guide. Perhaps a link fromjest.mock()would be enough for now? I mean, ESM in Jest is still considered experimental, so keeping docs in ECMAScript Modules guide makes sense. Or?
@mrazauskas Yeah I think it makes sense to keep the examples in the ECMAScript Modules guide. Docs will need to be changed when the APIs are stabled for ESM support anyway, so no point making extensive amount of documentation at this phase.
Agreed (; Are you up to opening a PR?
Agreed (; Are you up to opening a PR?
@mrazauskas sure. Let's see if I can find some time to make it later this week.
@mrazauskas Thank you! That got me moving. I guess
unstable_mockModuleis undocumented, but needed instead of.mock()for ESM modules? I found the PR where it was added; seems transitional.I also needed to make sure that if I provided a mock implementation, I manually restored that implementation after
jest.resetAllMocks(). My working test code looks like this:// myModule.test.js import { createRequire } from "node:module"; import { jest } from '@jest/globals'; const require = createRequire(import.meta.url); jest.unstable_mockModule('node:child_process', () => ({ execSync: jest.fn(() => 'foobar'), })); const { execSync } = await import('node:child_process'); const myFunc = (await import('../src/myModule.js')).default; afterEach(() => { jest.resetAllMocks(); execSync.mockImplementation(() => 'foobar'); }); test('execSync mock should be working', () => { execSync('echo'); expect(execSync).toHaveBeenCalledWith('echo') }); test('myFunc should call mocked execSync', () => { myFunc(); expect(execSync).toHaveBeenCalledWith('ls package.json'); });
@mildmojo Yes I can confirm unstable_mockModule is needed to mock ESM. jest.mock only works for CJS modules for now.
I don't understand why do you need to resetAllMocks in afterEach tho. I tried it without resetting it and it works fine for me. What went wrong if you don't?
I don't understand why do you need to resetAllMocks in afterEach tho. I tried it without resetting it and it works fine for me. What went wrong if you don't?
I extracted this example from my real test code that modifies the mocks on a per-test level. In this example here, it's not actually necessary. :stuck_out_tongue:
I don't understand why do you need to resetAllMocks in afterEach tho. I tried it without resetting it and it works fine for me. What went wrong if you don't?
I extracted this example from my real test code that modifies the mocks on a per-test level. In this example here, it's not actually necessary. :stuck_out_tongue:
Ah I see. It makes sense then. Just wanna make sure before it goes into the docs. Cheers 🍻
Thanks guys we are now finally able to mock our ES modules properly again 🎉 - please allow me to share our mockESModule() approach;
https://gist.github.com/booya2nd/dcaa1775fd4c06cd79610e3feea6362c#file-mock-esmodule-js
usage:
const mocked = await mockESModule('someDependency', import.meta);
const myModule = (await import('my-module-using-dep.js')).default;
it('...', () => {
mocked.someFunction.mockImplementationOnce(() => 1337);
...
});
Only "downside" now is to switch from static import to dynamic import() 😅
This issue is stale because it has been open 30 days with no activity. Remove stale label or comment or this will be closed in 30 days.
This issue was closed because it has been stalled for 30 days with no activity. Please open a new issue if the issue is still relevant, linking to this one.
This issue has been automatically locked since there has not been any recent activity after it was closed. Please open a new issue for related bugs. Please note this issue tracker is not a help forum. We recommend using StackOverflow or our discord channel for questions.