如何对需要其他模块的Node.js模块进行单元测试,并如何模拟全局require函数?

171

这是一个简单的例子,说明了我的问题的关键:

var innerLib = require('./path/to/innerLib');

function underTest() {
    return innerLib.doComplexStuff();
}

module.exports = underTest;
我正在尝试编写这段代码的单元测试。如何在不完全模拟 require 函数的情况下模拟 innerLib 的要求?
因此,我试图模拟全局的 require ,并发现这样做也不起作用:
var path = require('path'),
    vm = require('vm'),
    fs = require('fs'),
    indexPath = path.join(__dirname, './underTest');

var globalRequire = require;

require = function(name) {
    console.log('require: ' + name);
    switch(name) {
        case 'connect':
        case indexPath:
            return globalRequire(name);
            break;
    }
};
问题在于underTest.js文件中的require函数实际上没有被模拟出来。它仍然指向全局的require函数。因此,似乎我只能在进行模拟的同一文件中模拟出require函数。如果我使用全局的require来包含任何内容,即使我已经覆盖了本地副本,被要求的文件仍将具有全局require引用。

你需要覆盖 global.require。变量默认写入到 module 中,因为模块是模块作用域的。 - Raynos
@Raynos 我怎么做呢?global.require未定义?即使我用自己的函数替换它,其他函数也永远不会使用它,对吗? - HMR
9个回答

189

你现在可以了!

我发布了proxyquire,它将在测试模块时负责覆盖全局的require。

这意味着您无需更改代码即可为所需模块注入模拟对象。

Proxyquire具有非常简单的API,可以在一步中解析要测试的模块并传递其所需模块的模拟/存根。

@Raynos是正确的,传统上你不得不采取不太理想的解决方案或者从底层开始开发。

这就是我创建proxyquire的主要原因-允许顶部向下的测试驱动开发,而不需要任何麻烦。

请查看文档和示例以确定它是否符合您的需求。


6
我使用 proxyquire,无法说出足够的好话。它救了我!我的任务是为在 appcelerator Titanium 中开发的应用编写 jasmine-node 测试,该应用强制某些模块为绝对路径,并且存在许多循环依赖。proxyquire 让我能够填补这些缺口,并针对每个测试模拟掉我不需要的垃圾代码。(在这里有解释)。非常感谢你! - Sukima
1
非常好,@ThorstenLorenz,我一定会使用proxyquire - bevacqua
这个模块真是太棒了!让我想起了.NET的Moq库。 - Teoman shipahi
3
对于那些使用Webpack的人,不要浪费时间研究proxyquire。它不支持Webpack。我正在研究inject-loader代替它(https://github.com/plasticine/inject-loader)。 - Artif3x

121
在这种情况下,更好的选择是模拟返回的模块方法。
无论好坏,大多数node.js模块都是单例的;两个require()相同模块的代码片段会获得对该模块的相同引用。
您可以利用这一点,并使用类似sinon的工具来模拟所需的项目。以下是mocha测试:
// in your testfile
var innerLib  = require('./path/to/innerLib');
var underTest = require('./path/to/underTest');
var sinon     = require('sinon');

describe("underTest", function() {
  it("does something", function() {
    sinon.stub(innerLib, 'toCrazyCrap').callsFake(function() {
      // whatever you would like innerLib.toCrazyCrap to do under test
    });

    underTest();

    sinon.assert.calledOnce(innerLib.toCrazyCrap); // sinon assertion

    innerLib.toCrazyCrap.restore(); // restore original functionality
  });
});

Sinon与chai的整合非常好,我编写了一个模块,用于将sinon与mocha整合,以便更轻松地清除spy/stub(避免测试污染)。

请注意,无法以相同方式对underTest进行模拟,因为underTest仅返回一个函数。

另一个选项是使用Jest mocks。请参阅它们的页面以获取更多信息。


3
很遗憾,node.js模块不能保证是单例模式的,正如这里所解释的:http://justjs.com/posts/singletons-in-node-js-modules-cannot-be-trusted-or-why-you-can-t-just-do-var-foo-require-baz-init - FrontierPsycho
5
@FrontierPsycho,有几件事情需要说明:首先,就测试而言,这篇文章并不相关。只要您测试了您的依赖项(而不是依赖项的依赖项)当您使用require('some_module')时,所有代码都将获得相同的对象,因为所有代码都共享同一个node_modules目录。其次,该文章混淆了名称空间和单例,这两者是有些不同的概念。第三,该文章相当古老(就Node.js而言),因此过去可能有效的内容现在可能已经无效了。 - Elliot Foster
2
嗯,除非我们中的某个人真正挖掘出证明一方或另一方的代码,否则我会选择你的依赖注入解决方案,或者只是简单地传递对象,这样更安全,更具未来性。 - FrontierPsycho
1
我不确定你要证明什么。节点模块的单例(缓存)特性是众所周知的。依赖注入虽然是一条好路线,但需要更多的样板代码和更多的代码量。在静态类型语言中,DI更常见,因为在动态地将间谍/存根/模拟器插入代码时更难。在过去三年中,我完成的多个项目都使用了我上面回答中描述的方法。这是所有方法中最简单的方法,尽管我很少使用它。 - Elliot Foster
1
我建议你阅读一下 sinon.js。如果你正在使用 sinon(如上面的例子),你可以通过 innerLib.toCrazyCrap.restore() 来重置存根,或者通过 sinon.stub(innerLib, 'toCrazyCrap') 调用 sinon,这样你就可以更改存根的行为:innerLib.toCrazyCrap.returns(false)。此外,rewire 似乎与上面提到的 proxyquire 扩展非常相似。 - Elliot Foster
显示剩余7条评论

12

我使用mock-require。确保在需要测试的模块require之前定义好你的模拟对象。


同时,最好立即执行stop(<file>)或者stopAll(),这样您就不会在不需要模拟的测试中得到缓存文件了。 - Justin Kruse

4

用于模拟模块的简单代码

注意在操作require.cache部分,同时请注意使用require.resolve方法,这是关键所在。

class MockModules {  
  constructor() {
    this._resolvedPaths = {} 
  }
  add({ path, mock }) {
    const resolvedPath = require.resolve(path)
    this._resolvedPaths[resolvedPath] = true
    require.cache[resolvedPath] = {
      id: resolvedPath,
      file: resolvedPath,
      loaded: true,
      exports: mock
    }
  }
  clear(path) {
    const resolvedPath = require.resolve(path)
    delete this._resolvedPaths[resolvedPath]
    delete require.cache[resolvedPath]
  }
  clearAll() {
    Object.keys(this._resolvedPaths).forEach(resolvedPath =>
      delete require.cache[resolvedPath]
    )
    this._resolvedPaths = {}
  }
}

使用方法:

describe('#someModuleUsingTheThing', () => {
  const mockModules = new MockModules()
  beforeAll(() => {
    mockModules.add({
      // use the same require path as you normally would
      path: '../theThing',
      // mock return an object with "theThingMethod"
      mock: {
        theThingMethod: () => true
      }
    })
  })
  afterAll(() => {
    mockModules.clearAll()
  })
  it('should do the thing', async () => {
    const someModuleUsingTheThing = require('./someModuleUsingTheThing')
    expect(someModuleUsingTheThing.theThingMethod()).to.equal(true)
  })
})

但是... Jest已经内置了这个功能,我建议在测试时使用这个测试框架,而不是自己编写。


3

我觉得模拟 require 看起来像个恶劣的hack。我个人会尝试避免它并重构代码使其更易于测试。 处理依赖项有多种方法。

1)将依赖项作为参数传递

function underTest(innerLib) {
    return innerLib.doComplexStuff();
}

这将使代码具有普适性的可测试性。缺点是需要传递依赖项,这可能会使代码看起来更加复杂。

2)将模块实现为类,然后使用类方法/属性获取依赖项。

(这是一个人为制造的例子,其中类的使用不合理,但它传达了这个思想)(ES6示例)

const innerLib = require('./path/to/innerLib')

class underTestClass {
    getInnerLib () {
        return innerLib
    }

    underTestMethod () {
        return this.getInnerLib().doComplexStuff()
    }
}

现在您可以轻松地存根getInnerLib方法来测试您的代码。虽然代码变得更加冗长,但也更容易测试。


2
我不认为这是你所想象的那样“hacky”(指粗糙、不专业)……这正是模拟的本质。模拟所需的依赖关系使事情变得如此简单,以至于它可以在不改变代码结构的情况下将控制权交给开发人员。你的方法太冗长了,因此很难理解。我选择使用proxyrequire或mock-require;我在这里看不到任何问题。代码干净易懂,大多数人阅读这篇文章时已经编写了你想让他们复杂化的代码。如果这些库是“hackish”,那么按照你的定义,模拟和存根也是“hackish”,应该停止使用。 - Emmanuel Mahuni
2
方法一的问题在于向上传递了内部实现细节。如果有多个层,则模块的使用者会变得更加复杂。但是它可以采用类似IOC容器的方法,让依赖项自动注入。然而,由于我们已经通过导入语句在节点模块中注入了依赖项,因此在那个级别上轻松地进行模拟是有意义的。 - magritte
1
  1. 这只是将问题转移到另一个文件中
  2. 仍然加载其他模块,从而造成性能开销,并可能导致副作用(例如流行的“colors”模块会干扰“String.prototype”)
- ThomasR

3
如果您曾经使用过jest,那么您可能熟悉jest的模拟功能。使用“jest.mock(...)”,您可以简单地指定在代码中某个地方出现的require语句中会发生的字符串,每当使用该字符串需要一个模块时,都会返回一个模拟对象。例如:
jest.mock("firebase-admin", () => {
    const a = require("mocked-version-of-firebase-admin");
    a.someAdditionalMockedMethod = () => {}
    return a;
})

将所有“firebase-admin”的导入/需要全部替换为您从“factory”函数返回的对象。

当使用jest时,您可以这样做,因为jest会创建一个运行时环境来运行每个模块,并向模块注入“hooked”版本的require,但是如果没有jest,您将无法这样做。

我尝试使用mock-require来实现这一点,但对于我的源代码中的嵌套级别,它对我没有起作用。请看下面在github上的问题:mock-require not always called with Mocha

为了解决这个问题,我创建了两个npm模块,您可以使用它们来实现您想要的功能。

您需要一个babel插件和一个模块模拟器。

在您的.babelrc文件中使用babel-plugin-mock-require插件,并使用以下选项:

...
"plugins": [
        ["babel-plugin-mock-require", { "moduleMocker": "jestlike-mock" }],
        ...
]
...

在您的测试文件中使用jestlike-mock模块,如下所示:

import {jestMocker} from "jestlike-mock";
...
jestMocker.mock("firebase-admin", () => {
            const firebase = new (require("firebase-mock").MockFirebaseSdk)();
            ...
            return firebase;
});
...
jestlike-mock模块仍然非常基础,没有很多文档,但代码也不多。我欢迎任何更完整的功能集的PR。目标是重新创建整个“jest.mock”功能。
为了看到jest如何实现它,可以查找“jest-runtime”包中的代码。例如,可以参考https://github.com/facebook/jest/blob/master/packages/jest-runtime/src/index.js#L734,在这里他们生成一个模块的“automock”。
希望能对您有所帮助 ;)

1
你可以使用 mockery 库:
describe 'UnderTest', ->
  before ->
    mockery.enable( warnOnUnregistered: false )
    mockery.registerMock('./path/to/innerLib', { doComplexStuff: -> 'Complex result' })
    @underTest = require('./path/to/underTest')

  it 'should compute complex value', ->
    expect(@underTest()).to.eq 'Complex result'

它是用什么语言编写的?CoffeeScript吗? - Sohail Si

1

不行。您必须建立起单元测试套件,以便首先测试最低模块,然后测试需要这些模块的更高级别模块。

您还必须假设任何第三方代码和node.js本身都经过了充分的测试。

我预计您将在不久的将来看到模拟框架覆盖global.require

如果您真的必须注入一个模拟对象,您可以更改代码以公开模块范围。

// underTest.js
var innerLib = require('./path/to/innerLib');

function underTest() {
    return innerLib.toCrazyCrap();
}

module.exports = underTest;
module.exports.__module = module;

// test.js
function test() {
    var underTest = require("underTest");
    underTest.__module.innerLib = {
        toCrazyCrap: function() { return true; }
    };
    assert.ok(underTest());
}

请注意,这会将.__module暴露给您的API,任何代码都可以在自己的风险下访问模块作用域。


2
在我看来,假设第三方代码经过充分测试并不是一种很好的工作方式。 - henry.oswald
5
@beck 这是一种非常好的工作方式。它强制你只使用高质量的第三方代码或编写自己的代码,以便每个依赖项都经过了良好的测试。 - Raynos
好的,我以为你指的是不在你的代码和第三方代码之间进行集成测试。同意。 - henry.oswald
1
一个“单元测试套件”只是一组单元测试,但是这些单元测试应该相互独立,这就是为什么称之为“单元测试”的原因。为了可用,单元测试应该快速且相互独立,这样当一个单元测试失败时,你可以清楚地看到代码哪里出了故障。 - Andreas Berheim Brudin
这对我没用。该模块对象未公开“var innerLib...”等内容。 - AnitKryst

0

我使用一个简单的工厂,返回一个函数,该函数调用具有其所有依赖项的函数:

/**
 * fnFactory
 * Returns a function that calls a function with all of its dependencies.
*/

"use strict";

const fnFactory = ({ target, dependencies }) => () => target(...dependencies);

module.exports = fnFactory;

想要测试以下函数:
/*
 * underTest
*/

"use strict";

const underTest = ( innerLib, millions ) => innerLib.doComplexStuff(millions);

module.exports = underTest;

我会按照以下方式设置我的测试(我使用Jest):
"use strict";

const fnFactory = require("./fnFactory");
const _underTest = require("./underTest");

test("fnFactory can mock a function by returng a function that calls a function with all its dependencies", () => {
    const fake = millions => `Didn't do anything with ${millions} million dollars!`;
    const underTest = fnFactory({ target: _underTest, dependencies: [{ doComplexStuff: fake  }, 10] });
    expect(underTest()).toBe("Didn't do anything with 10 million dollars!");
});

查看测试结果

在生产代码中,我会手动注入被调用者的依赖项,如下所示:

/**
 * main
 * Entry point for the real application.
*/

"use strict";

const underTest = require("./underTest");
const innerLib = require("./innerLib");

underTest(innerLib, 10);

我倾向于将我编写的大多数模块的范围限制在一个方面,这样可以减少测试和集成到项目中时需要考虑的依赖项数量。

这就是我处理依赖关系的方法。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接