jest icon indicating copy to clipboard operation
jest copied to clipboard

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

Open iamWing opened this issue 3 years ago • 15 comments

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

iamWing avatar Aug 15 '22 00:08 iamWing

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

mrazauskas avatar Aug 15 '22 06:08 mrazauskas

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

iamWing avatar Aug 15 '22 13:08 iamWing

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;

mrazauskas avatar Aug 16 '22 07:08 mrazauskas

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.

iamWing avatar Aug 18 '22 22:08 iamWing

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;

mildmojo avatar Aug 21 '22 01:08 mildmojo

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

mrazauskas avatar Aug 21 '22 04:08 mrazauskas

@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 avatar Aug 21 '22 04:08 mrazauskas

@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 avatar Aug 21 '22 09:08 mildmojo

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

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

iamWing avatar Aug 21 '22 20:08 iamWing

Agreed (; Are you up to opening a PR?

mrazauskas avatar Aug 23 '22 07:08 mrazauskas

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 avatar Aug 24 '22 04:08 iamWing

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

iamWing avatar Aug 31 '22 16:08 iamWing

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:

mildmojo avatar Aug 31 '22 19:08 mildmojo

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 🍻

iamWing avatar Sep 01 '22 10:09 iamWing

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

booya2nd avatar Sep 13 '22 16:09 booya2nd

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[bot] avatar Oct 13 '22 17:10 github-actions[bot]

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[bot] avatar Nov 12 '22 17:11 github-actions[bot]

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[bot] avatar Dec 13 '22 00:12 github-actions[bot]