如何模拟 ES6模块的导入?

我有以下 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>而不是 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模块(比如使用 摩卡 + 巴别塔) ,我会这样写:

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();
});
});

好吧,但是现在 window中没有 getDataFromServer(好吧,根本就没有 window) ,而且我不知道一种方法可以直接把东西注入到 widget.js自己的作用域中。

那我接下来该怎么办?

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

我考虑的事情:

A 手动依赖注入。

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

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

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


暴露进口,以允许嘲笑他们。

比如:

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的风险。我对此感到不安,但这是目前为止我最好的主意。

92004 次浏览

我已经开始在测试中使用 import * as obj样式,它将模块的所有导出作为对象的属性导入,然后可以对其进行模拟。我发现这比使用像重新连接或代理查尔或任何类似的技术要干净得多。例如,当需要模仿 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 转发器已经开始尊重这一点,这可能会打破这种间谍风格。这高度依赖于您的传输器和测试框架。例如,我认为 Jest 表演了一些魔术来使这个工作,虽然 茉莉没有,至少目前没有。YMMV.

Carpeliam 是正确的 ,但是请注意,如果您想监视模块中的一个函数并使用该模块中调用该函数的另一个函数,则需要将该函数作为导出名称空间的一部分来调用,否则将不会使用监视。

错误的例子:

// 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
});
});

我发现这种语法很管用:

我的模块:

// 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);
});
});

参见 文件

Vdloo 的回答 让我朝着正确的方向前进,但是在同一个文件中同时使用 CommonJS“ export”和 ES6模块“ export”关键字对我来说不起作用(网络包 v2或更高版本的抱怨)。

相反,我使用一个默认(命名变量)导出来包装所有单独的命名模块导出,然后在测试文件中导入默认导出。我正在使用下面的导出设置与 摩卡/Sinon 和短桩工程没有需要 重新接线等细:

// 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);
});
});

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

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

这个库可以在这里找到: 模拟进口

我自己还没试过,但我觉得 嘲笑可能有用。它允许您用您提供的模拟代替真正的模块。下面是一个例子,让你了解它是如何工作的:

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 中使用,但是尽管如此,它仍然是一个很好的模仿模块的解决方案,否则很难模仿这些模块。

我最近发现 巴别尔插件仿制进口可以灵活地处理这个问题,恕我直言。如果你已经在使用 巴别塔,那么它值得一看。

假设我想模拟从 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();
}

可以为此使用基于 不爽的库 模拟进口

让我们假设您有一个要测试的代码,假设它是 cat.js:

import {readFile} from 'fs/promises';


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

名为 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();
});


为了运行 test,我们应该添加 装弹手参数:

node --loader mock-import test.js

或使用 节点 _ 选项:

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

在底层 mock-import使用 转换源钩子,它在运行中用常量声明替换所有 imports,如下所示:

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

因此,mockImportMap中添加新的条目,而 stopAll清除所有模拟,所以测试不会重叠。

所有这些东西需要,因为 ESM 有自己的独立缓存和用户地址代码没有直接访问它。

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

文件 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();
});
});

我还没有能够尝试它,但是 ( codesandbox.io/s/adoring-orla-wqs3zl?file=/index.js 现场演示)

如果您有一个基于浏览器的测试运行器,理论上您应该能够包含一个 Service Worker,它可以拦截您想要模拟的 ES6模块的请求,并将其替换为另一个实现(类似于或相同于 模拟服务员处理事情的方式)

你的服务人员也是这样

self.addEventListener('fetch', (event) => {
if (event.request.url.includes("canvas-confetti")) {
event.respondWith(
new Response('const confetti=function() {}; export default confetti;', {
headers: { 'Content-Type': 'text/javascript' }
})
);
}
});

如果您的源代码正在使用这样的 ES6模块

import confetti from 'https://cdn.skypack.dev/canvas-confetti';
confetti();