Skip to content

mock.module: consolidate defaultExport + namedExports into exports #58443

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

Open
JakobJingleheimer opened this issue May 24, 2025 · 7 comments
Open
Assignees
Labels
test_runner Issues and PRs related to the test runner subsystem.

Comments

@JakobJingleheimer
Copy link
Member

Currently, mock.module's options expects defaultExport and namedExports separately. We are not aware of a reason to separate them, and it's inconsistent with other major testing frameworks/utilities (such as Jest).

function mock__default() {…}
function mock__foo() {…}

mock.module('example.mjs', {
-  defaultExport: mock__default,
-  namedExports: {
+  exports: {
+    default: mock__default,
     foo: mock__foo,
   },
});

The plan would be:

  1. Immediately introduce options.exports
    • alias defaultExport & namedExports into options.exports
  2. Immediately flag defaultExport & namedExports deprecated
  3. Provide a userland-migration to automatically transform old to new
  4. In node 25.x remove defaultExport & namedExports
@JakobJingleheimer JakobJingleheimer added the test_runner Issues and PRs related to the test runner subsystem. label May 24, 2025
@JakobJingleheimer JakobJingleheimer changed the title mocks: consolidate defaultExport + namedExports into exports mock.module: consolidate defaultExport + namedExports into exports May 24, 2025
@JakobJingleheimer JakobJingleheimer self-assigned this May 24, 2025
@cjihrig
Copy link
Contributor

cjihrig commented May 24, 2025

We are not aware of a reason to separate them

CommonJS?

EDIT: Actually, I guess CommonJS can be handled by this change.

@cjihrig
Copy link
Contributor

cjihrig commented May 24, 2025

After thinking about this more this seems like it might be a more confusing user experience (I might be misunderstanding how the proposed changes will work). If I provide the example mock exports:

exports: {
  default: mock__default,
  foo: mock__foo,
},

Don't I now need to know more about the module that I'm mocking - specifically if it is CJS or ESM? If I'm mocking ESM, then I get a default export and a named export. However, if I'm mocking CJS, then I get two named exports, with one of them being named default. With the current API, I would get the same configuration for both module systems without needing to know the module type.

it's inconsistent with other major testing frameworks/utilities (such as Jest).

Another note here is that quibble, for example, does separate the named and default exports. Jest is also not known for its great support for both module systems.

@JakobJingleheimer
Copy link
Member Author

JakobJingleheimer commented May 27, 2025

With the current API, I would get the same configuration for both module systems without needing to know the module type.

Ahh, interesting. Buut I don't see how this could be possible:

{
  defaultExport: mock__default,
  namedExports: { foo: mock__foo },
}

In CJS, wouldn't mock__default get assigned to module.exports (module.exports = mock__default), and then foo would get added onto mock__default (Object.assign(module.exports, { foo: mock__default })). Then you effectively end up with a function with an instance property as the export of the CJS module? (I think it could not happen in reverse because then one would squash the other)

import someCjs from 'some-cjs';
// 💥 import { foo } from 'some-cjs';

typeof someCjs; // 'function'
typeof someCjs.foo; // 'function'

(I'm not sure what happens via require here: const { foo } = require('some-cjs'); I think it probably works as expected?)

And if the mocked module was ESM:

import someEsm, { foo } from 'some-esm';

typeof someEsm; // 'function'
typeof foo; // 'function'
typeof someEsm.foo; // 'undefined'

@cjihrig
Copy link
Contributor

cjihrig commented May 27, 2025

Good point. Yes, that's how it works. I don't think you can really avoid it in CJS. We could do the same thing in ESM under the hood, but I don't think that would be a good idea. I still think the current behavior is significantly less confusing than my example in #58443 (comment).

Currently, when the mock is created, foo is specified as a named export. If I wanted to access a named export, I would use the correct syntax to do that. If I planned to access it as someEsm.foo, I would have created the default export properly.

This has also been the behavior since the API was introduced. We shouldn't break existing users just because an API is experimental unless there is a good reason to do so - we saw how bothered people got with the loader hooks changes.

I also don't think creating a automated migration and changing the API in v25 would be adequate because there wouldn't be a consistent API across all of the supported versions. I think you would need to wait until there was a single usable API on all supported release lines before removing things.

I guess I don't see any tangible upside to this proposed change, but I do see disadvantages.

@JakobJingleheimer
Copy link
Member Author

Actually, I was wrong: it can be done to work the same:

// foo.cjs
module.exports = function foo() {}
module.exports.a = 'a';
// does not work via `Object.assign(module.exports)`
// main.mjs
import foo, { a } from './foo.cjs';
console.log({ foo, a });
{
  foo: [Function: foo] { a: 'a' },
  a: 'a',
}

I believe this can be applied to both options shapes (defaultExport + namedExports and exports) if it isn't already applied to the current.

This has also been the behavior since the API was introduced. We shouldn't break existing users just because an API is experimental unless there is a good reason to do so

One impetus is facilitating adoption of node's test runner. @nodejs/userland-migrations is working on a migration from Jest. This would help with that. Jest is by far the biggest test runner and test utilities lib (Jest's 29M vs Quibble's 135K / Testdouble's 127K).

Another is ergonomics (I think this is an ergonomic improvement—no shade to whoever chose the current). If we provide an automated migration, the burden to users seems trivia; for me as a user, I would happily run a quick script once to improve my DX going forward (I think we should include info about the migration in the deprecation message). I would also be okay leaving the separated ones for longer.

I wish we had a way to track usage so we know can judge user-preference (but that's another can of worms). BUT we can know how many times the migration is used, which could give us an idea.

we saw how bothered people got with the loader hooks changes.

Touché 😅 This is significantly smaller/less complex and (IMHO) far less controversial, especially if we provide a migration. We can also do a better job this time advertising the incoming change.

@cjihrig
Copy link
Contributor

cjihrig commented May 28, 2025

If we can make both APIs work the same, I don't care too much about the actual format of the options object. I do care about breaking users though. I think an alternative plan could look like this:

  1. Immediately introduce options.exports
    • alias defaultExport & namedExports into options.exports
    • Copy the existing test file to use the API from this issue to make sure we can cover all of the same cases.
  2. Optionally, provide a userland-migration to automatically transform things. Since this is a userland thing, I'm not too concerned about this.
  3. Wait until there is a consistent API on all supported release lines to consider removing anything.

As a side note, I think always trying to do whatever Jest does in general should be a non-goal.

@JakobJingleheimer
Copy link
Member Author

That sounds good to me!

I think always trying to do whatever Jest does in general should be a non-goal.

I don't either 😉 In this case, I think they got it right though.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
test_runner Issues and PRs related to the test runner subsystem.
Projects
Status: Todo
Development

No branches or pull requests

2 participants