模拟/存根Mongoose模型保存方法

19

给定一个简单的 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方法进行桩操作,然后断言它已被调用,但显然我正在调用它...此外,我似乎无法获取非桩操作的Mongoose方法返回的参数。我想将post变量与save方法返回的内容进行比较,就像第一个测试中我访问数据库时那样。我尝试了几种 方法,但它们都感觉很麻烦。一定有更简单的方法,对吧?
几个问题:
  • 我应该像我一直在各处阅读的那样避免频繁访问数据库吗?我的第一个示例可以正常运行,而且我可以在每次运行后清空数据库。但是,这种方式并不觉得对我来说很合适。

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


2
如果你是一个模拟派的TDDer,Oleg的答案看起来很好,但大多数传统TDDer不会介意访问数据库。关于模拟、桩和模拟派与传统TDD的解释,请参见马丁·福勒(Martin Fowler)有关此主题的文章。 - heenenee
2
@heenenee 最终测试的目的是保证代码质量,因此只要不影响质量,就不存在对错之分。基本单元测试访问数据库的缺点有:(1)速度慢,(2)CI和项目开发人员的复杂性,(3)测试副作用通过DB状态在个别测试或同时运行的测试之间传递,这些难以解决,(4)修复错误需要开发人员额外的努力,在最坏的情况下还需要外部资源依赖。这并没有什么不对,但是仅适用于集成测试。我会明确将两者区分开来。 - Oleg Sklyar
@heenenee 忘了提一下:感谢您提供 Martin Fowler 写的非常有趣的文章的链接! - Oleg Sklyar
3
我同意这个观点。就个人而言,我认为对于与持久性相关的代码,只进行集成测试而不进行单元测试也可以接受。这源于传统TDD 中“尽可能使用真实对象”的思维方式。我只是想提供这个观点以及一些背景信息给问者,因为他表示自己对桩和模仿还不熟悉,可能完全不了解集成测试。 - heenenee
2个回答

53

基础知识

在单元测试中,不应该对数据库进行访问。我能想到一个例外情况:对内存中的数据库进行访问,但即使这样也已经属于集成测试范畴,因为您仅需要为复杂进程保存在内存中的状态(因此不是真正的功能单元)。所以,是的,没有实际的数据库。

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

这意味着您需要模拟的是DB驱动程序接口以下的所有内容。但是,您需要建模其行为,以便可以对DB调用的所有结果测试您的业务逻辑。

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

Mongoose的实际情况

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

对于您的特定示例,我创建了一个包含完整包的公共Git存储库,但我将在答案中发布所有元素。

解决方案

个人认为与mongoose配合使用的“推荐”方式非常难以使用:模型通常在定义相应架构的模块中创建,但它们已经需要连接。为了在同一项目中与完全不同的mongodb数据库进行多个连接,并用于测试目的,这使得生活非常困难。事实上,只要关注点被完全分开,对我来说mongoose几乎无法使用。

因此,我首先创建的是包描述文件、一个包含模式和通用“模型生成器”的模块:

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

这样的模型生成器存在一些缺点:可能需要将某些元素附加到模型上,并且将它们放置在创建模式的同一模块中是有意义的。因此,找到一种通用的方法来添加它们有点棘手。例如,一个模块可以导出 post-actions,在为给定连接生成模型时自动运行它们等(黑客行为)。

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

因此,让我们看一下模拟成功 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.createpost.save内部的业务逻辑。

最后,让我们运行测试:

~/source/web/xxx $ npm test

> 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的替代方案?您提到Mongoose无法使用,并且说这种模拟的方式“不好玩”,那么替代方案是什么呢?我猜不写这些测试也是一个选项,哈哈。 - Joris Ooms
1
@cabaret Mongoose是一种通用的ODM(对象-文档映射)解决方案,而“通用”元素对我来说并不起作用。我更喜欢清晰的类层次结构,而不是mongoose中可能混杂的东西。因此,测试并不是主要原因,但它确实是一个因素。所以,最终我选择在本地驱动程序之上实现了一个较少通用的ODM,以满足我的需求:我定义了一个清晰的API,进行了测试,然后让我所有的类(在ODM中)使用这些功能。这是一个有趣的练习,但不够全面,无法公开。如果你坚持使用mongoose,你基本上有两个选择:待续。 - Oleg Sklyar
2
(1)使用类似我在这里概述的方法或类似的方法;或者(2)对真实数据库运行测试,进行基本上是集成测试而不是单元测试。如果这样可以让您快速获得完全测试过的代码,并且您的团队愿意采用它(因为他们都需要一个数据库等),那么请采取这种务实的方法。毕竟,您关心的是产品的质量,而不是测试是否可以称为单元测试或必须称为集成测试。因此,请务必编写测试 :) - Oleg Sklyar
我正在寻找不连接实际数据库就能测试我的 mongoose 代码的解决方案。上面的解决方案真正达到了隔离数据库的效果吗?var conn = mongoose.createConnection(); 是做什么用的? - angularrocks.com

8
如果您想测试某个Mongoose模型的静态和方法,我建议您使用sinonsinon-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存储库中找到工作(且简单)的示例。


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