Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Bug]: jest.mock not working with ESM support activated #13135

Closed
iamWing opened this issue Aug 15, 2022 · 18 comments
Closed

[Bug]: jest.mock not working with ESM support activated #13135

iamWing opened this issue Aug 15, 2022 · 18 comments

Comments

@iamWing
Copy link
Contributor

iamWing commented Aug 15, 2022

Version

28.1.3

Steps to reproduce

  1. Clone my repo: https://github.com/iamWing/jest-esm-mock-issue
  2. npm I
  3. npm run start to verify Electron app shows up without issue
  4. npm t to 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 (#10025 (comment))

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
@mrazauskas
Copy link
Contributor

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().

@iamWing
Copy link
Contributor Author

iamWing commented Aug 15, 2022

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().

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(?).

@mrazauskas
Copy link
Contributor

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;

@iamWing
Copy link
Contributor Author

iamWing commented Aug 18, 2022

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.

@mildmojo
Copy link

mildmojo commented Aug 21, 2022

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;

@mrazauskas
Copy link
Contributor

@iamWing Good idea. I think it would be great to add an example with jest.unstable_mockModule() (see #10025 (comment)) 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?

@mrazauskas
Copy link
Contributor

@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;

// ...

@mildmojo
Copy link

@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
Copy link
Contributor Author

iamWing commented Aug 21, 2022

@iamWing Good idea. I think it would be great to add an example with jest.unstable_mockModule() (see #10025 (comment)) 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?

@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.

@mrazauskas
Copy link
Contributor

Agreed (; Are you up to opening a PR?

@iamWing
Copy link
Contributor Author

iamWing commented Aug 24, 2022

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.

@iamWing
Copy link
Contributor Author

iamWing commented Aug 31, 2022

@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');
});

@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?

@mildmojo
Copy link

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. 😛

@iamWing
Copy link
Contributor Author

iamWing commented Sep 1, 2022

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. 😛

Ah I see. It makes sense then. Just wanna make sure before it goes into the docs. Cheers 🍻

@booya2nd
Copy link

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() 😅

@github-actions
Copy link

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.

@github-actions github-actions bot added the Stale label Oct 13, 2022
@github-actions
Copy link

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.

@github-actions github-actions bot closed this as not planned Won't fix, can't repro, duplicate, stale Nov 12, 2022
@github-actions
Copy link

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.

@github-actions github-actions bot locked as resolved and limited conversation to collaborators Dec 13, 2022
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Projects
None yet
Development

No branches or pull requests

4 participants