目录
Project structure
【项目结构】
进入“测试”之前,修改 Node.js 项目的结构。 优化之后,得到如下结构:
├── index.js
├── app.js
├── build
│ └── ...
├── controllers
│ └── notes.js
├── models
│ └── note.js
├── package-lock.json
├── package.json
├── utils
│ ├── config.js
│ ├── logger.js
│ └── middleware.js
用console.log 和console.error 来打印代码中的变化信息并不是一个很好的方式。
将所有到控制台的打印分离到它自己的模块 utils/logger.js
const info = (...params) => {
console.log(...params)
}
const error = (...params) => {
console.error(...params)
}
module.exports = {
info, error
}
logger有两个功能,info 用于打印正常的日志消息,error 用于所有错误消息。
将日志记录功能提取到一个单独的模块后, 如果将日志写入一个文件,或者将它们发送到一个外部日志服务中,比如 graylog 或者 papertrail ,只需要在一个地方进行修改就可以了。
用于启动应用的index.js 文件的内容简化如下:
const app = require('./app')
const http = require('http')
const config = require('./utils/config')
const logger = require('./utils/logger')
const server = http.createServer(app)
server.listen(config.PORT, () => {
logger.info(`Server running on port ${config.PORT}`)
})
index.js 文件只从 app.js 文件导入实际的应用,然后启动应用。 logger- 模块的功能用于控制台的打印输出,告诉应用的运行状态。
环境变量的处理被提取到一个单独的utils/config.js 文件中:
require('dotenv').config()
const PORT = process.env.PORT
const MONGODB_URI = process.env.MONGODB_URI
module.exports = {
MONGODB_URI,
PORT
}
应用的其他部分可以通过导入配置模块来访问环境变量:
const config = require('./utils/config')
logger.info(`Server running on port ${config.PORT}`)
路由处理程序也被移动到一个专用的模块中。 路由的事件处理程序通常称为controllers, 所有与便笺相关的路由现在都在controllers 目录下的notes.js 模块中定义。
模块的内容如下:
const notesRouter = require('express').Router()
const Note = require('../models/note')
notesRouter.get('/', (request, response) => {
Note.find({}).then(notes => {
response.json(notes)
})
})
notesRouter.get('/:id', (request, response, next) => {
Note.findById(request.params.id)
.then(note => {
if (note) {
response.json(note)
} else {
response.status(404).end()
}
})
.catch(error => next(error))
})
notesRouter.post('/', (request, response, next) => {
const body = request.body
const note = new Note({
content: body.content,
important: body.important || false,
date: new Date()
})
note.save()
.then(savedNote => {
response.json(savedNote)
})
.catch(error => next(error))
})
notesRouter.delete('/:id', (request, response, next) => {
Note.findByIdAndRemove(request.params.id)
.then(() => {
response.status(204).end()
})
.catch(error => next(error))
})
notesRouter.put('/:id', (request, response, next) => {
const body = request.body
const note = {
content: body.content,
important: body.important,
}
Note.findByIdAndUpdate(request.params.id, note, { new: true })
.then(updatedNote => {
response.json(updatedNote)
})
.catch(error => next(error))
})
module.exports = notesRouter
这几乎是我们之前的index.js 文件的完整复制粘贴。
在文件的开始我们创建了一个新的router 对象:
const notesRouter = require('express').Router()
//...
module.exports = notesRouter
该模块将路由导出,该模块的所有消费者可用。现已为路由对象定义了所有路由,与之前应用类似。
注意,路由处理中的路径已缩短:
app.delete('/api/notes/:id', (request, response) => {
在目前的版本中,代码为:
notesRouter.delete('/:id', (request, response) => {
Express手册对路由对象的解释:
A router object is an isolated instance of middleware and routes. You can think of it as a “mini-application,” capable only of performing middleware and routing functions. Every Express application has a built-in app router.
路由对象是中间件和路由的单例。 您可以把它看作是一个“迷你应用” ,只能执行中间件和路由功能。 每个 Express 应用都有一个内置的应用路由。
路由实际上是一个中间件,可用于在某个位置定义“相关路由” ,通常放置在单独的模块中。
下面的app.js 是一个创建实际应用的文件,对路由对象使用use方法,按如下方式使用:
const notesRouter = require('./controllers/notes')
app.use('/api/notes', notesRouter)
如果请求的 URL 以 /api/notes开头,则会使用之前定义的路由。 因此notesRouter 对象只定义路由的相对部分,即空路径/或仅仅定义参数/:id。
在进行了这些更改之后,app.js 文件如下所示:
const config = require('./utils/config')
const express = require('express')
const app = express()
const cors = require('cors')
const notesRouter = require('./controllers/notes')
const middleware = require('./utils/middleware')
const logger = require('./utils/logger')
const mongoose = require('mongoose')
logger.info('connecting to', config.MONGODB_URI)
mongoose.connect(config.MONGODB_URI, { useNewUrlParser: true, useUnifiedTopology: true, useFindAndModify: false, useCreateIndex: true })
.then(() => {
logger.info('connected to MongoDB')
})
.catch((error) => {
logger.error('error connecting to MongoDB:', error.message)
})
app.use(cors())
app.use(express.static('build'))
app.use(express.json())
app.use(middleware.requestLogger)
app.use('/api/notes', notesRouter)
app.use(middleware.unknownEndpoint)
app.use(middleware.errorHandler)
module.exports = app
文件将不同的中间件放到use中,其中之一是附加到 /api/notes 路由的notesRouter。
自定义中间件已经移动到一个新的 utils/middleware.js 模块:
const logger = require('./logger')
const requestLogger = (request, response, next) => {
logger.info('Method:', request.method)
logger.info('Path: ', request.path)
logger.info('Body: ', request.body)
logger.info('---')
next()
}
const unknownEndpoint = (request, response) => {
response.status(404).send({ error: 'unknown endpoint' })
}
const errorHandler = (error, request, response, next) => {
logger.error(error.message)
if (error.name === 'CastError') {
return response.status(400).send({ error: 'malformatted id' })
} else if (error.name === 'ValidationError') {
return response.status(400).json({ error: error.message })
}
next(error)
}
module.exports = {
requestLogger,
unknownEndpoint,
errorHandler
}
建立到数据库连接的部分放在app.js 模块。models 目录下的note.js 文件只为 notes 定义了 Mongoose schema。
const mongoose = require('mongoose')
const noteSchema = new mongoose.Schema({
content: {
type: String,
required: true,
minlength: 5
},
date: {
type: Date,
required: true,
},
important: Boolean,
})
noteSchema.set('toJSON', {
transform: (document, returnedObject) => {
returnedObject.id = returnedObject._id.toString()
delete returnedObject._id
delete returnedObject.__v
}
})
module.exports = mongoose.model('Note', noteSchema)
总结一下,修改后的目录结构如下所示:
├── index.js
├── app.js
├── build
│ └── ...
├── controllers
│ └── notes.js
├── models
│ └── note.js
├── package-lock.json
├── package.json
├── utils
│ ├── config.js
│ ├── logger.js
│ └── middleware.js
对于较小的应用,结构并不重要。 当应用开始增大,就需要建立某种结构,并将应用的不同职责分离到单独的模块中, 这将使开发应用更加容易。
对于 Express 应用,没有严格的目录结构或文件命名原则。 与此相对的,Ruby on Rails 就需要一个特定的结构。 目前的结构只是遵循一些常用的流行实践。
Testing Node applications
【测试 Node 应用】
软件开发的一个重要环节是自动化测试。
从单元测试开始。 我们应用的逻辑非常简单,使用单元测试来进行测试没有多大意义。 创建一个新的文件utils/for_testing.js ,并编写几个简单的函数用于实践测试:
const palindrome = (string) => {
return string
.split('')
.reverse()
.join('')
}
const average = (array) => {
const reducer = (sum, item) => {
return sum + item
}
return array.reduce(reducer, 0) / array.length
}
module.exports = {
palindrome,
average,
}
average 函数使用 array的 reduce方法。 如果你对这个方法还不熟悉,是时候在 Youtube 上观看3个视频了,这3个视频来自Functional Javascript系列。
有许多不同的测试库或者test runner 可用于 JavaScript。 在本课程中,我们将使用一个由 Facebook 内部开发和使用的测试库,这个测试库名为jest ,类似于之前 JavaScript 测试库之王Mocha。 其他替代品也确实存在,比如在某些圈子里受欢迎的ava。
对于本课程来说,Jest 是一个自然的选择,因为它可以很好地测试后端,并且在测试 React 应用时表现出色。
Windows 用户: 如果项目目录的路径所包含的目录名称含有空格, Jest 可能无法工作。
由于测试只在应用开发过程中执行,我们将使用下面的命令安装jest作为开发依赖项:
npm install --save-dev jest
让我们定义npm script test,用 Jest 执行测试,用verbose 样式报告测试执行情况:
{
//...
"scripts": {
"start": "node index.js",
"dev": "nodemon index.js",
"build:ui": "rm -rf build && cd ../../../2/luento/notes && npm run build && cp -r build ../../../3/luento/notes-backend",
"deploy": "git push heroku master",
"deploy:full": "npm run build:ui && git add . && git commit -m uibuild && git push && npm run deploy",
"logs:prod": "heroku logs --tail",
"lint": "eslint .",
"test": "jest --verbose" },
//...
}
Jest 需要指定执行环境为 Node。 可以通过在package.json 的末尾添加如下内容来实现:
{
//...
"jest": {
"testEnvironment": "node"
}
}
或者,Jest 会查找默认名为 jest.config.js的配置文件,在这里我们可以这样定义执行环境:
module.exports = {
testEnvironment: 'node',
};
让测试创建一个名为tests 的单独目录,并创建一个名为palindrome.test.js 的新文件,其内容如下:
const palindrome = require('../utils/for_testing').palindrome
test('palindrome of a', () => {
const result = palindrome('a')
expect(result).toBe('a')
})
test('palindrome of react', () => {
const result = palindrome('react')
expect(result).toBe('tcaer')
})
test('palindrome of releveler', () => {
const result = palindrome('releveler')
expect(result).toBe('releveler')
})
添加到项目中的 ESLint 配置会在测试文件中提示 test 和 expect 命令,因为配置不允许globals。通过在.eslintrc.js 文件的env 属性中添加"jest": true 来消除这些提示。
module.exports = {
"env": {
"commonjs": true
"es6": true,
"node": true,
"jest": true, },
"extends": "eslint:recommended",
"rules": {
// ...
},
};
在第一行,测试文件导入要测试的函数,并将其赋值给一个名为palindrome的变量:
const palindrome = require('../utils/for_testing').palindrome
单个测试用例是用测试函数定义的。 该函数的第一个参数是作为字符串的测试描述。 第二个参数是function,它定义了测试用例的功能。 第二个测试用例的功能如下:
() => {
const result = palindrome('react')
expect(result).toBe('tcaer')
}
执行要测试代码,字符串react 生成一个回文。 接下来用expect函数验证结果。 Expect 将结果值封装到一个对象中,该对象提供一组matcher 函数,可用于验证结果的正确性。 因为在这个测试用例中,我们要比较两个字符串,所以我们可以使用toBe匹配器。
所有的测试都通过了:
Jest 默认情况下希望测试文件的名称包含 .test. 遵循将测试文件命名为扩展名 .test.js的约定。
Jest有友好的错误消息,让我们破坏测试来演示一下:
test('palindrom of react', () => {
const result = palindrome('react')
expect(result).toBe('tkaer')
})
运行上面的测试会产生如下错误消息:
在一个新文件 tests/average.test.js.中添加一些对 average 函数的测试。
const average = require('../utils/for_testing').average
describe('average', () => {
test('of one value is the value itself', () => {
expect(average([1])).toBe(1)
})
test('of many is calculated right', () => {
expect(average([1, 2, 3, 4, 5, 6])).toBe(3.5)
})
test('of empty array is zero', () => {
expect(average([])).toBe(0)
})
})
测试显示,该函数在空数组中不能正常工作(这是因为在 JavaScript 中, 除以零的结果为NaN ) :
修复这个函数:
const average = array => {
const reducer = (sum, item) => {
return sum + item
}
return array.length === 0
? 0
: array.reduce(reducer, 0) / array.length
}
如果数组的长度是0,那么返回0,在所有其他情况下,使用 reduce 方法来计算平均值。
我们在测试周围定义了一个describe 块,它的名字是 average:
describe('average', () => {
// tests
})
描述块可用于将测试分组为逻辑集合: