【问题标题】:How to Unit Test a Node API using Sinon (Express with Mongo DB)如何使用 Sinon 对节点 API 进行单元测试(用 Mongodb 表示)
【发布时间】:2016-11-06 12:57:19
【问题描述】:

我正在使用 Node 创建一个 API,但我很难理解如何正确地对 API 进行单元测试。 API 本身使用 Express 和 Mongo(与 Mongoose)。

到目前为止,我已经能够为 API 端点本身的端到端测试创建集成测试。我使用了 supertest、mocha 和 chai 进行集成测试,以及 dotenv 在运行时使用测试数据库。 npm 测试脚本在集成测试运行之前设置要测试的环境。效果很好。

但我还想为各种组件(例如控制器功能)创建单元测试。

我热衷于使用 Sinon 进行单元测试,但我不知道接下来要采取什么步骤。

我将详细介绍重写为每个人都喜欢的 Todos 的 API 的通用版本。

该应用具有以下目录结构:

api
|- todo
|   |- controller.js
|   |- model.js
|   |- routes.js
|   |- serializer.js
|- test
|   |- integration
|   |  |- todos.js
|   |- unit
|   |  |- todos.js
|- index.js
|- package.json

package.json

{
  "name": "todos",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "directories": {
    "doc": "docs"
  },
  "scripts": {
    "test": "mocha test/unit --recursive",
    "test-int": "NODE_ENV=test mocha test/integration --recursive"
  },
  "author": "",
  "license": "ISC",
  "dependencies": {
    "body-parser": "^1.15.0",
    "express": "^4.13.4",
    "jsonapi-serializer": "^3.1.0",
    "mongoose": "^4.4.13"
  },
  "devDependencies": {
    "chai": "^3.5.0",
    "mocha": "^2.4.5",
    "sinon": "^1.17.4",
    "sinon-as-promised": "^4.0.0",
    "sinon-mongoose": "^1.2.1",
    "supertest": "^1.2.0"
  }
}

index.js

var express = require('express');
var app = express();
var mongoose = require('mongoose');
var bodyParser = require('body-parser');

// Configs
// I really use 'dotenv' package to set config based on environment.
// removed and defaults put in place for brevity
process.env.NODE_ENV = process.env.NODE_ENV || 'development';

// Database
mongoose.connect('mongodb://localhost/todosapi');

//Middleware
app.set('port', 3000);
app.use(bodyParser.urlencoded({extended: true}));
app.use(bodyParser.json());

// Routers
var todosRouter = require('./api/todos/routes');
app.use('/todos', todosRouter);

app.listen(app.get('port'), function() {
    console.log('App now running on http://localhost:' +     app.get('port'));
});

module.exports = app;

serializer.js

(这纯粹是从 Mongo 获取输出并将其序列化为 JsonAPI 格式。所以对于这个例子来说有点多余,但我把它留在了,因为它是我目前在 api 中使用的东西。)

'use strict';

var JSONAPISerializer = require('jsonapi-serializer').Serializer;

module.exports = new JSONAPISerializer('todos', {
    attributes: ['title', '_user']
    ,
    _user: {
        ref: 'id',
        attributes: ['username']
    }
});

routes.js

var router = require('express').Router();
var controller = require('./controller');

router.route('/')
    .get(controller.getAll)
    .post(controller.create);

router.route('/:id')
    .get(controller.getOne)
    .put(controller.update)
    .delete(controller.delete);

module.exports = router;

model.js

var mongoose = require('mongoose');
var Schema = mongoose.Schema;

var todoSchema = new Schema({
    title: {
        type: String
    },

    _user: {
        type: Schema.Types.ObjectId,
        ref: 'User'
    }
});

module.exports = mongoose.model('Todo', todoSchema);

controller.js

var Todo = require('./model');
var TodoSerializer = require('./serializer');

module.exports = {
    getAll: function(req, res, next) {
        Todo.find({})
            .populate('_user', '-password')
            .then(function(data) {
                var todoJson = TodoSerializer.serialize(data);
                res.json(todoJson);
            }, function(err) {
                next(err);
            });
    },

    getOne: function(req, res, next) {
        // I use passport for handling User authentication so assume the user._id is set at this point
        Todo.findOne({'_id': req.params.id, '_user': req.user._id})
            .populate('_user', '-password')
            .then(function(todo) {
                if (!todo) {
                    next(new Error('No todo item found.'));
                } else {
                    var todoJson = TodoSerializer.serialize(todo);
                    return res.json(todoJson);
                }
            }, function(err) {
                next(err);
            });
    },

    create: function(req, res, next) {
        // ...
    },

    update: function(req, res, next) {
        // ...
    },

    delete: function(req, res, next) {
        // ...
    }
};

test/unit/todos.js

var mocha = require('mocha');
var sinon = require('sinon');
require('sinon-as-promised');
require('sinon-mongoose');
var expect = require('chai').expect;
var app = require('../../index');

var TodosModel = require('../../api/todos/model');

describe('Routes: Todos', function() {
  it('getAllTodos', function (done) {
    // What goes here?
  });

  it('getOneTodoForUser', function (done) {
      // What goes here?
  });
});

现在我不想测试路由本身(我在此处未详细说明的集成测试中这样做)。

我目前的想法是,下一个最好的事情是实际对 controller.getAll 或 controller.getOne 函数进行单元测试。然后使用 Sinon 存根模拟通过 Mongoose 对 Mongo 的调用。

但尽管阅读了 sinon 文档,但我不知道下一步该做什么:/

问题

  • 如果控制器功能需要 req、res、next 作为参数,我该如何测试它?
  • 我是否将模型的查找和填充(当前在 Controller 函数中)移动到 todoSchema.static 函数中?
  • 如何模拟填充函数来执行 Mongoose JOIN?
  • 基本上是什么进入test/unit/todos.js 以使上述内容处于可靠的单元测试状态:/

最终目标是运行 mocha test/unit 并让它对该 API 部分的各个部分进行单元测试

【问题讨论】:

  • 查看npm中的supertest模块,用于测试api。
  • @afuous - 感谢您的回复 :) 是的,我使用 Supertest 通过一些集成测试来访问 API 端点本身。这使用了一个专用的测试数据库,并在运行之前将环境设置为测试。我在这里感兴趣的是使用像 Sinon 这样的东西来测试某些功能(例如 Controller 功能)并且不需要依赖项处于活动状态(因此模拟 Mongoose 动作并且实际上不需要数据库处于活动状态)。但是像这样的单元测试对我来说是新的,并且不确定如何在运行的完整环境中隔离测试。
  • @nodenoob,我目前面临同样的问题。我不知道如何对上述表单的控制器特定功能进行单元测试。如果您现在有更好的了解,请与我分享...

标签: node.js unit-testing


【解决方案1】:

您好,我为您创建了一些测试来了解如何使用模拟。

完整示例github/nodejs_unit_tests_example

controller.test.js

const proxyquire = require('proxyquire')
const sinon = require('sinon')
const faker = require('faker')
const assert = require('chai').assert

describe('todo/controller', () => {
  describe('controller', () => {

    let mdl
    let modelStub, serializerStub, populateMethodStub, fakeData
    let fakeSerializedData, fakeError
    let mongoResponse

    before(() => {
      fakeData = faker.helpers.createTransaction()
      fakeError = faker.lorem.word()
      populateMethodStub = {
        populate: sinon.stub().callsFake(() => mongoResponse)
      }
      modelStub = {
        find: sinon.stub().callsFake(() => {
          return populateMethodStub
        }),
        findOne: sinon.stub().callsFake(() => {
          return populateMethodStub
        })
      }

      fakeSerializedData = faker.helpers.createTransaction()
      serializerStub = {
        serialize: sinon.stub().callsFake(() => {
          return fakeSerializedData
        })
      }

      mdl = proxyquire('../todo/controller.js',
        {
          './model': modelStub,
          './serializer': serializerStub
        }
      )
    })

    beforeEach(() => {
      modelStub.find.resetHistory()
      modelStub.findOne.resetHistory()
      populateMethodStub.populate.resetHistory()
      serializerStub.serialize.resetHistory()
    })

    describe('getAll', () => {
      it('should return serialized search result from mongodb', (done) => {
        let resolveFn
        let fakeCallback = new Promise((res, rej) => {
          resolveFn = res
        })
        mongoResponse = Promise.resolve(fakeData)
        let fakeRes = {
          json: sinon.stub().callsFake(() => {
            resolveFn()
          })
        }
        mdl.getAll(null, fakeRes, null)

        fakeCallback.then(() => {
          sinon.assert.calledOnce(modelStub.find)
          sinon.assert.calledWith(modelStub.find, {})

          sinon.assert.calledOnce(populateMethodStub.populate)
          sinon.assert.calledWith(populateMethodStub.populate, '_user', '-password')

          sinon.assert.calledOnce(serializerStub.serialize)
          sinon.assert.calledWith(serializerStub.serialize, fakeData)

          sinon.assert.calledOnce(fakeRes.json)
          sinon.assert.calledWith(fakeRes.json, fakeSerializedData)
          done()
        }).catch(done)
      })

      it('should call next callback if mongo db return exception', (done) => {
        let fakeCallback = (err) => {
          assert.equal(fakeError, err)
          done()
        }
        mongoResponse = Promise.reject(fakeError)
        let fakeRes = sinon.mock()
        mdl.getAll(null, fakeRes, fakeCallback)
      })

    })

    describe('getOne', () => {

      it('should return serialized search result from mongodb', (done) => {
        let resolveFn
        let fakeCallback = new Promise((res, rej) => {
          resolveFn = res
        })
        mongoResponse = Promise.resolve(fakeData)
        let fakeRes = {
          json: sinon.stub().callsFake(() => {
            resolveFn()
          })
        }

        let fakeReq = {
          params: {
            id: faker.random.number()
          },
          user: {
            _id: faker.random.number()
          }
        }
        let findParams = {
          '_id': fakeReq.params.id,
          '_user': fakeReq.user._id
        }
        mdl.getOne(fakeReq, fakeRes, null)

        fakeCallback.then(() => {
          sinon.assert.calledOnce(modelStub.findOne)
          sinon.assert.calledWith(modelStub.findOne, findParams)

          sinon.assert.calledOnce(populateMethodStub.populate)
          sinon.assert.calledWith(populateMethodStub.populate, '_user', '-password')

          sinon.assert.calledOnce(serializerStub.serialize)
          sinon.assert.calledWith(serializerStub.serialize, fakeData)

          sinon.assert.calledOnce(fakeRes.json)
          sinon.assert.calledWith(fakeRes.json, fakeSerializedData)
          done()
        }).catch(done)
      })

      it('should call next callback if mongodb return exception', (done) => {
        let fakeReq = {
          params: {
            id: faker.random.number()
          },
          user: {
            _id: faker.random.number()
          }
        }
        let fakeCallback = (err) => {
          assert.equal(fakeError, err)
          done()
        }
        mongoResponse = Promise.reject(fakeError)
        let fakeRes = sinon.mock()
        mdl.getOne(fakeReq, fakeRes, fakeCallback)
      })

      it('should call next callback with error if mongodb return empty result', (done) => {
        let fakeReq = {
          params: {
            id: faker.random.number()
          },
          user: {
            _id: faker.random.number()
          }
        }
        let expectedError = new Error('No todo item found.')

        let fakeCallback = (err) => {
          assert.equal(expectedError.message, err.message)
          done()
        }

        mongoResponse = Promise.resolve(null)
        let fakeRes = sinon.mock()
        mdl.getOne(fakeReq, fakeRes, fakeCallback)
      })

    })
  })
})

model.test.js

const proxyquire = require('proxyquire')
const sinon = require('sinon')
const faker = require('faker')

describe('todo/model', () => {
  describe('todo schema', () => {
    let mongooseStub, SchemaConstructorSpy
    let ObjectIdFake, mongooseModelSpy, SchemaSpy

    before(() => {
      ObjectIdFake = faker.lorem.word()
      SchemaConstructorSpy = sinon.spy()
      SchemaSpy = sinon.spy()

      class SchemaStub {
        constructor(...args) {
          SchemaConstructorSpy(...args)
          return SchemaSpy
        }
      }

      SchemaStub.Types = {
        ObjectId: ObjectIdFake
      }

      mongooseModelSpy = sinon.spy()
      mongooseStub = {
        "Schema": SchemaStub,
        "model": mongooseModelSpy
      }

      proxyquire('../todo/model.js',
        {
          'mongoose': mongooseStub
        }
      )
    })

    it('should return new Todo model by schema', () => {
      let todoSchema = {
        title: {
          type: String
        },

        _user: {
          type: ObjectIdFake,
          ref: 'User'
        }
      }
      sinon.assert.calledOnce(SchemaConstructorSpy)
      sinon.assert.calledWith(SchemaConstructorSpy, todoSchema)

      sinon.assert.calledOnce(mongooseModelSpy)
      sinon.assert.calledWith(mongooseModelSpy, 'Todo', SchemaSpy)
    })
  })
})

routes.test.js

const proxyquire = require('proxyquire')
const sinon = require('sinon')
const faker = require('faker')

describe('todo/routes', () => {
  describe('router', () => {
    let expressStub, controllerStub, RouterStub, rootRouteStub, idRouterStub

    before(() => {
      rootRouteStub = {
        "get": sinon.stub().callsFake(() => rootRouteStub),
        "post": sinon.stub().callsFake(() => rootRouteStub)
      }
      idRouterStub = {
        "get": sinon.stub().callsFake(() => idRouterStub),
        "put": sinon.stub().callsFake(() => idRouterStub),
        "delete": sinon.stub().callsFake(() => idRouterStub)
      }
      RouterStub = {
        route: sinon.stub().callsFake((route) => {
          if (route === '/:id') {
            return idRouterStub
          }
          return rootRouteStub
        })
      }

      expressStub = {
        Router: sinon.stub().returns(RouterStub)
      }

      controllerStub = {
        getAll: sinon.mock(),
        create: sinon.mock(),
        getOne: sinon.mock(),
        update: sinon.mock(),
        delete: sinon.mock()
      }

      proxyquire('../todo/routes.js',
        {
          'express': expressStub,
          './controller': controllerStub
        }
      )
    })

    it('should map root get router with getAll controller', () => {
      sinon.assert.calledWith(RouterStub.route, '/')
      sinon.assert.calledWith(rootRouteStub.get, controllerStub.getAll)
    })

    it('should map root post router with create controller', () => {
      sinon.assert.calledWith(RouterStub.route, '/')
      sinon.assert.calledWith(rootRouteStub.post, controllerStub.create)
    })

    it('should map /:id get router with getOne controller', () => {
      sinon.assert.calledWith(RouterStub.route, '/:id')
      sinon.assert.calledWith(idRouterStub.get, controllerStub.getOne)
    })

    it('should map /:id put router with update controller', () => {
      sinon.assert.calledWith(RouterStub.route, '/:id')
      sinon.assert.calledWith(idRouterStub.put, controllerStub.update)
    })

    it('should map /:id delete router with delete controller', () => {
      sinon.assert.calledWith(RouterStub.route, '/:id')
      sinon.assert.calledWith(idRouterStub.delete, controllerStub.delete)
    })
  })
})

serializer.test.js

const proxyquire = require('proxyquire')
const sinon = require('sinon')

describe('todo/serializer', () => {
  describe('json serializer', () => {
    let JSONAPISerializerStub, SerializerConstructorSpy

    before(() => {
      SerializerConstructorSpy = sinon.spy()

      class SerializerStub {
        constructor(...args) {
          SerializerConstructorSpy(...args)
        }
      }

      JSONAPISerializerStub = {
        Serializer: SerializerStub
      }

      proxyquire('../todo/serializer.js',
        {
          'jsonapi-serializer': JSONAPISerializerStub
        }
      )
    })

    it('should return new instance of Serializer', () => {
      let schema = {
        attributes: ['title', '_user']
        ,
        _user: {
          ref: 'id',
          attributes: ['username']
        }
      }
      sinon.assert.calledOnce(SerializerConstructorSpy)
      sinon.assert.calledWith(SerializerConstructorSpy, 'todos', schema)
    })
  })
})

【讨论】:

  • 不幸的是,这种方法不再适用于新的 sinon 版本。我还尝试了您的 github 示例,但失败了。现在有更好的方法来测试路由器的网址吗?
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 2016-12-21
  • 2021-03-15
  • 2019-05-24
  • 2020-08-13
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多