注意:本指南涉及 JavaScript 生态系统中快速变化的各个部分,可能会过时。最近一次更新于 2023 年 9 月。本文中描述的部分方法和提到的工具依赖于将来可能会更改的 Node、捆绑包、编译器等的 API 不稳定性、API 私有性和实现详细信息。

模块模拟

模块模拟是一种测试技术,其中测试替换被导入到另一个模块的其中一个或全部模块的部分,而无需涉及的任何模块的协作。在大多数情况下,依赖注入是优于模块模拟的更好的选择。但如果你真的想这么做,在 Jasmine 被用来的大部分环境中,这是可行的。

模块模拟的优点和缺点

模块模拟最大的优点是它让你可以轻松测试与它的依赖项紧密耦合的代码。这非常方便,特别是如果你正在测试未以可测试性为中心而设计的遗留代码,或者你已经决定你更喜欢硬连线的依赖项。

模块模拟最大的缺点是它可以让你可以轻松测试与它的依赖项紧密耦合的代码。结果是,编写测试的行为将不再给你关于过度耦合的反馈。

模块模拟的另一个主要缺点是它改变了测试代码所依赖的全局状态。这会在默认情况下使测试变得不稳定:每个与模拟模块交互的测试会影响随后测试的行为,除非在测试之间将模拟重置为其原始配置。

模块模拟还“违背”了 JavaScript 语言的常规。它涉及一个文件在另一个文件中的全局变量发生变异,但该文件并不知情或参与其中。这可能会引起混淆,因为它不会发生在 JavaScript 的其他任何地方。如果模拟技术与模块系统或语言本身的规范发生冲突,它还可能导致问题。

在许多环境下,模块模拟涉及不稳定的 API 或 Node、编译器或捆绑包的私有实现细节。这极大地增加了将来停止工作的风险。

如果你仍想使用模块模拟

这里有一些食谱可以帮助你。它们中的大多数包括指向可以本地运行的完整工作示例的链接。

要选择正确的方案,您需要了解有关代码如何编译、捆绑和加载的某些信息。在大多数情况下,重要的是实际加载到 Node 或浏览器中的代码类型。因此,例如,如果您的代码编译为 CommonJS 模块,则即使源代码包含 import 语句,您也需要一个 CommonJS 模块模拟方法。

除非另有说明,所有这些方案都假定您没有使用 Webpack 或任何其他捆绑器。

使用 jasmine-browser-runner 在浏览器中进行 ES 模块

如果您的代码位于 ES 模块中,并且您使用 jasmine-browser-runner 测试它,则可以使用 import maps 来模拟模块。导入映射会覆盖浏览器的默认模块解析,允许您替换模拟版本。例如,如果您在 src/anotherModule.mjs 中有一个“真实”模块,而在 mockModules/anotherModule.mjs 中有一个模拟版本,您可以通过此配置让模拟加载而不是加载真实模块。

// jasmine-browser.json
{
  "srcDir": "src",
  // ...
  "importMap": {
    "moduleRootDir": "mockModules",
    "imports": {
      "anotherModule": "./anotherModule.mjs"
    }
  }
}
// src/anotherModule.mjs
export function theString() {
    return 'the string';
}
// mockModules/anotherModule.mjs
export let theString = jasmine.createSpy('theString');

// IMPORTANT:
// Reset after each spec to prevent spy state from leaking to the next spec
afterEach(function() {
    theString = jasmine.createSpy('theString');
});

好消息是,此技术完全依赖于 ES 模块系统的标准功能,因此在将来不太可能中断。坏消息是,它是完全全局的。您无法仅在某些测试中模拟模块,或在不同测试中使用不同的模拟。浏览器不提供允许执行该行为的模块加载器扩展挂钩。

完整的实际示例

在 Node 中使用 CommonJS 模块,无需额外的工具

如果您在 Node 中使用 CommonJS 模块,您无需任何额外工具即可模拟它们,只要您不对其进行解构。

// aModule.js
// Destructuring (e.g. const {theString} = require('./anotherModule.js');) will
// prevent code outside this file from replacing toString.
const anotherModule = require('./anotherModule.js');

function quote() {
    return '"' + anotherModule.theString() + '"';
}

module.exports = { quote };
// aModuleSpec.js
const anotherModule = require('../anotherModule');
const subject = require('../aModule');

describe('aModule', function() {
    describe('quote', function () {
        it('quotes the string returned by theString', function () {
            // Spies installed with spyOn are automatically cleaned up by
            // Jasmine between tests.
            spyOn(anotherModule, 'theString').and.returnValue('a more different string');
            expect(anotherModule.theString()).toEqual('a more different string');
            expect(subject.quote()).toEqual('"a more different string"');
        });
    });
});

这会对代码的编写方式施加限制,因为它在 aModuleanotherModule 进行解构时不起作用。但它不需要任何额外工具,并且由于模拟是通过 spyOn 完成的,因此您可以依靠 Jasmine 在测试结束时自动清理它。

完整的实际示例

在 Node 中使用 CommonJS 输出进行 TypeScript,无需额外的工具

此方案依赖于 TypeScript 编译器输出中未记录的详细信息,这些详细信息在过去已更改,并且将来可能会更改。已使用 TypeScript 5.1.0 对它进行了测试。

大多数版本的 TypeScript 都会发出不会对模块进行解构的 CommonJS 代码。因此,此源代码

import {theString} from './anotherModule';

export function quote() {
    return '"' + theString() + '"';
}

会被编译成类似以下内容

const anotherModule_1 = require("./anotherModule");
function quote() {
    return '"' + (0, anotherModule_1.theString)() + '"';
}

这允许即使源代码对模块进行解构,上述“在 Node 中使用 CommonJS 模块,无需额外的工具”方案中描述的方法也能起作用。

// aModule.ts
import {theString} from './anotherModule';

export function quote() {
    return '"' + theString() + '"';
}
// aModuleSpec.ts
import "jasmine";
import {quote} from '../src/aModule';
import * as anotherModule from '../src/anotherModule';

describe('aModule', function() {
    describe('quote', function() {
        it('quotes the string returned by theString', function() {
            spyOn(anotherModule, 'theString').and.returnValue('a more different string');
            expect(quote()).toEqual('"a more different string"');
        });
    });
});

对于分解导入模块的任何版本 TypeScript,这将不起作用。对于 TypeScript 3.9,这也不起作用,因为该版本将导出属性标记为只读。

完整的实际示例

使用 Testdouble.js 在 Node 中使用 CommonJS 模块

除了提供 Jasmine 间谍的备选方案,Testdouble.js 还可以连接到 Node 模块加载器,并使用 Mock 替换模块。

const td = require('testdouble');

describe('aModule', function() {
    beforeEach(function () {
        this.anotherModule = td.replace('../anotherModule.js');
        this.subject = require('../aModule.js');
    });

    afterEach(function () {
        td.reset();
    });

    describe('quote', function () {
        it('quotes the string returned by theString', function () {
            td.when(this.anotherModule.theString()).thenReturn('a more different string');
            expect(this.subject.quote()).toEqual('"a more different string"');
        });
    });
});

如果您更喜欢使用 Jasmine 的间谍,也可以这样做。

const td = require('testdouble');

describe('aModule', function() {
    beforeEach(function () {
        this.anotherModule = td.replace(
            '../anotherModule.js',
            {theString: jasmine.createSpy('anotherModule.theString')}
        );
        this.subject = require('../aModule.js');
    });

    afterEach(function () {
        td.reset();
    });

    describe('quote', function () {
        it('quotes the string returned by theString', function () {
            this.anotherModule.theString.and.returnValue('a more different string');
            expect(this.subject.quote()).toEqual('"a more different string"');
            expect(this.anotherModule.theString).toHaveBeenCalled();
        });
    });
});

有关更多信息,请查看 Testdouble 文档

完整的实际示例

使用 Testdouble.js 在 Node 中进行 ES 模块

此方法依赖于 Node 模块加载器 API,而这个 API 在 Node 20.6.1 中仍然是试验性的。Node 的未来版本可能会包含对加载器 API 的重大更改。

Testdouble 也可以模拟 ES 模块。与上述 CommonJS 方法相比,有两个重要的区别。第一个区别是 Testdouble 加载器必须在 Node 命令行中指定。因此,不要运行 npx jasmine./node_modules/.bin/jasmine,而要运行 node --loader=testdouble ./node_modules/.bin/jasmine。第二个区别是规范必须通过异步动态 import() 来加载模块,而不是通过 require 或静态 import 语句来加载模块。

import * as td from 'testdouble';

describe('aModule', function() {
    beforeEach(async function () {
        this.anotherModule = await td.replaceEsm('../anotherModule.js');
        this.subject = await import('../aModule.js');
    });

    afterEach(function () {
        td.reset();
    });

    describe('quote', function () {
        it('quotes the string returned by theString', function () {
            td.when(this.anotherModule.theString()).thenReturn('a more different string');
            expect(this.subject.quote()).toEqual('"a more different string"');
        });
    });
});

与上述 CommonJS 方法一样,您也可以使用 Jasmine 间谍(如果您愿意)。

由于 Testdouble 中的错误与较旧版本 Jasmine 中的错误之间的交互,如果您将 Testdouble ESM 加载器与 Jasmine 5.0.x 或更低版本一起使用,则您的 Jasmine 配置文件必须是 jasmine.js,而不是 jasmine.json。Jasmine 5.1.0 和更高版本允许将 JS 或 JSON 配置文件与 Testdouble ESM 加载器一起使用。

使用 JavaScript 的完整示例
使用 TypeScript 的完整示例

Webpack

Rewiremock 是一个包,可用于在各种情况下以模拟模块,包括代码由 Webpack 捆绑时。有很多不同的方法来配置 Rewiremock。有关更多信息,请参阅 其自述文件

Angular

Angular 测试应该 使用 Angular 对依赖关系注入的强大支持,而不是尝试模拟模块的属性。启用模块模拟可能需要修补 Angular 编译器(或重写其输出)以将导出属性标记为可写。目前还没有已知的任何工具可以做到这一点。如果有的话,未来 Angular 的版本可能会破坏它们。

如果您真的想模拟 Angular 中的硬连线依赖,则可以通过导出一个您控制的包装器对象来自定义模块系统。

// foo.js
const wrapper = {
    foo() { /* ... */ }
}
// bar.js
import fooWrapper from './foo.js';
//...
fooWrapper.foo();
// bar.spec.js
import fooWrapper from '../path/to/foo.js';
import bar from '../path/to/bar.js';
// ...
it('can mock foo', function() {
    spyOn(fooWrapper, 'foo').and.callFake(function() { /*... */ });
    // ...
})

Angular 应用的测试的详细信息可以在 Angular 手册中找到,特别是 测试依赖项注入 部分。

为本指南做贡献

您知道如何在这个指南未涉及的环境中启用模块模拟?请 添加贡献。完整的工作示例尤其有价值,因为它们展示了配置、程序包版本等详细信息,这些详细信息可能很重要,但一开始并不明显。