Skip to content
This repository has been archived by the owner on Sep 2, 2023. It is now read-only.

Feature: Mock modules (injection) #98

Closed
GeoffreyBooth opened this issue May 19, 2018 · 8 comments
Closed

Feature: Mock modules (injection) #98

GeoffreyBooth opened this issue May 19, 2018 · 8 comments
Labels

Comments

@GeoffreyBooth
Copy link
Member

  • I want to replace the dependencies of a module with mocks while testing.

Use case 7.

@benjamingr
Copy link
Member

I've been thinking a lot about that one (and looked at our rewire usage).

Generally mocking through the loader isn't a great use of the loader IMO because it's better done with dependency injection.

That said, mocking tools today can also swap actual var declared variables by parsing the module. This is something I think the loaders proposal could do quite nicely.

Note that one thing that arises from this feature if we choose to tackle it with multiple loaders is that we might need a way to change the loaders depending on what part of the same project we run (tests/app).

@mcollina
Copy link
Member

@benjamingr I think this can be achieved with the import()  function.

So, I would translate:

var proxyquire = require('proxyquire');
var foo = proxyquire('./foo', { 'path': pathStub });
foo.doSomething()

To

import proxyloader from 'proxyloader';

async function myTest() {
  const foo = await import('./foo', {
    loader: proxyloader({ 'path': pathStub })
  });

  foo.doSomething()
}

@ljharb
Copy link
Member

ljharb commented May 19, 2018

That would change timings in this module compared to all across the module graph on subtle ways - i don’t think that would work unless every single module was also moved to an async IIFE

@devsnek
Copy link
Member

devsnek commented May 20, 2018

this can be handled just fine by loader hooks, you shouldn't be setting up mocks by modifying your source anyway.

@jdalton
Copy link
Member

jdalton commented May 20, 2018

@mcollina

Dynamic import doesn't accept a second options argument at the moment.

@devsnek

There's also scenarios of folks mocking and unmocking in the same test file.
I'd be interested if that could be handled with loaders too.

import mock from "mock-require"
import requireInject from "require-inject"

mock("./real1.js", "./mock1.js")

import("./load.js")
  .then((ns) => {
    const exported = requireInject("./load.js", {
      "./real2.js": "mock2"
    })
    
    console.log("mock-require:", JSON.stringify(ns))
    // mock-require: {"real1":"mock1","real2":"real2"}
    console.log("require-inject:", JSON.stringify(exported))
    // mock-require: {"real1":"mock1","real2":"mock2"}

    mock.stopAll()
    return import("./load.js")
  })
  .then((ns) => {
    console.log("mock-require:", JSON.stringify(ns))
    // mock-require: {"real1":"real1","real2":"real2"}
  })

@Janpot
Copy link

Janpot commented May 20, 2018

I recently followed up some discussion around this topic in the webpack repo. Basically they made imports immutable in the last version, and it triggered some people that relied on those being mutable for stubbing. I did some thought experiment on how this could be done if there were loaders supported at runtime. And I arrived at something pretty close to what @mcollina proposes.

import { mock } from 'mock-library';
async function test () {
  const foo = await import(mock('./foo', {
    path: await import('./pathStub')
  }));
  foo.doSomething();
}

In here, the mock function would generate a unique url that could be picked up by a loader. Something like ./foo?id=123&mock=path. A loader could then instantiate a new version of ./foo with its path binding exposed somewhere, this binding can then be hooked up to the stub in the second parameter of mock.

@bmeck
Copy link
Member

bmeck commented May 23, 2018

This can be done in a variety of ways, I have one possible way as an example in https://github.com/bmeck/node-apm-loader-example that uses loaders and has some special scoping mechanisms going on. The general idea is to intercept the imports like @Janpot describes. Unmocking is a bit harder since you cannot fully preserve live bindings with this approach. The loader is able to create its own mock bindings, but those bindings are unable to delegate to other modules. That means that the loader must manually sync the bindings if it wishes to appear like the original module. The timing of that synchronization is not a well defined thing and would only be possible to fully track if events were created on the exported binding changing or VM support for binding delegation.

We could change the example to have synchronization events in order to preserve live bindings, but it would be a somewhat different workflow. In particular, instead of creating a "wrapper" module like https://github.com/bmeck/node-apm-loader-example/blob/master/overloads/fs.mjs is, it would instead completely transform the target module so that all exported variable assignments triggered synchronization. This would not work for builtins however unless the CJS form was also intercepted since builtins are using their own synchronization method as defined in nodejs/node#20403 .

This feature is complex enough to get right that we should probably invest in making this easier to get synchronization events than it currently is. I am not sure we need to create a separate feature from loaders for the mocking feature specifically.

@theKashey
Copy link

There are 2 wrong ways of "dependency mocking":

  • sinon way. Ie stubbing exports. That would not work since they are (now) immutable
  • rewire way. Ie injecting some control code into the module to change it from inside.

They both are "wrong" as long as everything you can do - you can do after module is loaded.

There are 2 right ways of "dependency mocking":

  • proxyquire way. Ie replacing the first level dependencies of a subject under test
  • jest way. Ie replacing a module, regardless if it's position.

In both cases, have first to locate modules to be replaced, replace them, remove cache before them to allow re-require, require the module under test with dependencies rewired, and then restore the module system to a previous state.
In both cases, the rest of the modules has to be reused due to singletons(classes, Symbols) might be defined there. So prefixing all modules with some unique seed to fork a universe is not an option.

node-apm-loader-example is a good example of how to replace a single module, but cache-management moment is a bit missing. Without it - nothing would work.

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
Projects
None yet
Development

No branches or pull requests

9 participants