【问题标题】:Mocking/stubbing Mongoose model save method模拟/存根猫鼬模型保存方法
【发布时间】:2016-01-21 02:53:30
【问题描述】:

给定一个简单的 Mongoose 模型:

import mongoose, { Schema } from 'mongoose';

const PostSchema = Schema({
  title:    { type: String },
  postDate: { type: Date, default: Date.now }
}, { timestamps: true });

const Post = mongoose.model('Post', PostSchema);

export default Post;

我想测试这个模型,但遇到了一些障碍。

我目前的规范看起来像这样(为简洁起见省略了一些内容):

import mongoose from 'mongoose';
import { expect } from 'chai';
import { Post } from '../../app/models';

describe('Post', () => {
  beforeEach((done) => {
    mongoose.connect('mongodb://localhost/node-test');
    done();
  });

  describe('Given a valid post', () => {
    it('should create the post', (done) => {
      const post = new Post({
        title: 'My test post',
        postDate: Date.now()
      });

      post.save((err, doc) => {
        expect(doc.title).to.equal(post.title)
        expect(doc.postDate).to.equal(post.postDate);
        done();
      });
    });
  });
});

但是,每次运行测试时我都会访问我的数据库,我希望避免这种情况。

我尝试过使用Mockgoose,但我的测试无法运行。

import mockgoose from 'mockgoose';
// in before or beforeEach
mockgoose(mongoose);

测试卡住并抛出错误消息:Error: timeout of 2000ms exceeded. Ensure the done() callback is being called in this test. 我已尝试将超时时间增加到 20 秒,但没有解决任何问题。

接下来,我扔掉了 Mockgoose 并尝试使用 Sinon 来 stub save 调用。

describe('Given a valid post', () => {
  it('should create the post', (done) => {
    const post = new Post({
      title: 'My test post',
      postDate: Date.now()
    });

    const stub = sinon.stub(post, 'save', function(cb) { cb(null) })
    post.save((err, post) => {
      expect(stub).to.have.been.called;
      done();
    });
  });
});

这个测试通过了,但不知何故对我来说没有多大意义。我对存根,嘲笑,你有什么很陌生,......我不确定这是否是正确的方法。我在post 上存根save 方法,然后我断言它已被调用,但我显然在调用它......另外,我似乎无法理解非-stubbed Mongoose 方法将返回。我想将post 变量与save 方法返回的变量进行比较,就像在我访问数据库的第一个测试中一样。我试过couple 和methods,但他们都觉得很hackish。必须有一个干净的方法,不是吗?

几个问题:

  • 我真的应该避免像我一直阅读的那样访问数据库吗?我的第一个示例运行良好,每次运行后我都可以清除数据库。但是,我感觉不太对。

  • 如何从 Mongoose 模型中存根保存方法并确保它实际测试我想要测试的内容:将新对象保存到数据库。

【问题讨论】:

  • 如果你是一个 mockist TDDer,Oleg 的答案看起来不错,但大多数经典 TDDer 会have no problem with hitting the database。有关 mock、stub 和 mockist 与经典 TDD 的解释,请参阅有关该主题的 Martin Fowler's article。
  • @heenenee 归根结底,测试是为了保证代码质量,所以只要质量不受影响,就没有写入或错误。基本单元测试对 DB 的缺点是:(1) 速度,(2) CI 和单个项目开发人员的复杂性,(3) 通过 DB 状态转移的测试副作用,在单个测试或同时测试运行之间,这很难解决,(4)修复错误意味着开发人员需要付出额外的努力,在最坏的情况下是外部资源依赖。这一切都没有错,但实际上仅适用于集成测试。我会明确地将两者分开。
  • @heenenee 忘了提一下:感谢 Martin Fowler 的一篇非常有趣的文章的链接!
  • @Oleg 我同意所有这些。就个人而言,我没有对与持久性相关的代码进行单元测试而只进行集成测试很好。这源于经典 TDD 的“尽可能使用真实对象”的心态。我只是想向提问者提供这种观点和一些背景知识,因为他说他是存根/模拟的新手,可能根本不了解集成测试。

标签: node.js mongodb unit-testing mongoose sinon


【解决方案1】:

基础知识

在单元测试中,不应访问数据库。我可以想到一个例外:访问内存数据库,但即使这已经在集成测试领域,因为您只需要将状态保存在内存中以用于复杂进程(因此不是真正的功能单元)。所以,是的,没有实际的数据库。

您要在单元测试中测试的是,您的业务逻辑会在您的应用程序和数据库之间的接口处产生正确的 API 调用。您可以并且可能应该假设 DB API/驱动程序开发人员已经很好地测试了 API 下的所有内容都按预期运行。但是,您还希望在测试中涵盖您的业务逻辑如何对不同的有效 API 结果做出反应,例如成功保存、由于数据一致性导致的失败、由于连接问题导致的失败等。

这意味着您需要并且想要模拟的是数据库驱动程序接口下方的所有内容。但是,您需要对该行为进行建模,以便可以针对数据库调用的所有结果测试您的业务逻辑。

说起来容易做起来难,因为这意味着您需要通过您使用的技术访问 API,并且您需要了解 API。

猫鼬的现实

坚持我们想要模拟由 mongoose 使用的底层“驱动程序”执行的调用的基础。假设它是node-mongodb-native,我们需要模拟这些调用。理解 mongoose 和本机驱动程序之间的完整相互作用并不容易,但它通常归结为 mongoose.Collection 中的方法,因为后者扩展了 mongoldb.Collection 并且没有重新实现像 insert 这样的方法。如果我们能够在这种特殊情况下控制insert 的行为,那么我们就知道我们在 API 级别模拟了数据库访问。你可以在两个项目的源码中追踪到,Collection.insert 确实是原生驱动方法。

对于您的特定示例,我创建了带有完整包的a public Git repository,但我将在答案中发布所有元素。

解决方案

就我个人而言,我发现使用 mongoose 的“推荐”方式非常不可用:模型通常是在定义了相应模式的模块中创建的,但它们已经需要连接。为了在同一个项目中拥有多个连接来与完全不同的 mongodb 数据库进行通信以及为了测试目的,这让生活变得非常艰难。事实上,一旦关注点完全分离,至少对我而言,猫鼬几乎无法使用。

所以我首先创建的是包描述文件,一个带有模式的模块和一个通用的“模型生成器”:

package.json

{
  "name": "xxx",
  "version": "0.1.0",
  "private": true,
  "main": "./src",
  "scripts": {
    "test" : "mocha --recursive"
  },
  "dependencies": {
    "mongoose": "*"
  },
  "devDependencies": {
    "mocha": "*",
    "chai": "*"
  }
}

src/post.js

var mongoose = require("mongoose");

var PostSchema = new mongoose.Schema({
    title: { type: String },
    postDate: { type: Date, default: Date.now }
}, {
    timestamps: true
});

module.exports = PostSchema;

src/index.js

var model = function(conn, schema, name) {
    var res = conn.models[name];
    return res || conn.model.bind(conn)(name, schema);
};

module.exports = {
    PostSchema: require("./post"),
    model: model
};

这样的模型生成器有其缺点:有些元素可能需要附加到模型中,将它们放在创建模式的同一模块中是有意义的。所以找到一个通用的方法来添加这些有点棘手。例如,一个模块可以导出后操作,以便在为给定连接等生成模型时自动运行(黑客攻击)。

现在让我们模拟 API。我会保持简单,并且只会模拟我所需要的测试。重要的是,我想模拟一般的 API,而不是单个实例的单个方法。后者在某些情况下可能很有用,或者当没有其他帮助时,但我需要访问在我的业务逻辑内部创建的对象(除非通过某种工厂模式注入或提供),这意味着修改主要源。同时,在一个地方模拟 API 有一个缺点:它是一个通用的解决方案,它可能会实现成功的执行。对于测试错误案例,可能需要在测试本身中模拟实例,但是在您的业务逻辑中,您可能无法直接访问例如post 在内心深处创造。

那么,我们来看看mock成功的API调用的一般情况:

test/mock.js

var mongoose = require("mongoose");

// this method is propagated from node-mongodb-native
mongoose.Collection.prototype.insert = function(docs, options, callback) {
    // this is what the API would do if the save succeeds!
    callback(null, docs);
};

module.exports = mongoose;

通常,只要在修改 mongoose 之后创建模型,就可以考虑在每个测试的基础上进行上述模拟以模拟任何行为。但是,请确保在每次测试之前恢复到原始行为!

最后,这就是我们对所有可能的数据保存操作的测试的样子。请注意,这些并非特定于我们的 Post 模型,并且可以在所有其他模型中使用完全相同的模型来完成。

test/test_model.js

// now we have mongoose with the mocked API
// but it is essential that our models are created AFTER 
// the API was mocked, not in the main source!
var mongoose = require("./mock"),
    assert = require("assert");

var underTest = require("../src");

describe("Post", function() {
    var Post;

    beforeEach(function(done) {
        var conn = mongoose.createConnection();
        Post = underTest.model(conn, underTest.PostSchema, "Post");
        done();
    });

    it("given valid data post.save returns saved document", function(done) {
        var post = new Post({
            title: 'My test post',
            postDate: Date.now()
        });
        post.save(function(err, doc) {
            assert.deepEqual(doc, post);
            done(err);
        });
    });

    it("given valid data Post.create returns saved documents", function(done) {
        var post = new Post({
            title: 'My test post',
            postDate: 876543
        });
        var posts = [ post ];
        Post.create(posts, function(err, docs) {
            try {
                assert.equal(1, docs.length);
                var doc = docs[0];
                assert.equal(post.title, doc.title);
                assert.equal(post.date, doc.date);
                assert.ok(doc._id);
                assert.ok(doc.createdAt);
                assert.ok(doc.updatedAt);
            } catch (ex) {
                err = ex;
            }
            done(err);
        });
    });

    it("Post.create filters out invalid data", function(done) {
        var post = new Post({
            foo: 'Some foo string',
            postDate: 876543
        });
        var posts = [ post ];
        Post.create(posts, function(err, docs) {
            try {
                assert.equal(1, docs.length);
                var doc = docs[0];
                assert.equal(undefined, doc.title);
                assert.equal(undefined, doc.foo);
                assert.equal(post.date, doc.date);
                assert.ok(doc._id);
                assert.ok(doc.createdAt);
                assert.ok(doc.updatedAt);
            } catch (ex) {
                err = ex;
            }
            done(err);
        });
    });

});

需要注意的是,我们仍在测试非常底层的功能,但我们可以使用相同的方法来测试任何内部使用Post.create 或post.save 的业务逻辑。

最后一点,让我们运行测试:

~/source/web/xxx $ npm 测试

> xxx@0.1.0 test /Users/osklyar/source/web/xxx
> mocha --recursive

Post
  ✓ given valid data post.save returns saved document
  ✓ given valid data Post.create returns saved documents
  ✓ Post.create filters out invalid data

3 passing (52ms)

我必须说,这样做并不好玩。但这种方式实际上是对业务逻辑的纯单元测试,没有任何内存或真实数据库,而且相当通用。

【讨论】:

  • 感谢您的示例和很好的解释。我想我会在我的项目中以这种方式实现它,看看效果如何。你会推荐使用猫鼬的替代品吗?您提到 Mongoose 无法使用,并说这种嘲弄方式“不好玩”,那么替代方案是什么?不写这些测试是一种选择,我想,哈哈。
  • @cabaret Mongoose 是一个通用的 ODM(对象-文档映射)解决方案,“通用”元素对我不起作用。我更喜欢干净的类层次结构,而不是可以说是猫鼬的混合。所以测试不是主要原因,但它是一个因素。因此,我最终在为我的需求量身定制的本机驱动程序之上实现了一个较小的通用 ODM:我定义了一个干净的 API,对其进行了测试,然后让我的所有类(在 ODM 中)使用这些功能。很有趣的练习,但不够全面,无法公开。如果你坚持使用猫鼬,你基本上有两个选择:继续
  • (1) 使用我在此处概述的方法或类似方法;或(2)针对真正的数据库运行测试,本质上是集成测试而不是单元测试。如果这使您能够快速获得您的团队乐于使用的经过全面测试的代码(因为他们都需要数据库等),那么请采取这种务实的路线。毕竟,您关心产品的质量,而不是测试是否可以称为单元或必须称为集成测试。所以无论如何都要写测试:)
  • 我正在寻找解决方案来测试我的mongoose 东西,而不连接到实际的db。上面的解决方案真的是db 孤立的吗? var conn = mongoose.createConnection(); 在那里做什么?
【解决方案2】:

如果您想要测试某些猫鼬模型的static's 和method's,我建议您使用sinon 和sinon-mongoose。 (我猜它与chai兼容)

这样,您就不需要连接到 Mongo DB。

按照您的示例,假设您有一个静态方法findLast

//If you are using callbacks
PostSchema.static('findLast', function (n, callback) {
  this.find().limit(n).sort('-postDate').exec(callback);
});

//If you are using Promises
PostSchema.static('findLast', function (n) {
  this.find().limit(n).sort('-postDate').exec();
});

然后,测试这个方法

var Post = mongoose.model('Post');
// If you are using callbacks, use yields so your callback will be called
sinon.mock(Post)
  .expects('find')
  .chain('limit').withArgs(10)
  .chain('sort').withArgs('-postDate')
  .chain('exec')
  .yields(null, 'SUCCESS!');

Post.findLast(10, function (err, res) {
  assert(res, 'SUCCESS!');
});

// If you are using Promises, use 'resolves' (using sinon-as-promised npm) 
sinon.mock(Post)
  .expects('find')
  .chain('limit').withArgs(10)
  .chain('sort').withArgs('-postDate')
  .chain('exec')
  .resolves('SUCCESS!');

Post.findLast(10).then(function (res) {
  assert(res, 'SUCCESS!');
});

您可以在 sinon-mongoose 存储库中找到工作(和简单)示例。

【讨论】:

    猜你喜欢
    • 2015-05-03
    • 2020-09-05
    • 2014-04-02
    • 2016-06-29
    • 1970-01-01
    • 2015-01-14
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多