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’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Errors with .stub() and .spy() caused by getters/setters #1762

Closed
lavelle opened this issue Apr 5, 2018 · 32 comments
Closed

Errors with .stub() and .spy() caused by getters/setters #1762

lavelle opened this issue Apr 5, 2018 · 32 comments
Labels
Property accessors Property Getters/Setters

Comments

@lavelle
Copy link

lavelle commented Apr 5, 2018

What did you expect to happen?

At @thumbtack we are in the process of upgrading our build to Webpack 4. As part of this some of our unit tests started failing. We tracked it down to the cases where we are using Sinon's .spy and .stub functionality on modules that are exported using a non-default ES6 export of the form export function foo. Digging in, it looks like under the hood Webpack creates getters and setters for these exports for its new implementation of Harmony modules. This changed from version 3 to version 4.

We were also able to reproduce this independent of Webpack by attempting to stub a plain object that has a getter or setter.

What actually happens

When using .stub, the stubbing works, but a later call to .restore does not, and the assertion fails.

When using .spy, the following error is thrown: TypeError: Attempted to wrap undefined property foo as function. For some reason Sinon thinks a property is undefined when it also exists as a getter.

How to reproduce

I created a repo that has a minimal reproduction of both the stub and spy issues, with the latest versions of Webpack and Sinon. It also has a base case that shows that this issue does not occur in objects that are not imported this way and therefore don't use setters.

You can clone the repo here: https://github.com/lavelle/sinon-stub-error

Run yarn install and then run

  • yarn pass to see the base case
  • yarn fail to see the failing stub case
  • yarn spy to see the failing spy case

Thanks in advance! We're happy to submit a PR to address this if you can point us in the right direction.

cc @bawjensen @dcapo

@lavelle
Copy link
Author

lavelle commented Apr 5, 2018

Looks like this could be related to #1741, but that doesn't specify that there is an error thrown. We also ideally would not need to use special Sinon methods for mocking Getters and could use the existing .spy and .stub methods. I imagine this issue will become more prevalent as more projects upgrade to use Webpack 4.

@lavelle
Copy link
Author

lavelle commented Apr 5, 2018

Here's an example of what Webpack compiles code to under the hood.

An example ES6 file with an API like

export function get(url, data, options) {
    
}

export function post(url, data, options) {
    
}

export function getJSON(url, data, options) {

}

Produces this code in Webpack 3

"use strict";
Object.defineProperty(__webpack_exports__, "__esModule", { value: true });
/* harmony export (immutable) */ __webpack_exports__["get"] = get;
/* harmony export (immutable) */ __webpack_exports__["post"] = post;
/* harmony export (immutable) */ __webpack_exports__["getJSON"] = getJSON;
/* harmony import */ var __WEBPACK_IMPORTED_MODULE_0_jquery__ = __webpack_require__(13);
/* harmony import */ var __WEBPACK_IMPORTED_MODULE_0_jquery___default = __webpack_require__.n(__WEBPACK_IMPORTED_MODULE_0_jquery__);
/* harmony import */ var __WEBPACK_IMPORTED_MODULE_1_lodash__ = __webpack_require__(1);
/* harmony import */ var __WEBPACK_IMPORTED_MODULE_1_lodash___default = __webpack_require__.n(__WEBPACK_IMPORTED_MODULE_1_lodash__);
/* harmony import */ var __WEBPACK_IMPORTED_MODULE_2__globals_scripts_csrf_es6__ = __webpack_require__(40);
function _defineProperty(obj, key, value) { if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }

And this code in Webpack 4:

// define getter function for harmony exports
__webpack_require__.d = function(exports, name, getter) {
	if(!__webpack_require__.o(exports, name)) {
		Object.defineProperty(exports, name, {
			configurable: false,
			enumerable: true,
			get: getter
		});
	}
};

// later

"use strict";
__webpack_require__.r(__webpack_exports__);
/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "get", function() { return get; });
/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "post", function() { return post; });
/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "getJSON", function() { return getJSON; });
/* harmony import */ var jquery__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! jquery */ "jquery");
/* harmony import */ var jquery__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(jquery__WEBPACK_IMPORTED_MODULE_0__);
/* harmony import */ var lodash__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! lodash */ "./node_modules/lodash/lodash.js");
/* harmony import */ var lodash__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(lodash__WEBPACK_IMPORTED_MODULE_1__);
/* harmony import */ var _globals_scripts_csrf_es6__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! ../../../globals/scripts/csrf.es6 */ "./globals/scripts/csrf.es6.js");
function _defineProperty(obj, key, value) { if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }

As you can see the module implementation now uses Object.defineProperty to dynamically create getter functions for exported functions in this way. I'm not sure why this changed, but it's presumably to support some new features of the module system. I don't think this is a bug in Webpack, because the code runs fine in all browsers and in Node. It only runs into an issue when using it with Sinon.

@lavelle
Copy link
Author

lavelle commented Apr 6, 2018

I found the section in the docs saying

Stub API
If you need to stub getters/setters or non-function properties, then you should be using sandbox.stub

However I switched to sandbox.stub and got the same error.

The only thing that fixes it for me is to replace

import * as obj from './index';

with

import { foo } from './index';
const obj = { foo };

since that prevents Webpack from creating getters in the compiled code.

I'd rather not do that throughout our codebase though, import * should be a valid way to import things. I realise that this is partly a Webpack problem, but giving how widely used it is in the community, it would be nice to fix it in Sinon so the two tools are compatible.

@mroderick
Copy link
Member

mroderick commented Apr 6, 2018

We also ideally would not need to use special Sinon methods for mocking Getters and could use the existing .spy and .stub methods. I imagine this issue will become more prevalent as more projects upgrade to use Webpack 4.

So would we, but that problem has not yet been solved.

Stubbing getters/setters was added in sinon@2 from #1297

@mroderick
Copy link
Member

The next version of Sinon npm i sinon@next --save-dev uses a default sandbox on sinon, which means you won't explicitly have to use sandbox.

However, it still uses stub.get() and stub.set() for getters/setters.

Ping @lucasfcosta: do you have any ideas for this issue?

@Izhaki
Copy link

Izhaki commented May 15, 2018

As @lavelle mentioned. esm import in Webpack 4 use immutable bindings with the following config:

Object.defineProperty(exports, name, {
    configurable: false,
    enumerable: true,
    get: getter
});

Having the same issue, I've written a tiny webpack plugin to override configurable: false to configurable: true, so we can use sinon to stub like so:

    Object.defineProperty(logger, 'createLogger', {
      writable: true,
      value: sinon.stub().returns(loggerStub)
    });

In the same issue (see last section) I've argued that Webpack should consider having configurable: true rather than configurable: false (so there's no need for the plugin). Dunno if they'll take it or not.

But at any rate It may be wise to consider Sinon.Stub(object, "method") calling Object.getOwnPropertyDescriptor and if it has the hallmark of an esm import, plug the stub in (so we don't have to write the code above).

@mroderick
Copy link
Member

@Izhaki I am not sure I understand what you're proposing as a solution.

Would you mind creating a runnable example that demonstrates your idea, in a way that isn't specific to any module loaders (Webpack, Rollup), but is just pure JS and can be used directly in ESM supporting runtimes, like in evergreen browsers?

@Izhaki
Copy link

Izhaki commented May 16, 2018

@mroderick

OK. Let's do this step by step.

ESM exports use immutable binding.

So when you write:

export const x = value;

The actual emitted code will end up calling:

Object.defineProperty(exports, name, {
    configurable: ?, // whether this is true or false depends on the bundler at the moment.
    enumerable: true,
    get: getter
});

where name is x and getter is a function that returns value.

When you write:

import { x } from './X';

What's imported is the getter for x, not the value. Also, because there is no set in the property descriptor, this import is read-only (immutable).

That's why Sinon.Stub(object, "method") doesn't work (should throw TypeError in strict mode).

However, so long the bundler sets configurable: true, the export is still read only, but this can be overridden with something like this:

    Object.defineProperty(logger, 'createLogger', {
      writable: true,
      value: sinon.stub().returns(loggerStub)
    });

Which is what sinon needs to do in order to support stubbing es6 imports.


I'm not 100% sure what this issue is about exactly - the comments seem to have diverged from the original issue. But basically, if this ticket reports the inability to stub es6 imports, than this is the solution.

@Izhaki
Copy link

Izhaki commented May 16, 2018

Here's a more concrete example of how we enabled stubbing es6 imports:

const isEs6Import = (object, property) => {
    const descriptor = Object.getOwnPropertyDescriptor(object, property) || {};
    // An es6 import will have get && enumerable.
    // Non-es6 imports should have writable && value.
    return descriptor.get && descriptor.enumerable;
  };

  // Takes a property that is not writable (such as an es6 import) and makes
  // it writable.
  // Should throw in strict mode if the property doesn't have configurable: true.
  const makePropertyWritable = (object, property) => Object.defineProperty(
    object,
    property,
    {
      writable: true,
      value: object[property]
    }
  );

  /** Create a new sandbox before each test **/
  helper.sinon = sinon.sandbox.create();

  const sinonStub = helper.sinon.stub.bind(helper.sinon);
  helper.sinon.stub = function (object, property) {
    if (object && isEs6Import(object, property)) {
      // Es6 imports are read-only by default.
      // So make them writable so we can mock them.
      makePropertyWritable(object, property);
    }
    return sinonStub(object, property);
  };

@fatso83
Copy link
Contributor

fatso83 commented May 17, 2018

You are right this issue is all over the place. All the getter/setter/sandbox stuff detracted from the main point. There are confusion at many stages, but I think you summed it up, @lzhaki.

  1. This is not a bug in Sinon.
  2. The docs on how getters/setters are created haven't been read, so complaints about them working is more about wrong usage (also: it would fail if configurable was false, as mentioned)
  3. It is superficially a feature request to make it easier to test the new transpiled code from Webpack. Adjusting Sinon to tooling formats and quirks is not so interesting from my perspective, and something that normally should be handled at the build stage (outside if Sinon), like what has been done using your plugin to make accessors configurable.

I do sympathize with the idea of making Sinon more "auto-nice", but I am not sure the proposed fix is sufficient or error proof. What constitutes "the hallmarks of a ESM module"? How can we reliably detect we are dealing with transpiled ESM, not some general object that happens to have a getter? We already have explicit support for overriding accessors, so that won't fly.

We could add additional methods of course, called sinon.stubImport or something, but it's a method of very limited use and timespan.

Mind you, this will only work/make sense in a transpile-to-ES5 world, as ES modules are truly immutable. We explicitly detect that and say we can't support that, hence https://github.com/sinonjs/sinon/blob/master/test/es2015/module-support-assessment-test.mjs.

Once people are over on native ESM and HTT2 loading making bundling obsolete all of these hacks goes out the window.

I think simply adding spying capabilities by default to our existing accessor stubbing is a better solution that would also be more generally usable. See #1741 for discussion on that.


I am on vacation without a computer, so not in a position to test out code, but I guess the steps needed for the original poster to achieve his intended goal is simply this:

  1. Use the mentioned Webpack plugin to make the transpiled exports configurable
  2. Stub the export like this:
    sinon.stub(myModule, 'foo').get( ()=>42 )

As discussed in #1741 the passed in stub currently only provides behaviour, not spying. Until someone (@RoystonS?) expands on the API you'll need to pass in a spied function as the getter to verify interactions. Better docs would be nice, agreed ...

As those points should answer the original issue I regard this as having to do with meagre documentation, not a bug. Feel free to provide improvements :-)

Links:

@fatso83 fatso83 closed this as completed May 17, 2018
@Izhaki
Copy link

Izhaki commented May 17, 2018

@fatso83

While what you say makes a lot of sense, allow me to counter-argue.

What happened?

We wanted to benefit from tree-shaking, so we switch Typescript to emit es6 rather than es5 modules - that's when all our sinon.stub tests failed.

This is just the start

I'd be very surprised if esm is not going to prevail in the near future. There's too much good in it compared to other paradigms.

You are probably going to have more and more people hitting this issue, or opening new issue on this matter.

This is not about webpack

The implementation of esm import as getter does not seem to be a choice made by webpack - it appears that this is the way to comply with the standard.

So in the following, replace 'webpack' and 'tooling format' with 'the standard':

It is superficially a feature request to make it easier to test the new transpiled code from Webpack. Adjusting Sinon to tooling formats and quirks is not so interesting from my perspective...

This was never a proposed solution

I do sympathize with the idea of making Sinon more "auto-nice", but I am not sure the proposed fix is sufficient or error proof.

The code I've provided was just a basic example - far from a 'solution' as far as sinon is concerned.

Mind your API surface

How can we reliably detect we are dealing with transpiled ESM, not some general object that happens to have a getter? We already have explicit support for overriding accessors, so that won't fly.

Well there's another way to look at that... why is there an explicit support for accessors? Why do I care if its an object with an accessor, or just a function?

Why should I use .return in one case and .get in another? It's an implementation detail for me as the test writer.


Anyhow, I hope I'm wrong about all this, and that is indeed a problem of far few people I appear to believe it is. Just we'll have to wait and see.

@fatso83
Copy link
Contributor

fatso83 commented May 17, 2018

The implementation of esm import as getter does not seem to be a choice made by webpack - it appears that this is the way to comply with the standard.

Yes, it is a way of complying with the standard in environments that don't support/implement ES Modules, and where you want the effect of a module system in an environment that doesn't natively support it. Transpiled code will probably be the de facto standard for how how ES Modules will be consumed for quite a while, until support is ubiquitous. But it isn't the real thing.

That's a good thing for testing employing mutation of targets, though, as true ES Modules can't be stubbed.

Explicitly targeting Webpack's output format doesn't seem like a good way of spending maintenance resources, as it's a moving target that perhaps won't be needed in a couple of years, but the point of making the API easier to use is a good one. As you stated, why should you care. Right now, I am not totally sure if/what technical reasons we had for making the API we did, but ATM I really can't see why we can't get away with the needless dummy stub used in accesor stubbing today. I do mention some downsides of this simplification further down though, which might be why the original author of the Sinon 2+ functionality made the API decisions he did.

Property descriptors is saving a superset of the property value, right? So it should be usable wherever we save values (such as the original function) today. By always storing the original property descriptor and using property descriptors when assigning new stubs we should be able to reuse the same logic (though I suspect the changes to the Sinon codebase might be quite invasive ...). This would remove explicit support for different setter and getter logic, but that again could be solved in the stub by seeing if it was used as a setter or getter.

This would make the API nicer for some cases (although accessor stubbing is relatively rare today), but it would also make other cases potentially confusing: especially stubbing Webpack bundled modules. To see this, consider what information Sinon has about the export object made by Webpack:
we see a lot of property accessors (getters), but without actually executing each we wouldn't know what is returned by each getter. Is the exported property hiding a function or a value? Impossible to tell. That is knowledge only held by the one implementing the test (and Webpack for that matter).

So that is maybe the answer to your question "Why should I care?": because you are the only one that knows what is expected.

This could be remedied, of course, by instrumenting Webpack (using a plugin) to add additional hints to the export objects about what is hiding behind the getters, but again: this is implementation details we don't want in the core of Sinon, but it could be a nice complementary package (like sinon-test or sinon-as-promised in the past) that adds functionality, maintained by the interested community.

Or ... just bypass the issue entirely, using link seams (proxywire, rewire, etc). After all, this issue concerns module loading, which Sinon isn't about, and for which there are existing, specialized products.

@jdalton
Copy link
Contributor

jdalton commented May 24, 2018

Having an official Sinon webpack plugin to do this or that may be a good thing so folks can have an easy to reach for solution to a common webpack 4 issue.

@fatso83
Copy link
Contributor

fatso83 commented May 24, 2018

True, we should consider adding a repo for that. As well as some docs ... Hopefully, @lzhaki won't mind if we steal the code for the plugin :-)

@Izhaki
Copy link

Izhaki commented May 24, 2018

@fatso83 This plugin? All yours.

I'd be happy to help here - I've written the nodemon webpack plugin, so I'd probably be able to write the tests and deal with webpack 3 compatibility faster.

Having said that, I'm still not sure configurable: false is the right choice by the Webpack team. Perhaps we shall raise an issue on Webpack before venturing into starting a new repo.

@jdalton
Copy link
Contributor

jdalton commented May 24, 2018

@Izhaki

Having said that, I'm still not sure configurable: false is the right choice by the Webpack team.

This is about making a Sinon webpack plugin not changing the core webpack functionality. A Sinon webpack plugin is opt-in by nature as those using Sinon have to install and add the plugin to their webpack configs.

@fatso83
Copy link
Contributor

fatso83 commented May 25, 2018

@Izhaki help is always wanted. If you make a minimal repo called sinon-webpack-plugin, stuff in whatever you feel needs to be there, preferably along with some kind of test that functionally makes sure that the resulting transpiled file is testable by Sinon (pretest step in package.json that builds, test step that tests the result using Sinon), we'll fork it in a jiffy under the Sinon organization! Doing this will, off course, automatically attribute you through the original commits.

@joepuzzo
Copy link

joepuzzo commented Apr 2, 2019

Hey guys, So i have attempted to use the recommended plugin and I still get the error TypeError: Cannot redefine property: Am I missing something? I simply called have the following in my webpack config:

 plugins: [
    new AllowMutateEsmExports()
  ]

@Izhaki
Copy link

Izhaki commented Apr 2, 2019

This doesn't work with latest version of Webpack. We're stuck on "webpack": "4.8.1" where it still works.

@joepuzzo
Copy link

joepuzzo commented Apr 2, 2019

It never ends lol. Well i guess its just time to start using jest or stop using webpack in my tests.

@Izhaki
Copy link

Izhaki commented Apr 2, 2019

This issue is global - it is about adherence to the standard, and regardless of what you'll use you're going to hit the same issue (search the jest repo for the very same issue there). You cannot mock es modules.

Nothing to do with Sinon or Webpack or Jest.

@fatso83
Copy link
Contributor

fatso83 commented Apr 2, 2019

You cannot mock es modules.

That's somewhat debatable :-) Yes, indeed, if you are following the standards, this is absolutely not possible, since ES Modules per the standard does not allow for that, but you do have stuff like ESM breaking the rules :-) So, ignoring WebPack for a second, doing mocha --register esm my-module-test.es6 would normally allow you to do so (as its mutableNamespace option is true by default).

Now, for the issue here, I am just going to ask @joepuzzo the same thing I did in the linked WebPack thread:
Why is webpack needed to run the tests? Why can't you just run them using Mocha directly to avoid all the hassle? It supports runtime transforms using Babel, so webpack is hardly ever needed:

mocha --require @babel/register test/**/*.js

@Izhaki
Copy link

Izhaki commented Apr 4, 2019

I've replied in the original issue.

@fatso83 fatso83 added the Property accessors Property Getters/Setters label Jun 27, 2019
fatso83 added a commit that referenced this issue Jun 27, 2019
Webpack users are often searching for ways to ways of using Sinon to stub out dependencies. They should really be using link seams instead, as you often cannot stub out internal dependencies.

See discussions in 
- webpack/webpack#6979 (comment)
- #1762 (plus #1962 and many more)
franck-romano pushed a commit to franck-romano/sinon that referenced this issue Oct 1, 2019
Webpack users are often searching for ways to ways of using Sinon to stub out dependencies. They should really be using link seams instead, as you often cannot stub out internal dependencies.

See discussions in 
- webpack/webpack#6979 (comment)
- sinonjs#1762 (plus sinonjs#1962 and many more)
@steve-taylor
Copy link

Why is webpack needed to run the tests? Why can't you just run them using Mocha directly to avoid all the hassle?

Speaking for myself, I test front-end code in the browser. It gives me an environment in which I can actually inspect the UI that I'm testing.

@fatso83
Copy link
Contributor

fatso83 commented Oct 28, 2019

@steve-taylor I used to use Karma to achieve that earlier. Webpack in itself does no such thing; it just creates a bunde that can be used in your tests. Is there a specific loader/plugin you use to achieve running your tests in the browser?

@Timmmm
Copy link

Timmmm commented May 27, 2020

I'm migrating a Vue project away from Vue CLI, and must have updated Webpack in the process or something (or maybe it was because I removed Babel?). It caused Sinon to stop working with no errors. It just didn't work. Very confusing.

It took me over a day to find this issue. I'm not keen to completely remove Webpack (yet) because then I'll have to make a whole new build system that does Typescript compilation, whatever dark magic vue-loader does etc. etc.

@Timmmm
Copy link

Timmmm commented May 27, 2020

So, to make sure I understand correctly:

  1. Webpack is trying to emulate ES6 Modules (ESM).
  2. In recent versions it does it like this:
/******/ 	// define getter function for harmony exports
/******/ 	__webpack_require__.d = function(exports, name, getter) {
/******/ 		if(!__webpack_require__.o(exports, name)) {
/******/ 			Object.defineProperty(exports, name, { enumerable: true, get: getter });
/******/ 		}
/******/ 	};

...

/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "globalState", function() { return globalState; });
  1. Because it doesn't call Object.defineProperty() with configurable: true, it means the globalState export is read-only, so Sinon cannot stub it.

  2. Izhaki's solution is to add a Webpack plugin that makes it use configurable: true. Unfortunately it also means you have to change your test code, from

sinon.stub(logger, 'createLogger').returns(loggerStub);

to

    Object.defineProperty(logger, 'createLogger', {
      writable: true,
      value: sinon.stub().returns(loggerStub)
    });

Edit: Oh also note that the linked Plugin uses a string search and replace, which is not very robust, as proven by the fact that it will not work in the current Webpack because it tries to replace configurable: false with configurable: true, but configurable: false is now implicit (see above).

  1. Webpack won't add an option to allow mutable ES6 modules because technically that is not compatible with the standard.

  2. A workaround that I don't really understand yet is to use Babel (i.e. add babel-loader to your webpack config). That seems shit though.

I feel like the best solution would be for Sinon to detect these situations (rather than just silently fail), and then try to use the Object.defineProperty() method instead. If that fails it should print an error advising you to add Izhaki's AllowMutateEsmExports plugin to your Webpack config.

Is that about right?

@Timmmm
Copy link

Timmmm commented May 27, 2020

Ok I had the idea to just make all of the exports mutable properties, similar to Izhaki's solution but so that it wouldn't require any changes to the code. Still using hacky string-based search and replace.

class MutableModulesHackPlugin {
  apply(compiler) {
    compiler.hooks.compilation.tap("MutableModulesHackPlugin", compilation => {
      compilation.mainTemplate.hooks.requireExtensions.tap(
        "MutableModulesHackPlugin",
        source => {
          let replaced = false;
          const newSource = source.replace(
            "Object.defineProperty(exports, name, { enumerable: true, get: getter });",
            () => {
              replaced = true;
              return `
  Object.defineProperty(exports, name,
    {
      enumerable: true,

      // Make it so that we can call Object.defineProperty() on this property
      // in the setter.
      configurable: true,

      // The original getter. Unfortunately we can't just do
      // exports[name] = getter() because the getter returns an object that
      // defined at the point that this function is called.
      get: getter,

      // When someone modifies this property, change the getter to return the
      // new value.
      set: val => {
        Object.defineProperty(exports, name,
          {
            get: () => val,
          }
        );
      },
    },
  );
`;
            },
          );
          if (!replaced) {
            throw new Error(
              "Couldn't find the required 'Object.defineProperty' string in Webpack output",
            );
          }
          return newSource;
        },
      );
    });
  }
}

Unfortunately sinon.stub() still does nothing (and still reports no errors), but if I replace

sinon.stub(MyModule, "aFunction").returns(42);

with

MyModule.aFunction = () => 42;

then it does work! I had a brief look into the Sinon source code and it seems like it can't mock getter/setter properties at all. This line doesn't do the right thing:

var func = typeof actualDescriptor.value === "function" ? actualDescriptor.value : null;

Maybe. I don't fully understand the Sinon code yet - it's quite complicated and almost entirely uncommented. :-/

@Timmmm
Copy link

Timmmm commented May 27, 2020

Ok I gave up trying to figure out how to modify Sinon so that it automatically stubs properties that are setters/getters, so I just made a wrapper that does it outside Sinon:

function stubImport(object: any, property: string) {
  const prop = Object.getOwnPropertyDescriptor(object, property);
  if (prop !== undefined && prop.get !== undefined) {
    const value = sinon.stub();
    Object.defineProperty(object, property, { value });
    return value;
  }
  return sinon.stub(object, property);
}

Use that instead of sinon.stub(Module, "export"), and then use the MutableModulesHackPlugin above and it seems to work. The set bit is actually optional in the MutableModulesHackPlugin plugin because we don't use it, so you can simplify it to this:

class MutableModulesHackPlugin {
  apply(compiler) {
    compiler.hooks.compilation.tap("MutableModulesHackPlugin", compilation => {
      compilation.mainTemplate.hooks.requireExtensions.tap(
        "MutableModulesHackPlugin",
        source => {
          let replaced = false;
          const newSource = source.replace(
            "Object.defineProperty(exports, name, { enumerable: true, get: getter });",
            () => {
              replaced = true;
              return "Object.defineProperty(exports, name, { enumerable: true, get: getter, configurable: true });";
            },
          );
          if (!replaced) {
            throw new Error(
              "Couldn't find the required 'Object.defineProperty' string in Webpack output",
            );
          }
          return newSource;
        },
      );
    });
  }
}

@Timmmm
Copy link

Timmmm commented May 28, 2020

    const value = sinon.stub();
    Object.defineProperty(object, property, { value });

Hmm unfortunately while this does work, it is not restored properly by sinon.restore(). Not unexpected I suppose. Does anyone know of a way to do this in a way that works with restore()?

@Timmmm
Copy link

Timmmm commented May 28, 2020

Aha, I found a solution! Just turn the getter into a normal value-based property.

export function stubImport(object: Record<string, any>, property: string) {
  const prop = Object.getOwnPropertyDescriptor(object, property);
  if (prop !== undefined && prop.get !== undefined) {
    Object.defineProperty(object, property, {
      value: object[property],
      writable: true,
      enumerable: true,
    });
  }
  return sinon.stub(object, property);
}

Combine that with the MutableModulesHackPlugin above, and then use stubImport(foo, bar) instead of sinon.stub(foo, bar) (for import-level items), and then everything works properly!

It would be nice if Sinon provided a webpack plugin like MutableModulesHackPlugin, and also autodetected them when you run sinon.stub(), but I can live with this solution.

@MeoMix
Copy link

MeoMix commented Apr 22, 2022

Just to help any poor souls who find themselves here - I have a working solution for you.

Be mindful of the oldCode source string. Yours might not match mine depending on what tools and versions of tools you're running in your build system. This example is compatible with Webpack 5. There is a different way of tapping the source for Webpack 4, but the idea is the same.

First, create a new, ad-hoc plugin. Use this to rewrite the source generated by Webpack. Hunt down the bit of code that manages exports and rewrite the source so that it includes configurable: true

AllowMutateEsmExports.prototype.apply = function (compiler) {
  compiler.hooks.compilation.tap(
    'AllowMutateEsmExports', function (compilation) {
      compilation.hooks.processAssets.tapPromise(
        {
          name: 'AllowMutateEsmExports',
          stage: compiler.webpack.Compilation.PROCESS_ASSETS_STAGE_ADDITIONAL,
          additionalAssets: true,
        },
        async (assets) => {
          const oldSource = assets['runtime.js'];
          const { ReplaceSource } = compiler.webpack.sources;
          const newSource = new ReplaceSource(oldSource, 'AllowMutateEsmExports');

          const oldCode = 'Object.defineProperty(exports, key, { enumerable: true, get: definition[key] });';
          const newCode = 'Object.defineProperty(exports, key, { configurable: true, enumerable: true, get: definition[key] });';
          const start = oldSource.source().indexOf(oldCode);
          const end = start + oldCode.length;

          newSource.replace(start, end, newCode, 'AllowMutateEsmExports');

          await compilation.updateAsset('runtime.js', newSource);
        }
      );
    }
  );
};

Ensure the plugin is loaded

plugins: [
  new AllowMutateEsmExports(),
],

Import the module to be affected using * notation. Rewrite the default export and give it a traditional value which is self-referential. This will only work if the property is made configurable first.

import * as fooModule from 'FooModule';

Object.defineProperty(fooModule, 'default', {
  writable: true,
  value: fooModule.default,
});

Spy will now work as expected. :)

const spy = sinon.spy(fooModule, 'default');

Consider upvoting my answer on S/O to help get the word out if you found this helpful.
https://stackoverflow.com/a/71962126/633438 I spent a good amount of time trying to figure out the most current solution. There's years of legacy partial/non-working answers floating around out there.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Property accessors Property Getters/Setters
Projects
None yet
Development

No branches or pull requests

9 participants