如何模拟 ES6 模块的导入?

Posted

技术标签:

【中文标题】如何模拟 ES6 模块的导入?【英文标题】:How can I mock the imports of an ES6 module? 【发布时间】:2016-05-16 09:26:40 【问题描述】:

我有以下 ES6 模块:

文件 network.js

export function getDataFromServer() 
  return ...

文件 widget.js

import  getDataFromServer  from 'network.js';

export class Widget() 
  constructor() 
    getDataFromServer("dataForWidget")
    .then(data => this.render(data));
  

  render() 
    ...
  

我正在寻找一种方法来使用 getDataFromServer 的模拟实例来测试 Widget。如果我使用单独的 <script>s 而不是 ES6 模块,就像在 Karma 中一样,我可以这样编写测试:

describe("widget", function() 
  it("should do stuff", function() 
    let getDataFromServer = spyOn(window, "getDataFromServer").andReturn("mockData")
    let widget = new Widget();
    expect(getDataFromServer).toHaveBeenCalledWith("dataForWidget");
    expect(otherStuff).toHaveHappened();
  );
);

但是,如果我在浏览器之外单独测试 ES6 模块(例如 Mocha + Babel),我会写如下内容:

import  Widget  from 'widget.js';

describe("widget", function() 
  it("should do stuff", function() 
    let getDataFromServer = spyOn(?????) // How to mock?
    .andReturn("mockData")
    let widget = new Widget();
    expect(getDataFromServer).toHaveBeenCalledWith("dataForWidget");
    expect(otherStuff).toHaveHappened();
  );
);

好的,但是现在getDataFromServerwindow 中不可用(好吧,根本没有window),而且我不知道如何将内容直接注入widget.js 自己的范围。

那么我该去哪里呢?

    有没有办法访问widget.js 的范围,或者至少用我自己的代码替换它的导入? 如果不能,我怎样才能使 Widget 可测试?

我考虑过的东西:

一个。手动依赖注入。

widget.js 中删除所有导入,并期望调用者提供deps。

export class Widget() 
  constructor(deps) 
    deps.getDataFromServer("dataForWidget")
    .then(data => this.render(data));
  

我对像这样弄乱 Widget 的公共接口并暴露实现细节感到非常不舒服。不行。


b.公开导入以允许模拟它们。

类似:

import  getDataFromServer  from 'network.js';

export let deps = 
  getDataFromServer
;

export class Widget() 
  constructor() 
    deps.getDataFromServer("dataForWidget")
    .then(data => this.render(data));
  

然后:

import  Widget, deps  from 'widget.js';

describe("widget", function() 
  it("should do stuff", function() 
    let getDataFromServer = spyOn(deps.getDataFromServer)  // !
      .andReturn("mockData");
    let widget = new Widget();
    expect(getDataFromServer).toHaveBeenCalledWith("dataForWidget");
    expect(otherStuff).toHaveHappened();
  );
);

这侵入性较小,但它需要我为每个模块编写大量样板文件,而且我仍然存在一直使用getDataFromServer 而不是deps.getDataFromServer 的风险。我对此感到不安,但这是迄今为止我最好的主意。

【问题讨论】:

如果没有对这种导入的 native 模拟支持,我可能会考虑为 babel 编写一个自己的转换器,将您的 ES6 样式导入转换为自定义的可模拟导入系统。这肯定会增加另一层可能的失败并更改您要测试的代码,... . 我现在无法设置测试套件,但我会尝试使用 jasmin 的 createSpy (github.com/jasmine/jasmine/blob/…) 函数,并从“network.js”模块导入对 getDataFromServer 的引用。这样,在小部件的测试文件中,您将导入 getDataFromServer,然后将 let spy = createSpy('getDataFromServer', getDataFromServer) 第二个猜测是从 'network.js' 模块返回一个对象,而不是一个函数。这样,您就可以在该对象上使用spyOn,从network.js 模块导入。它始终是对同一个对象的引用。 其实它已经是一个对象了,据我所见:babeljs.io/repl/… 我真的不明白依赖注入是如何弄乱Widget的公共接口的? Widget 搞砸了没有 deps。为什么不明确依赖关系? 【参考方案1】:

我已经开始在我的测试中使用import * as obj 样式,它将模块中的所有导出作为对象的属性导入,然后可以对其进行模拟。我发现这比使用 rewire 或 proxyquire 或任何类似技术要干净得多。例如,在需要模拟 Redux 操作时,我经常这样做。以下是我可能会在上面的示例中使用的内容:

import * as network from 'network.js';

describe("widget", function() 
  it("should do stuff", function() 
    let getDataFromServer = spyOn(network, "getDataFromServer").andReturn("mockData")
    let widget = new Widget();
    expect(getDataFromServer).toHaveBeenCalledWith("dataForWidget");
    expect(otherStuff).toHaveHappened();
  );
);

如果您的函数恰好是默认导出,那么 import * as network from './network' 将生成 default: getDataFromServer,您可以模拟 network.default。

注意:ES 规范将模块定义为只读,许多 ES 转译器已经开始尊重这一点,这可能会破坏这种间谍风格。这高度依赖于您的转译器以及您的测试框架。例如,尽管Jasmine does not, at least currently,我认为 Jest 发挥了一些魔力来完成这项工作。 YMMV。

【讨论】:

您是仅在测试中使用import * as obj,还是在常规代码中使用? @carpeliam 这不适用于导入只读的 ES6 模块规范。 Jasmine 抱怨 [method_name] is not declared writable or has no setter 这是有道理的,因为 es6 导入是恒定的。有没有办法解决? @Francisc import(不像require,它可以去任何地方)被提升,所以从技术上讲,你不能多次导入。听起来你的间谍在别处被召唤?为了防止测试弄乱状态(称为测试污染),您可以在 afterEach 中重置您的间谍(例如 sinon.sandbox)。我相信 Jasmine 会自动执行此操作。 @agent47 问题在于,虽然 ES6 规范明确阻止了这个答案的工作,但正如你提到的那样,大多数在 JS 中编写 import 的人并没有真正使用 ES6 模块。 webpack 或 babel 之类的东西会在构建时介入,并将其转换为它们自己的内部机制以调用代码的远处部分(例如 __webpack_require__)或 ES6 之前的 de facto之一> 标准、CommonJS、AMD 或 UMD。而且这种转换通常不严格遵守规范。所以对于现在很多很多的开发者来说,这个答案很好用。现在。【参考方案2】:

carpeliam is correct,但请注意,如果您想监视模块中的函数并使用该模块中的另一个函数调用该函数,则需要将该函数作为导出命名空间的一部分调用,否则间谍将不会不能用。

错误的例子:

// File mymodule.js

export function myfunc2() return 2;
export function myfunc1() return myfunc2();

// File tests.js
import * as mymodule

describe('tests', () => 
    beforeEach(() => 
        spyOn(mymodule, 'myfunc2').and.returnValue = 3;
    );

    it('calls myfunc2', () => 
        let out = mymodule.myfunc1();
        // 'out' will still be 2
    );
);

正确的例子:

export function myfunc2() return 2;
export function myfunc1() return exports.myfunc2();

// File tests.js
import * as mymodule

describe('tests', () => 
    beforeEach(() => 
        spyOn(mymodule, 'myfunc2').and.returnValue = 3;
    );

    it('calls myfunc2', () => 
        let out = mymodule.myfunc1();
        // 'out' will be 3, which is what you expect
    );
);

【讨论】:

我希望我能再给这个答案投票 20 次!谢谢! 有人能解释一下为什么会这样吗? export.myfunc2() 是 myfunc2() 的副本,而不是直接引用吗? @ColinWhitmarsh exports.myfunc2 是对myfunc2 的直接引用,直到spyOn 将其替换为对间谍函数的引用。 spyOn 将更改 exports.myfunc2 的值并将其替换为间谍对象,而 myfunc2 在模块范围内保持不变(因为 spyOn 无法访问它) 不应该用* 导入冻结对象并且不能更改对象属性? 请注意,使用 export functionexports.myfunc2 的建议在技术上混合了 commonjs 和 ES6 模块语法,这在需要 all- 的较新版本的 webpack (2+) 中是不允许的or-nothing ES6 模块语法使用。我在下面添加了一个基于这个将在 ES6 严格环境中工作的答案。【参考方案3】:

vdloo's answer 让我朝着正确的方向前进,但是在同一个文件中同时使用 CommonJS "exports" 和 ES6 模块 "export" 关键字对我不起作用(Webpack v2 或更高版本抱怨)。

相反,我使用默认(命名变量)导出包装所有单独的命名模块导出,然后在我的测试文件中导入默认导出。我将以下导出设置与Mocha/Sinon 一起使用,并且存根可以正常工作而无需 rewire 等:

// MyModule.js
let MyModule;

export function myfunc2()  return 2; 
export function myfunc1()  return MyModule.myfunc2(); 

export default MyModule = 
  myfunc1,
  myfunc2


// tests.js
import MyModule from './MyModule'

describe('MyModule', () => 
  const sandbox = sinon.sandbox.create();
  beforeEach(() => 
    sandbox.stub(MyModule, 'myfunc2').returns(4);
  );
  afterEach(() => 
    sandbox.restore();
  );
  it('myfunc1 is a proxy for myfunc2', () => 
    expect(MyModule.myfunc1()).to.eql(4);
  );
);

【讨论】:

有用的答案,谢谢。只是想提一下let MyModule 不需要使用默认导出(它可以是原始对象)。此外,这种方法不需要myfunc1() 调用myfunc2(),它可以直接窥探它。 @QuarkleMotion:您似乎无意中使用与主帐户不同的帐户编辑了此内容。这就是为什么您的编辑必须经过手动批准的原因——它看起来不像来自 我认为这只是一个意外,但如果是故意的,您应该 read the official policy on sock puppet accounts so you don't accidentally violate the rules . @ConspicuousCompiler 感谢您的提醒 - 这是一个错误,我不打算用我的工作电子邮件链接的 SO 帐户修改此答案。 这似乎是对另一个问题的回答! widget.js 和 network.js 在哪里?这个答案似乎没有传递依赖,这就是使原始问题变得困难的原因。【参考方案4】:

我实现了一个库,它试图解决 TypeScript 类导入的运行时模拟问题,而不需要原始类知道任何显式依赖注入。

该库使用import * as 语法,然后将原始导出对象替换为存根类。它保留了类型安全性,因此如果在没有更新相应测试的情况下更新了方法名称,您的测试将在编译时中断。

这个库可以在这里找到:ts-mock-imports。

【讨论】:

这个模块需要更多的github stars【参考方案5】:

我发现这种语法是有效的:

我的模块:

// File mymod.js
import shortid from 'shortid';

const myfunc = () => shortid();
export default myfunc;

我的模块的测试代码:

// File mymod.test.js
import myfunc from './mymod';
import shortid from 'shortid';

jest.mock('shortid');

describe('mocks shortid', () => 
  it('works', () => 
    shortid.mockImplementation(() => 1);
    expect(myfunc()).toEqual(1);
  );
);

见the documentation。

【讨论】:

+1 和一些附加说明:似乎只适用于节点模块,即你在 package.json 上的东西。更重要的是,在 Jest 文档中没有提到的东西,传递给 jest.mock() 的字符串必须匹配 import/packge.json 中使用的名称,而不是常量的名称。在文档中它们都是相同的,但是使用像 import jwt from 'jsonwebtoken' 这样的代码,您需要将模拟设置为 jest.mock('jsonwebtoken')【参考方案6】:

我自己没有尝试过,但我认为mockery 可能会起作用。它允许您用您提供的模拟替换真实模块。下面是一个示例,可让您了解其工作原理:

mockery.enable();
var networkMock = 
    getDataFromServer: function ()  /* your mock code */ 
;
mockery.registerMock('network.js', networkMock);

import  Widget  from 'widget.js';
// This widget will have imported the `networkMock` instead of the real 'network.js'

mockery.deregisterMock('network.js');
mockery.disable();

mockery 似乎不再维护,我认为它只适用于 Node.js,但尽管如此,它是一个很好的解决方案,用于模拟难以模拟的模块。

【讨论】:

【参考方案7】:

我最近发现babel-plugin-mockable-imports 巧妙地处理了这个问题,恕我直言。如果您已经在使用Babel,那么值得研究一下。

【讨论】:

【参考方案8】:

假设我想模拟从 isDevMode() 函数返回的结果,以检查代码在某些情况下的行为。

以下示例针对以下设置进行了测试

    "@angular/core": "~9.1.3",
    "karma": "~5.1.0",
    "karma-jasmine": "~3.3.1",

这里是一个简单的测试用例场景的例子

import * as coreLobrary from '@angular/core';
import  urlBuilder  from '@app/util';

const isDevMode = jasmine.createSpy().and.returnValue(true);

Object.defineProperty(coreLibrary, 'isDevMode', 
  value: isDevMode
);

describe('url builder', () => 
  it('should build url for prod', () => 
    isDevMode.and.returnValue(false);
    expect(urlBuilder.build('/api/users').toBe('https://api.acme.enterprise.com/users');
  );

  it('should build url for dev', () => 
    isDevMode.and.returnValue(true);
    expect(urlBuilder.build('/api/users').toBe('localhost:3000/api/users');
  );
);

src/app/util/url-builder.ts的示例内容

import  isDevMode  from '@angular/core';
import  environment  from '@root/environments';

export function urlBuilder(urlPath: string): string 
  const base = isDevMode() ? environment.API_PROD_URI ? environment.API_LOCAL_URI;

  return new URL(urlPath, base).toJSON();

【讨论】:

【参考方案9】:

您可以为此目的使用基于putout 的库mock-import。

假设您有一个要测试的代码,设为cat.js

import readFile from 'fs/promises';

export default function cat() 
    const readme = await readFile('./README.md', 'utf8');
    return readme;
;

而tap-based 名称为test.js 的测试看起来是这样的:

import test, stub from 'supertape';
import createImport from 'mock-import';

const mockImport, reImport, stopAll = createMockImport(import.meta.url);

// check that stub called
test('cat: should call readFile', async (t) => 
    const readFile = stub();
    
    mockImport('fs/promises', 
        readFile,
    );
    
    const cat = await reImport('./cat.js');
    await cat();
    
    stopAll();
    
    t.calledWith(readFile, ['./README.md', 'utf8']);
    t.end();
);

// mock result of a stub
test('cat: should return readFile result', async (t) => 
    const readFile = stub().returns('hello');
    
    mockImport('fs/promises', 
        readFile,
    );
    
    const cat = await reImport('./cat.js');
    const result = await cat();
    
    stopAll();
    
    t.equal(result, 'hello');
    t.end();
);

要运行测试,我们应该添加--loader 参数:

node --loader mock-import test.js

或者使用NODE_OPTIONS:

NODE_OPTIONS="--loader mock-import" node test.js

在底层mock-import 使用transformSource 钩子,它将所有imports 替换为这种形式的常量声明:

const readFile = global.__mockImportCache.get('fs/promises');

所以mockImport 将新条目添加到MapstopAll 清除所有模拟,因此测试不会重叠。

需要所有这些东西,因为 ESM has it's own separate cache 和用户空间代码无法直接访问它。

【讨论】:

【参考方案10】:

这是一个模拟导入函数的示例

文件 network.js

export function exportedFunc(data) 
  //..

文件 widget.js

import  exportedFunc  from 'network.js';

export class Widget() 
  constructor() 
    exportedFunc("data")
  

测试文件

import  Widget  from 'widget.js';
import  exportedFunc  from 'network'
jest.mock('network', () => (
  exportedFunc: jest.fn(),
))

describe("widget", function() 
  it("should do stuff", function() 
    let widget = new Widget();
    expect(exportedFunc).toHaveBeenCalled();
  );
);

【讨论】:

以上是关于如何模拟 ES6 模块的导入?的主要内容,如果未能解决你的问题,请参考以下文章

jest.mock():如何使用工厂参数模拟 ES6 类默认导入

它是模拟ES6模块

如何使用 ES6 模块导入来导入路径

如何从 ES6 模块导入默认和命名

如何使用 es6 导入加载 emscripten 生成的模块?

ES6,如何在一行中导出导入的模块?