Open aldeed opened 4 years ago
Copied from https://github.com/facebook/jest/issues/9430#issuecomment-625418195 at the request of @SimenB. Thanks!
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).
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?
@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 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?
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 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:
jest.mock
(jest.mockModule
?) with ESM now? (probably using 27.0.0-next.x
?) And if so - can you provide a short working example?jest.mockModule
or ...?)Thanks in advance.
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 π
As a status update, I've opened up a PR here: #10976
@SimenB before your PR gets merged, what is the work-around solution here?
@anshulsahni no workaround, apart from rewriting your code to not use mocking for the moment with the esm modules
yeah, there is not workaround if you wanna use native ESM until that lands
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
yep, that'll keep working
I'm looking forward for this feature π
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
@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.
marked
There IS (sometimes) a workaround, but it requires you to go about things in a very specific way:
// 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!).
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 probably yes. See my comment above for a potential workaround.
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 is that going to be merged anytime soon?
@yurijmikhalevich do you have any update, please?
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:
@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
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 - π€―
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
@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
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.
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.
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.
@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.
I was unable to get TypeScript + ESM + mocks to work and ended up transpiling to CJS with Babel for tests.
{ "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.
}
Following some advice I saw somewhere (hard to know... I've had so many tabs open today chasing this) I installed @babel/plugin-transform-modules-commonjs
to turn ESM into CommonJS in the test environment, but it doesn't work - it dies with SyntaxError: The requested module 'whatever.js' does not provide an export named 'someFunction'
. Someone's filed a bug for that as #12120. I'm considering my options at this point.
@scottdotjs Have you tried the setup I described above?
@vialoh None of that was relevant to my situation. Also this is incorrect:
moduleNameMapper
strips the.js
and.jsx
extensions from module names, which is necessary...
Jest is able to see the modules without mapping the file names, as my comment above would suggest.
@scottdotjs What is different about your setup?
Also, regarding the moduleNameMapper
, does your code import someModule from './someModule.js'
(with the .js
extension) or does it import someModule from './someModule
(without the extension)? If it's the latter then it isn't actually following the ES modules spec.
@vialoh sorry your wrong "string" specifier are part of the spec they get resolved via the importMap proposal in userland ESM Code and else its up to the Environment to resolve string module Specifiers
they are also implemented in NodeJS via packagejson import fild replacing partial importMaps
@frank-dspeed Can you show me how to do what you're talking about in Node without needing to map every local import? Is there something that can natively automatically add .js
(and possibly other extensions) to extensionless local imports? I was unable to find a way to do this natively but maybe I wasn't looking hard enough.
To quickly try what I'm talking about:
mkdir esm-import-example
cd esm-import-example
npm init
Add "type": "module"
to package.json
.
Create foo.js
:
export const foo = 'bar'
Create index.js
:
import { foo } from './foo'
console.log(foo)
Run node index.js
and you'll be met with the following error:
$ node index.js
node:internal/process/esm_loader:94
internalBinding('errors').triggerUncaughtException(
^
Error [ERR_MODULE_NOT_FOUND]: Cannot find module 'esm-import-example/foo' imported from esm-import-example/index.js
Did you mean to import ../foo.js?
β[90m at new NodeError (node:internal/errors:371:5)β[39m
β[90m at finalizeResolution (node:internal/modules/esm/resolve:416:11)β[39m
β[90m at moduleResolve (node:internal/modules/esm/resolve:932:10)β[39m
β[90m at defaultResolve (node:internal/modules/esm/resolve:1044:11)β[39m
β[90m at ESMLoader.resolve (node:internal/modules/esm/loader:422:30)β[39m
β[90m at ESMLoader.getModuleJob (node:internal/modules/esm/loader:222:40)β[39m
β[90m at ModuleWrap.<anonymous> (node:internal/modules/esm/module_job:76:40)β[39m
β[90m at link (node:internal/modules/esm/module_job:75:36)β[39m {
code: β[32m'ERR_MODULE_NOT_FOUND'β[39m
}
@vialoh sure here you go from https://nodejs.org/api/packages.html#subpath-patterns
// ./node_modules/es-module-package/package.json
{
"exports": {
"./features/*": "./src/features/*.js"
},
"imports": {
"#internal/*": "./src/internal/*.js"
}
}
All instances of * on the right hand side will then be replaced with this value, including if it contains any / separators.
import featureX from 'es-module-package/features/x';
// Loads ./node_modules/es-module-package/src/features/x.js
import featureY from 'es-module-package/features/y/y';
// Loads ./node_modules/es-module-package/src/features/y/y.js
import internalZ from '#internal/z';
// Loads ./node_modules/es-module-package/src/internal/z.js
@frank-dspeed Interesting, thanks!
I was able to get your suggestion to work with the following...
src/foo.js:
export const foo = 'bar'
src/index.js:
import { foo } from '#foo'
console.log(foo)
package.json:
{
"name": "esm-import-example",
"version": "1.0.0",
"description": "",
"main": "index.js",
"type": "module",
"scripts": {
"start": "node src/index.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
"imports": {
"#*": "./src/*.js"
},
"author": "",
"license": "MIT"
}
I'm not sure how I feel about it though. It feels sort of wrong, maybe from too many years of using relative imports. Although this may actually prove to be better for modularity in the long term.
I am still curious though. Is it possible to import using a relative path (e.g., import ./foo
) instead of needing the path prefixed with #
?
For example...
src/index.js:
import { foo } from './foo'
console.log(foo)
package.json:
{
"imports": {
"./*": "./*.js"
}
}
the prefix # was fully optional i showed both the # is a internal convention to easy parse and find such imports without much trouble
and about your other question It is fully environment dependent the NPM way works only for NodeJS as it is from the same People. The Cross Platform way is ImportMaps you can use them in many environments today
and the offical shim PoillyPony Fill for that is systemjs a cross platform module system that offers everything that ESM Specs try to enforce on environments.
for example the nodejs modules will now get referenced like that in future
import { writeFile } from 'node:fs/promises'
@frank-dspeed I used SystemJS a while back, but I'm trying to avoid relying on any fills or transpilation.
If I have src/foo.js
and src/index.js
and want to import { foo } from './foo'
(exactly as written here) from within src/index.js
, what would the package.json
configuration be?
Is a fix for this still on the roadmap?
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...
}
Yeah! The true C# way. But automocking was soooooo convenient π
@PeterKDex exactly that is what you should do it is always a good pattern to allow your module dependencies to be inject able.
Has this been fixed in v28.0.0?
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" }
inpackage.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 ESMimport
statements need the file extension, while therequire
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 transformimport.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.
π Bug Report
In an ES module Node project, with no Babel,
jest.mock
works when the mocked module is anode_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:
Expected behavior
jest.mock(filename)
should mock the exports fromfilename
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