Mocking/stubbing Mongoose模型保存方法
Mocking/stubbing Mongoose model save method
给定一个简单的 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 来存根 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 方法的参数 return。我想将 post
变量与 save
方法 returns 进行比较,就像在我访问数据库的第一个测试中一样。我试过 couple of methods 但他们都觉得很老套。必须有一个干净的方法,不是吗?
几个问题:
我真的应该像以前到处阅读一样避免访问数据库吗?我的第一个示例工作正常,我可以在每个 运行 之后清除数据库。但是,我真的感觉不太对。
我如何存根 Mongoose 模型的保存方法并确保它实际测试我想测试的内容:将新对象保存到数据库。
基础知识
在单元测试中,不应命中数据库。我可以想到一个例外:命中内存中的数据库,但即使是这样也已经在集成测试领域,因为您只需要为复杂的过程(因此不是真正的功能单元)保存在内存中的状态。所以,是的,没有实际的数据库。
您要在单元测试中测试的是您的业务逻辑在您的应用程序和数据库之间的接口处产生正确的 API 调用。您可以并且可能应该假设 DB API/driver 开发人员已经很好地测试了 API 以下的所有内容都按预期运行。但是,您还希望在测试中涵盖您的业务逻辑如何对不同的有效 API 结果做出反应,例如成功保存、由于数据一致性导致的失败、由于连接问题导致的失败等。
这意味着您需要和想要模拟的是数据库驱动程序接口下的所有内容。但是,您需要对该行为建模,以便可以针对数据库调用的所有结果测试您的业务逻辑。
说起来容易做起来难,因为这意味着您需要通过您使用的技术访问 API,并且您需要了解 API。
猫鼬的现实
坚持基本原则,我们想模拟 mongoose 使用的底层 'driver' 执行的调用。假设它是 node-mongodb-native 我们需要模拟这些调用。理解猫鼬和本机驱动程序之间的完整相互作用并不容易,但它通常归结为 mongoose.Collection
中的方法,因为后者扩展了 mongoldb.Collection
而 没有 重新实现 insert
等方法。如果我们能够在这种特殊情况下控制 insert
的行为,那么我们就知道我们在 API 级别模拟了数据库访问。您可以在两个项目的源代码中进行跟踪,Collection.insert
确实是本机驱动程序方法。
对于您的特定示例,我创建了 a public Git repository 一个完整的包,但我会 post 答案中的所有元素。
解决方法
就我个人而言,我发现 "recommended" 使用 mongoose 的方式非常不可用:模型通常是在定义了相应模式的模块中创建的,但它们已经需要一个连接。为了让多个连接与同一个项目中完全不同的 mongodb 数据库对话以及出于测试目的,这让生活变得非常艰难。事实上,一旦关注点完全分离,至少对我来说,猫鼬就变得几乎无法使用。
所以我首先创建的是包描述文件,一个带有模式和通用的模块 "model generator":
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
};
这样的模型生成器有其缺点:有些元素可能需要附加到模型,将它们放在创建模式的同一模块中是有意义的。所以找到一种通用的方法来添加它们有点棘手。例如,当为给定连接等生成模型时,模块可以导出 post-actions 以自动 运行(hacking)。
现在让我们模拟 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;
一般来说,只要在修改猫鼬之后创建模型,就可以认为上述模拟是在每个测试的基础上完成的,以模拟任何行为。但是,请确保在每次测试之前恢复到原始行为!
最后,这就是我们对所有可能的数据保存操作的测试结果。请注意,这些并不特定于我们的 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 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)
我必须说,这样做一点都不好玩。但这种方式实际上是业务逻辑的纯单元测试,没有任何内存或真实数据库,而且相当通用。
如果你想要测试static's
和method's
某些Mongoose模型,我建议你使用sinon and sinon-mongoose. (I guess it's compatible with chai)
这样,您将不需要连接到 Mongo 数据库。
按照你的例子,假设你有一个静态方法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 存储库中找到有效(和简单)的示例。
给定一个简单的 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 来存根 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 方法的参数 return。我想将 post
变量与 save
方法 returns 进行比较,就像在我访问数据库的第一个测试中一样。我试过 couple of methods 但他们都觉得很老套。必须有一个干净的方法,不是吗?
几个问题:
我真的应该像以前到处阅读一样避免访问数据库吗?我的第一个示例工作正常,我可以在每个 运行 之后清除数据库。但是,我真的感觉不太对。
我如何存根 Mongoose 模型的保存方法并确保它实际测试我想测试的内容:将新对象保存到数据库。
基础知识
在单元测试中,不应命中数据库。我可以想到一个例外:命中内存中的数据库,但即使是这样也已经在集成测试领域,因为您只需要为复杂的过程(因此不是真正的功能单元)保存在内存中的状态。所以,是的,没有实际的数据库。
您要在单元测试中测试的是您的业务逻辑在您的应用程序和数据库之间的接口处产生正确的 API 调用。您可以并且可能应该假设 DB API/driver 开发人员已经很好地测试了 API 以下的所有内容都按预期运行。但是,您还希望在测试中涵盖您的业务逻辑如何对不同的有效 API 结果做出反应,例如成功保存、由于数据一致性导致的失败、由于连接问题导致的失败等。
这意味着您需要和想要模拟的是数据库驱动程序接口下的所有内容。但是,您需要对该行为建模,以便可以针对数据库调用的所有结果测试您的业务逻辑。
说起来容易做起来难,因为这意味着您需要通过您使用的技术访问 API,并且您需要了解 API。
猫鼬的现实
坚持基本原则,我们想模拟 mongoose 使用的底层 'driver' 执行的调用。假设它是 node-mongodb-native 我们需要模拟这些调用。理解猫鼬和本机驱动程序之间的完整相互作用并不容易,但它通常归结为 mongoose.Collection
中的方法,因为后者扩展了 mongoldb.Collection
而 没有 重新实现 insert
等方法。如果我们能够在这种特殊情况下控制 insert
的行为,那么我们就知道我们在 API 级别模拟了数据库访问。您可以在两个项目的源代码中进行跟踪,Collection.insert
确实是本机驱动程序方法。
对于您的特定示例,我创建了 a public Git repository 一个完整的包,但我会 post 答案中的所有元素。
解决方法
就我个人而言,我发现 "recommended" 使用 mongoose 的方式非常不可用:模型通常是在定义了相应模式的模块中创建的,但它们已经需要一个连接。为了让多个连接与同一个项目中完全不同的 mongodb 数据库对话以及出于测试目的,这让生活变得非常艰难。事实上,一旦关注点完全分离,至少对我来说,猫鼬就变得几乎无法使用。
所以我首先创建的是包描述文件,一个带有模式和通用的模块 "model generator":
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
};
这样的模型生成器有其缺点:有些元素可能需要附加到模型,将它们放在创建模式的同一模块中是有意义的。所以找到一种通用的方法来添加它们有点棘手。例如,当为给定连接等生成模型时,模块可以导出 post-actions 以自动 运行(hacking)。
现在让我们模拟 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;
一般来说,只要在修改猫鼬之后创建模型,就可以认为上述模拟是在每个测试的基础上完成的,以模拟任何行为。但是,请确保在每次测试之前恢复到原始行为!
最后,这就是我们对所有可能的数据保存操作的测试结果。请注意,这些并不特定于我们的 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 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)
我必须说,这样做一点都不好玩。但这种方式实际上是业务逻辑的纯单元测试,没有任何内存或真实数据库,而且相当通用。
如果你想要测试static's
和method's
某些Mongoose模型,我建议你使用sinon and sinon-mongoose. (I guess it's compatible with chai)
这样,您将不需要连接到 Mongo 数据库。
按照你的例子,假设你有一个静态方法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 存储库中找到有效(和简单)的示例。