How to Unit Test a Node API using Sinon (Express with Mongo DB)
我正在使用Node创建API,但正在努力了解如何正确地对API进行单元测试。 API本身使用Express和Mongo(带有Mongoose)。
到目前为止,我已经能够创建集成测试,以对API端点本身进行端到端测试。我已经使用了supertest,mocha和chai进行集成测试,并使用dotenv在运行它时使用测试数据库。 npm测试脚本将环境设置为在集成测试运行之前进行测试。它表现出色。
但是我还想为各种组件(例如控制器功能)创建单元测试。
我热衷于使用Sinon进行单元测试,但是我很难知道下一步该怎么做。
我将详细介绍API的通用版本,将其重写为每个人都喜欢的Todos。
该应用具有以下目录结构:
1 2 3 4 5 6 7 8 9 10 11 12 13 | api |- todo | |- controller.js | |- model.js | |- routes.js | |- serializer.js |- test | |- integration | | |- todos.js | |- unit | | |- todos.js |- index.js |- package.json |
package.json
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 | { "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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 | 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中使用的东西。)
1 2 3 4 5 6 7 8 9 10 11 12 | 'use strict'; var JSONAPISerializer = require('jsonapi-serializer').Serializer; module.exports = new JSONAPISerializer('todos', { attributes: ['title', '_user'] , _user: { ref: 'id', attributes: ['username'] } }); |
routes.js
1 2 3 4 5 6 7 8 9 10 11 12 13 | 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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | 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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 | 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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | 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文档,我仍然不知道下一步该怎么做:/
问题
- 如果要求参数re,res和next作为参数,如何测试控制器功能?
- 是否将模型的查找内容(当前在Controller函数中)移动到todoSchema.static函数中?
- 如何模拟填充函数来执行Mongoose JOIN?
-
基本上,进入
test/unit/todos.js 的内容可以在稳固的单元测试状态下达到上述要求:/
最终目标是运行
嗨,我已经为您创建了一些测试,以了解如何使用模拟程序。
完整示例github / nodejs_unit_tests_example
controller.test.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 | 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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 | 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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 | 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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 | 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) }) }) }) |