Web全栈开发学习笔记—Part4 测试 Express 服务端程序, 以及用户管理—a.从后端结构到测试入门

目录

Project structure

Testing Node applications

 

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匹配器。

所有的测试都通过了:

fullstack content

Jest 默认情况下希望测试文件的名称包含 .test. 遵循将测试文件命名为扩展名 .test.js的约定。

Jest有友好的错误消息,让我们破坏测试来演示一下:

test('palindrom of react', () => {
  const result = palindrome('react')

  expect(result).toBe('tkaer')
})

运行上面的测试会产生如下错误消息:

fullstack content

在一个新文件 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 ) :

fullstack content

修复这个函数:

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
})

描述块可用于将测试分组为逻辑集合:

fullstack content

猜你喜欢

转载自blog.csdn.net/qq_39389123/article/details/112255007
今日推荐