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鈥檒l occasionally send you account related emails.

Already on GitHub? Sign in to your account

Mock Single Export #9456

Closed
fongandrew opened this issue Jan 23, 2020 · 8 comments
Closed

Mock Single Export #9456

fongandrew opened this issue Jan 23, 2020 · 8 comments

Comments

@fongandrew
Copy link

馃殌 Feature Proposal

Helper to mock a single export from an ES module. Something like jest.spyOnModule('path/to/module', 'nameOfExport').

Motivation

Suppose we have a module with multiple exports but we want to replace only one of them:

export const x = () => 123;
export const y = () => 456;
export const z = () => 789;

At the moment, if you want to replace just x in 鈽濓笍, you might do something like like this:

jest.mock('path/to/module', () => ({
  ...jest.requireActual('path/to/module');
  x: jest.fn(() => 321),
}));

This isn't ideal for a number of reasons:

  • It's a bit verbose (note the repeated module paths).
  • If you're dealing with Babel and ES modules, you often had to add __esModule: true or some such to bridge the gap between Jest mocking things as CommonJS vs ESM.
  • It's hoisted to the top of module scope. Not a big deal in this example but the hoisting can be confusing, especially if you're trying to analogize using jest.mock a method on the module to jest.spyOn a method on an object.
  • Reasoning about mock modules is tricky. We work in a fairly large repo with more than a few cyclic dependencies, and if you're using multiple requireActuals with jest.mock, it's not easy trying to reason about the order in which modules are executed or whether something is referencing the mocked variant of something or the real one.

Note that you can do this:

import * as myModule from "path/to/module";
jest.spyOn(myModule, 'x').mockImplementation(() => 321);

This (usually) works, but the rub is that the above only works because we're using Babel or something to convert the ES exports to an object-like representation. Per the ES6 spec, we shouldn't be able to change a module's exports from outside the module. If/when Jest moves to using native ES modules in Node, it's unclear whether this would still work.

Example

const spy = jest.spyOnModule('path/to/module', 'nameOfExport');
spy.mockReturnValue(something);

Pitch

Spying on a single export from a module is a pretty common thing to do, especially if you have giant modules full of helper functions and what not. It's also a lot easier to reason about spying on just the one export that thinking about what happens when jest.mock replaces the module altogether.

There are ways to make this work without having it baked into Jest. But the implementations for something like this would depend heavily on the extent that Jest is relying on CommonJS vs ES modules. Having this be part of Jest itself would add some reassurance that these things were being considered if/when Jest decides to make use of Node's native support for ES modules.

@SimenB
Copy link
Member

SimenB commented Jan 24, 2020

Thanks for the detailed request!

It's a bit verbose (note the repeated module paths).

Not too much of a biggie in my mind, but we could possible inject some bound helpers as the argument to the factory function, e.g.

jest.mock('path/to/module', ({requireActual}) => ({
  ...requireActual();
  x: jest.fn(() => 321),
}));

Not sure if it's much of an improvement...

If you're dealing with Babel and ES modules, you often had to add __esModule: true or some such to bridge the gap between Jest mocking things as CommonJS vs ESM.

You shouldn't have to if you use requireActual as it'll be included already. Shouldn't need it either way though, unless you have a default export

It's hoisted to the top of module scope. Not a big deal in this example but the hoisting can be confusing, especially if you're trying to analogize using jest.mock a method on the module to jest.spyOn a method on an object.

You can use jest.doMock which is the same as mock, but it's not hoisted. Note that according to spec, all imports must be recursively resolved before we start executing any code in the module, which Babel achieves by hoisting the transpiled require calls. So if you do not hoist, you cannot mock any modules that use import.

Reasoning about mock modules is tricky. We work in a fairly large repo with more than a few cyclic dependencies, and if you're using multiple requireActuals with jest.mock, it's not easy trying to reason about the order in which modules are executed or whether something is referencing the mocked variant of something or the real one.

Fair enough, not sure we can do much about that - seems intrinsic to module mocking in general. In general one should avoid advanced dependency trees (and especially cycles), but that's way easier said than done 馃檪

Note that you can do this:

import * as myModule from "path/to/module";
jest.spyOn(myModule, 'x').mockImplementation(() => 321);

This (usually) works, but the rub is that the above only works because we're using Babel or something to convert the ES exports to an object-like representation. Per the ES6 spec, we shouldn't be able to change a module's exports from outside the module. If/when Jest moves to using native ES modules in Node, it's unclear whether this would still work.

Example

const spy = jest.spyOnModule('path/to/module', 'nameOfExport');
spy.mockReturnValue(something);

The "rub" you describe in the spyOn example is exactly the same in the second one - you've just created some sugar API for it. We will not be able to evaluate that code before resolving other modules, and which point it's too late to spy on it since other modules already have a reference to the real function.

The general workaround to the "linking first" problem is probably gonna be to use dynamic imports (import()), as that's evaluated together with the rest of the code, and jest.mock and friends will work with it.

Node has a module.syncBuiltinESMExports() method which updates live bindings of mutated core modules, but I'm not sure if it's something we wanna try to replicate over here.

See #9430 for some more thoughts on what native ESM means for Jest.

Pitch

Spying on a single export from a module is a pretty common thing to do, especially if you have giant modules full of helper functions and what not. It's also a lot easier to reason about spying on just the one export that thinking about what happens when jest.mock replaces the module altogether.

There are ways to make this work without having it baked into Jest. But the implementations for something like this would depend heavily on the extent that Jest is relying on CommonJS vs ES modules. Having this be part of Jest itself would add some reassurance that these things were being considered if/when Jest decides to make use of Node's native support for ES modules.

I agree with the pitch in principle, but I'm sceptical of adding APIs we know will be hard or impossible to replicate faithfully in an ESM environment. And the proposed API suffers from the same issue jest.mock does, it's just a bit less code for the end user to write.

@fongandrew
Copy link
Author

The "rub" you describe in the spyOn example is exactly the same in the second one - you've just created some sugar API for it.

In terms of implementation, we could try transforming the modules to essentially expose internal setter functions -- e.g. if there's a export const x, we could rewrite that to export let x; export const __somePrivateSetter = (v) => { x = v; }. That would make for valid ES modules and allow for per-module instance mocking.

That said, and I think I know the answer here, would that approach take it into transformer / Babel territory, or would this still fall into Jest's domain?

@jeysal
Copy link
Contributor

jeysal commented Jan 25, 2020

Thanks for the detailed writeup @fongandrew and your response @SimenB, I agree with everything in there 馃槃

That said, and I think I know the answer here, would that approach take it into transformer / Babel territory, or would this still fall into Jest's domain?

I would say it probably falls into both - Jest already applies babel-plugin-jest-hoist to files to enable the current jest.mock functionality, so Jest using Babel to implement features is absolutely an option.

@SimenB
Copy link
Member

SimenB commented Jan 27, 2020

Yeah, @rickhanlonii suggested rewriting import 'thing' to import('thing') seamlessly. Coupled with top level await that could solve our issues without falling back to CJS.

Not directly related to the feature request here, but using babel to implement features is certainly a possibility

@eric-horodyski
Copy link

I'm a pretty novice-level user of Jest, but I would find this extremely beneficial. @fongandrew nailed it on the head, a feature like his pitch would be super helpful when you're looking to mock just a single export of a module.

@SimenB - I'd expect it to look as you suggested:

Not too much of a biggie in my mind, but we could possible inject some bound helpers as the argument to the factory function, e.g.

jest.mock('path/to/module', ({requireActual}) => ({
  ...requireActual();
  x: jest.fn(() => 321),
}));

The current process isn't bad at all, but:

  1. Is difficult to figure out, and;
  2. Feels like there is unnecessary overhead creating an automock then cloning the original object back into it.

I'd think it would be more efficient to just overwrite the desired mock properties, but I'm not going to claim I know anything about the internals of Jest 馃槃

@github-actions
Copy link

This issue is stale because it has been open for 1 year with no activity. Remove stale label or comment or this will be closed in 14 days.

@github-actions github-actions bot added the Stale label Feb 25, 2022
@github-actions
Copy link

This issue was closed because it has been stalled for 7 days with no activity. Please open a new issue if the issue is still relevant, linking to this one.

@github-actions
Copy link

github-actions bot commented May 4, 2022

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 May 4, 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