jest icon indicating copy to clipboard operation
jest copied to clipboard

jest.mock does not mock an ES module without Babel

Open aldeed opened this issue 5 years ago β€’ 115 comments

πŸ› 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 

aldeed avatar May 11 '20 22:05 aldeed

Copied from https://github.com/facebook/jest/issues/9430#issuecomment-625418195 at the request of @SimenB. Thanks!

aldeed avatar May 11 '20 22:05 aldeed

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

aldeed avatar May 22 '20 15:05 aldeed

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?

SimenB avatar May 30 '20 21:05 SimenB

@aldeed @SimenB Hello, I'm also having the same problem, but when I try to use jest with babel instead, I'm running into SyntaxError: Cannot use import statement outside a module (it throws inside an imported module), which is basically what #9430 is all about I guess.

Is there any workaround to mock modules from the same project? Or to prevent the SyntaxError from occurring when using babel .

guilhermetelles avatar Jul 15 '20 20:07 guilhermetelles

@guilhermetelles It can be a pain to do, but you'll likely get more help if you create a public minimal reproduction repo for your issue and create a new GH issue that references this one. There are about a dozen things that could cause this issue, from using an older Node version to Babel config issues, and being able to see all the project files is the best way for someone to help you solve it.

@SimenB You mentioned above that you had a start on this and it looks like everyone πŸ‘ your proposal. Is there any update?

aldeed avatar Jul 16 '20 00:07 aldeed

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 πŸ‘

SimenB avatar Oct 26 '20 20:10 SimenB

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

stellarator avatar Dec 08 '20 09:12 stellarator

I want to add jest.mockModule, but since that's a new API and not a breaking change it might not go into Jest 27 at release. A PR would be very much welcome, but it's getting into some of the nitty gritty of jest-runtime so I understand if people are a bit hesitant to attempt it πŸ™‚

SimenB avatar Dec 08 '20 10:12 SimenB

As a status update, I've opened up a PR here: #10976

SimenB avatar Dec 23 '20 15:12 SimenB

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

anshulsahni avatar Jan 26 '21 20:01 anshulsahni

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

kalinchernev avatar Feb 11 '21 15:02 kalinchernev

yeah, there is not workaround if you wanna use native ESM until that lands

SimenB avatar Feb 11 '21 16:02 SimenB

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

anshulsahni avatar Feb 11 '21 16:02 anshulsahni

yep, that'll keep working

SimenB avatar Feb 11 '21 16:02 SimenB

I'm looking forward for this feature πŸ‘

mahnunchik avatar Mar 05 '21 11:03 mahnunchik

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

victorgmp avatar Mar 10 '21 15:03 victorgmp

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

one quasi work around is to use rushstack's heft tool to compile your ts. They allow a secondary emit target so you can emit cjs and esm but with only one compiler pass. pretty slick way to handle it until this is supported if you ask me.

https://rushstack.io/pages/heft_configs/typescript_json/

scamden avatar Mar 23 '21 00:03 scamden

marked

AlvinLaiPro avatar Mar 30 '21 06:03 AlvinLaiPro

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

JakobJingleheimer avatar Mar 31 '21 20:03 JakobJingleheimer

I have mocked node-fetch and import it to a test file with import * as fetch from 'node-fetch'; - but the functions I have set in the mock such as fetch.setMockJsonResponse cannot be found in the test file. Is that the same issue as this?

thernstig avatar May 06 '21 15:05 thernstig

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

JakobJingleheimer avatar May 08 '21 20:05 JakobJingleheimer

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.

yurijmikhalevich avatar May 16 '21 14:05 yurijmikhalevich

@yurijmikhalevich is that going to be merged anytime soon?

damianobarbati avatar Jun 18 '21 11:06 damianobarbati

@yurijmikhalevich do you have any update, please?

emilioSp avatar Jun 22 '21 08:06 emilioSp

https://github.com/facebook/jest/issues/9430#issuecomment-915109139

SimenB avatar Sep 08 '21 11:09 SimenB

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

jasonrberk avatar Sep 15 '21 15:09 jasonrberk

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

GerkinDev avatar Sep 15 '21 21:09 GerkinDev

@GerkinDev - 🀯

first off, thanks for the info.....

so if follow, you guys are working to fix the babel that jest is using under the covers to support using ESM in our own code, instead of just stuff imported from node_modules?

In the meantime, I could

A) do some babel stuff myself (which would happen in place of the transform Jest is doing) and convert all my js files to CommonJS via custom babel?

B) just use CommonJS for now in my source and switch it all to ESM later, once the guys with the big brain fix the issue(s)

thanks for helping me out.....been a long time since I did anything in node / js

jasonrberk avatar Sep 15 '21 22:09 jasonrberk

@GerkinDev

I still can't get the provided complete example working

https://github.com/jasonrberk/jest-mocking-esm

and I still don't get if I'm responsible for doing something with Babel, or if that's all buried behind jest????

what am I doing wrong here: https://github.com/jasonrberk/jest-mocking-esm

jasonrberk avatar Sep 17 '21 16:09 jasonrberk

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.

GerkinDev avatar Sep 17 '21 20:09 GerkinDev