Yocy

Mongoose 单元测试: 权威指南| CodeUtopia

原文链接: codeutopia.net

Mongoose 是一个很棒的工具,能使你运用 MongoDB 建立 Node 应用变得更加容易。Mongoose models 能够很容易的定义功能函数,而不用把有关的逻辑分散到各处。同样,用它在 MongoDB 中查询数据也会变得快速和容易,特别是你需要一些自定义查询逻辑时,自定义的逻辑会很好的被隐藏到 Mongoose models 中。

但是,你应该如何去测试所有的相关逻辑呢?当你测试他们的时候,可能会感到些许迷惑。理想情况下,我们想不用连接数据库去测试,毕竟连接后会使我们的测试变得缓慢而且不容易设置,因为我们需要管理数据库的状态。我们怎样才能做到这种不连接数据库的测试呢?

事实上,Mongoose 相关的问题是我读者中最常见的问题。

那么就让我们来看看如何测试 Mongoose models,我们会深入的的剖析那些你在运用 Mongoose 中遇到的问题。

开始吧

刚开始接触 Mongoose models 测试的时候看起来稍微有点复杂 —— 这里有 schemas, models, validators, finders, statics...

还不止这些。有两点我们需要了解:

  1. 测试 Mongoose model 本身的逻辑——如验证。

  2. 用我们的 model 测试代码 —— 借助其它的比如 MongoDB 本身的 querying 或 finders进行测试。

不难理解为什么刚开始会比较有挑战,但是一旦你掌握了我教给你的技术,测试就会变得非常简单。最重要的是:测试 Mongoose models 用到的技术和你以前用于测试的代码相差不大。

我们将要用到的工具有:用于运行的Mocha,用于断言的Chai,以及用于创建可能需要存根 (stubs) 的Sinon。

我们可以在项目中这样设置:

npm install -g mocha

npm install --save sinon chai

第一部分:测试模型(Models)

开始应该考虑如何测试 model 对象中的各个不同部分。

测试模型验证

一个好的 model 有一个最重要的方面就是认证(validation),你不想一些不合法的数据进入你的数据库,特别是 MongoDB 本身缺乏类型检查的机制 —— Oh,这个部分的数据看起来怎么这么奇怪!

Mongoose 通常会在你调用 save()的时候检查将要存入 MongoDB 的对象。作为替代的是我们可以不用连接数据库,而用validate()写我们自己的测试。

例如,假如我们已经有了下面的集合 (schema) 和模型:

var mongoose = require('mongoose');

var memeSchema = new mongoose.Schema({
    name: { type: String }
});

module.exports = mongoose.model('Meme', memeSchema);

当然,现在我们并不想允许存储一个没有命名的东西。怎么为这个写测试?

var expect = require('chai').expect;

var Meme = require('../src/Meme');

describe('meme', function() {
    it('should be invalid if name is empty', function(done) {
        var m = new Meme();

        m.validate(function(err) {
            expect(err.errors.name).to.exist;
            done();
        });
    });
});

如何你运行这个例子,你将会发现集合缺乏required属性。加上这个属性之后则会运行通过:

var memeSchema = new mongoose.Schema({
    name: { type: String, required: true }
});

OK,这的确十分简单。其它的类型检查呢?

你知道吗,就和这个一样!你能用和这个相同的模式去测试任何的验证逻辑

  1. 创建一个用于数据认证的模型,这个模型应该能验证我们希望的状态

  2. 回调函数中加入validate

  3. 在回调函数中做错误属性断言 (assertion)。

让我们来看一个稍微进阶的例子。

我们想要 meme 只允许转发:

var memeSchema = new mongoose.Schema({
    name: { type: String, required: true },
    dank: { type: Boolean },
    repost: {
        type: Boolean,
        validate: function(v) {
            return v === true && this.dank === true;
        }
    }
});

这个有关 repost 的验证函数只会在dank 也被设为 true 时通过。

现在让我们来检查这个问题的测试,你将会看到用上面的相同步骤写一个完全不同验证器。

it('should have validation error for repost if not dank', function(done) {
    //1\. set up the model in a way the validation should fail
    var m = new Meme({ repost: true });

    //2\. run validate
    m.validate(function(err) {
        //3\. check for the error property we need
        expect(err.errors.repost).to.exist;
        done();
    });
});

it('should be valid repost when dank', function(done) {
    //1\. set up the model in a way the validation should succeed
    var m = new Meme({ repost: true, dank: true });

    //2\. run validate 
    m.validate(function(err) {
        //3\. check for the error property that shouldn't exist now
        expect(err.errors.repost).to.not.exist;
        done();
    });
});

我们完成了失败和成功的测试例子(Failure and success)。这个验证更加复杂,它使成功和失败两个条件都有意义。

就如你看到的这样,我们用了相同的步骤写出了两个不同的验证测试。

测试模型的实例方法

在你的模型中应该有两种典型的实例方法:

  1. 不能进入数据库的实例方法

  2. 可以进入数据库的实例方法

在测试#1的时候,会比较简单:调用包含各种可能参数出现的函数,并且坚持函数返回值或回调函数的结果。

实现#2 会稍稍有点挑战。假设说我们已经有了这么一个函数,能够检查reposts是否存在:

memeSchema.methods.checkForReposts = function(cb) {
    this.model('Meme').findOne({
        name: this.name,
        repost: true
    }, function(err, val) {
        cb(!!val);
    });
};

这个用了 MongoDB 通用的简单查询方法, 并把 repost 设置为 true。让我们来看看我们应该怎么做。

首先,应该确认这个函数是否做了正确的查询,我们可以为这个写一个测试:

it('should check for reposts with same name', sinon.test(function() {
    this.stub(Meme, 'findOne');
    var expectedName = 'This name should be used in the check';
    var m = new Meme({ name: expectedName });

    m.checkForReposts(function() { });

    sinon.assert.calledWith(Meme.findOne, {
        name: expectedName,
        repost: true
    });
}));

Mene.findOne 是一个能够被调用的存根函数,我们可以借助它来模拟而不用连接数据库。这样就能允许我们使用 Sinon 去检查被调用的函数是否传入了正确的参数。

然后设置了包含我们想要检查了变量expectedName。创建了一个新的Meme 对象并调用chekForReposts

最后,用sinon.assert.calledWith 去检查调用是否正确。注意这一点,在保存传入的预期变量时,我们保存的是重新传入的变量,这样有利于后面的编码,你可以看到预期的值是什么。

这里还应该有另一个测试去确认findOne中的结果被正确处理了。当一个repost存在的时候,回调函数应该把true作为参数的一部分。

it('should call back with true when repost exists', sinon.test(function(done) {
    var repostObject = { name: 'foo' };
    this.stub(Meme, 'findOne').yields(null, repostObject);
    var m = new Meme({ name: 'some name' });

    m.checkForReposts(function(hasReposts) {
        expect(hasReposts).to.be.true;
        done();
    });
}));

和前面一样,我们创建了Meme.findOne用于存根。在这个例子中它产生nullrepostObject 两个值。在存根方法中的yields函数能够自动的调用任何传入的回调函数——在这个例子中,我们让null表示没有错误,repostObject则扮演着一个负责寻找回调函数的 Mongoose model。

这个时候在checkFoeReposts中用一个回调函数做断言,我们需要确保它被正确的调用。

测试静态函数

测试Mongoose model 中的静态函数和测试实例方法一样。不同的是你不用在测试中实例化一个模型。

  1. 存根所有的数据库访问函数

  2. 如果要测试任何的数据库查询,可以在存根中返回值

  3. sinon.assert.calledWith 能被用来检查数据库查询是否正确

Part 2: 测试用了 Mongoose models的代码

现在我们已经把测试覆盖了模型本身,让我们来考虑怎么去测试那些用了Mongoose models的代码。

在很多例子中,这个部分是比较明确的。我们用存根去做测试的大部分事情。

不管怎样,让我们看看一些这种代码的例子并考虑怎么测试。

测试一个查询数据的函数

在一个应用中最普遍的行为就是借助模型在数据库中查询。这可以是一个服务器,一个辅助函数或者可能是一个查询发生点的Express路由。

假如我们已近有了如下函数:

var Meme = require('./Meme');

module.exports.allMemes = function(req, res) {
    Meme.find({ repost: req.params.reposts }, function(err, memes) {
        res.send(memes);
    });
};

这可能是一个借助Express的路由。它载入了所有的memes并且将他们作为JSON格式发送,其中参数包含了一个可选的reposts标志。

我们可以用 Meme.find 很容易的测试这个函数:

var expect = require('chai').expect;
var sinon = require('sinon');

var routes = require('../src/routes');
var Meme = require('../src/Meme');

describe('routes', function() {
    beforeEach(function() {
        sinon.stub(Meme, 'find');
    });

    afterEach(function() {
        Meme.find.restore();
    });

    it('should send all memes', function() {
        var a = { name: 'a' };
        var b = { name: 'b' };
        var expectedModels = [a, b];
        Meme.find.yields(null, expectedModels);
        var req = { params: { } };
        var res = {
            send: sinon.stub()
        };

        routes.allMemes(req, res);

        sinon.assert.calledWith(res.send, expectedModels);
    });
});

在这里,用了beforEachafterEach 去自动的存根和回复查询函数。这方便我们在其他需要相同的 stub 操作中用这些钩子。

和前面的例子一样,在这里设置了一些预期的数据,并且返回查询函数。我们也设置了存根去产生结果。

同样也设置了路由的 reqres 参数,对于res,我们把存根作为 send 函数,这样我们在后面可以作断言。

在调用路由的时候,我们又一次用了sinon.assert.calledWith 去验证行为是否正确。

我们同样可以用一个带有reposts标志的函数验证:

it('should query for non-reposts if set as request parameter', function() {
    Meme.find.yields(null, []);
    var req = {
        params: {
            reposts: true
        }
    };
    var res = { send: sinon.stub() };

    routes.allMemes(req, res);

    sinon.assert.calledWith(Meme.find, { repost: true });
});

可以看到我们像前面一样用了相似的技术—— 设置存根,设置数据,调用函数,断言。

我们还可以把这个模式应用到很多其他的例子中。实际上,在我们前面的模型中都用了这样一种模式。

福利部分:处理测试数据

(Valeri Karpov 建议包含这部分)

在写这种测试的时候,你将会频繁的用到相同类型的测试数据。例如,在测试模型中不同的路由或者模块时,你需要为你的测试创建虚假的数据。

可以像上面的这些例子一样在测试中定义自己的数据,但是如果有许许多多的测试要做时,你需要为这些相似的测试数据做很多重复的工作。这会是一个问题,特别是你的测试需要用数据检查正确性时。

未了避免在测试中到处粘贴测试数据,你可以创建一个辅助函数为你创建虚假的数据。

例如,我们可以拥有这么一个文件 test/factories.js,用来存放上面所说的代码。我把这个叫做 factories的原因是他能创建一种某些类型的对象,经常被叫做工厂函数。

module.exports.validMeme = function() {
  return {
    name: 'Some name here',
    dank: false,
    repost: false
  };
};

module.exports.repostMeme = function() {
  return {
    name: 'Some name here',
    dank: false,
    repost: true
  };
};

我们可以让前面带有测试数据的代码变得更简单,就像这样:

var factories = require('./factories');

it('should send all memes', function() {
  var a = factories.validMeme();
  var b = factories.validMeme();
  var expectedModels = [a, b];
  Meme.find.yields(null, expectedModels);
  var req = { params: { } };
  var res = {
    send: sinon.stub()
  };

  routes.allMemes(req, res);

  sinon.assert.calledWith(res.send, expectedModels);
});

这样做的好处是你可以减少测试中需要的编码,并且使得维护更加坚定。例如,如果我们想在模型中增加一些新的东西,我们只需要更新工厂函数,而不用去修改许许多多的测试代码。

结论

当我们第一次运用像Mongoose 这样的库去做单元测试的时候,看起比较复杂,但是当你学会基础的应用后,你会发现都是在重复同一种模式。

当用它们去测试模型或者代码时,重要的一点是去确认什么应该被存根。如果是需要与数据库通信的部分,那么你应该这么做。设置存根后你就不用管它们了。

包含这个例子的源代码见 on Github.

想知道更多运用 Sinon.js 的知识从而使各种类型的测试更加简单吗? 看这里grab my free Sinon.js in the Real-world guide – 我认为它把所有有关Sinon.js 最好的内容都包含了。