跳到内容
b 测试后端应用c 用户管理d 密钥认证

a

从后端结构到测试入门

让我们继续我们在第三章节中开始的笔记应用的后端工作。

Project structure

请注意,本课程材料是使用 Node.js v20.11.0 版本编写的。请确保您的 Node 版本至少与材料中使用的版本一样新(您可以通过在命令行中运行 node -v 来检查版本)。

在我们进入测试主题之前,我们将修改我们项目的结构以遵守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.logconsole.error来打印代码中的不同信息。

然而,这并不是一个很好的方法。

让我们把所有打印到控制台的工作分离到自己的模块utils/logger.js

const info = (...params) => {
  console.log(...params)
}

const error = (...params) => {
  console.error(...params)
}

module.exports = {
  info, error
}

记录器有两个函数,info用于打印正常的日志信息,error用于所有的错误信息。

将日志提取到自己的模块中是一个好主意,而且不止一个方面。如果我们想开始将日志写入文件或将它们发送到外部日志服务,如 graylogpapertrail 我们只需要在一个地方进行修改。

用于启动应用的index.js文件的内容被简化如下。

const app = require('./app') // the actual Express application
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-module的函数info用于控制台打印输出,告诉人们应用正在运行。

对环境变量的处理被提取到一个单独的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相关的路由现在都在controllers目录下的notes.js模块中。

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

  note.save()
    .then(savedNote => {
      response.json(savedNote)
    })
    .catch(error => next(error))
})

notesRouter.delete('/:id', (request, response, next) => {
  Note.findByIdAndDelete(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手册提供了以下解释。

一个路由器对象是一个孤立的中间件和路由实例。你可以把它看作是一个 "小型应用",只能够执行中间件和路由功能。每个Express应用都有一个内置的应用路由器。

路由器实际上是一个中间件,它可以用来在一个地方定义 "相关的路由",它通常被放在自己的模块中。

创建实际应用的app.js文件使用了路由器,如下所示。

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

mongoose.set('strictQuery', false)

logger.info('connecting to', config.MONGODB_URI)

mongoose.connect(config.MONGODB_URI)
  .then(() => {
    logger.info('connected to MongoDB')
  })
  .catch((error) => {
    logger.error('error connecting to MongoDB:', error.message)
  })

app.use(cors())
app.use(express.static('dist'))
app.use(express.json())
app.use(middleware.requestLogger)

app.use('/api/notes', notesRouter)

app.use(middleware.unknownEndpoint)
app.use(middleware.errorHandler)

module.exports = app

该文件使用了不同的中间件,其中一个是连接到/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文件只定义了Mongoose模式的笔记。

const mongoose = require('mongoose')

const noteSchema = new mongoose.Schema({
  content: {
    type: String,
    required: true,
    minlength: 5
  },
  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确实需要一个特定的结构。我们目前的结构只是简单地遵循了一些你可以在网上看到的最佳实践。

你可以在这个Github仓库part4-1分支中找到我们当前应用的全部代码。

如果你为自己克隆了这个项目,在用npm run dev启动应用之前,先运行npm install命令。

Note on exports

我们在这部分使用了两种不同的导出方式。首先,例如,文件utils/logger.js做了如下的导出。

const info = (...params) => {
  console.log(...params)
}

const error = (...params) => {
  console.error(...params)
}

module.exports = {  info, error}

该文件导出了一个对象,该对象有两个字段,都是函数。这些函数可以用两种不同的方式来使用。第一个选项是要求整个对象,并通过对象使用点符号来引用函数。

const logger = require('./utils/logger')

logger.info('message')

logger.error('error message')

另一种方法是在require语句中把函数分解成它自己的变量。

const { info, error } = require('./utils/logger')

info('message')
error('error message')

如果一个文件中只使用了一小部分导出的函数,后者可能是更好的方式。

例如,在文件controller/notes.js中,导出的情况如下。

const notesRouter = require('express').Router()
const Note = require('../models/note')

// ...

module.exports = notesRouter

在这种情况下,只有一个 "东西 "被导出,所以使用它的唯一方法是如下。

const notesRouter = require('./controllers/notes')

// ...

app.use('/api/notes', notesRouter)

现在,导出的 "东西"(在本例中是一个路由器对象)被分配到一个变量中,并按此使用。

Testing Node applications

我们完全忽视了软件开发的一个重要领域,那就是自动化测试。

让我们从单元测试开始我们的测试之旅。我们应用程序的逻辑非常简单,因此没有太多内容可以进行单元测试。让我们创建一个新文件 utils/for_testing.js 并编写一些简单的函数,以便我们可以在测试编写练习中使用:

const reverse = (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 = {
  reverse,
  average,
}

average函数使用数组reduce方法。如果你对这个方法还不熟悉,那么现在是观看Youtube上Functional Javascript系列的前三个视频的好时机

JavaScript 有大量的测试库或“测试运行器”可用。

测试库的旧王者是 Mocha,它在几年前被 Jest 取代。这些库的新人是 Vitest,它自称为新一代测试库。

如今,Node 还有一个内置的测试库 node:test,它非常适合本课程的需求。

让我们为测试执行定义 npm script test

{
  //...
  "scripts": {
    "start": "node index.js",
    "dev": "nodemon index.js",
    "build:ui": "rm -rf build && cd ../frontend/ && npm run build && cp -r build ../backend",
    "deploy": "fly deploy",
    "deploy:full": "npm run build:ui && npm run deploy",
    "logs:prod": "fly logs",
    "lint": "eslint .",
    "test": "node --test"  },
  //...
}

让我们为我们的测试创建一个名为 tests 的单独目录,并创建一个名为 reverse.test.js 的新文件,内容如下:

const { test } = require('node:test')
const assert = require('node:assert')

const reverse = require('../utils/for_testing').reverse

test('reverse of a', () => {
  const result = reverse('a')

  assert.strictEqual(result, 'a')
})

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

  assert.strictEqual(result, 'tcaer')
})

test('reverse of saippuakauppias', () => {
  const result = reverse('saippuakauppias')

  assert.strictEqual(result, 'saippuakauppias')
})

该测试定义了关键字 test 和库 assert,该库由测试用于检查被测函数的结果。

在下一行中,测试文件导入要测试的函数,并将其分配给名为 reverse 的变量:

const reverse = require('../utils/for_testing').reverse

使用 test 函数定义各个测试用例。该函数的第一个参数是作为字符串的测试描述。第二个参数是 function,用于定义测试用例的功能。第二个测试用例的功能如下所示:

() => {
  const result = reverse('react')

  assert.strictEqual(result, 'tcaer')
}

首先,我们执行要测试的代码,这意味着我们为字符串 react 生成一个反转。接下来,我们使用 assert 库的 strictEqual 方法验证结果。

不出所料,所有测试都通过:

npm test 的终端输出,所有测试都通过

库 node:test 默认情况下期望测试文件的文件名包含 test。在本课程中,我们将遵循使用扩展名 test.js 命名测试文件约定的约定。

让我们破坏测试:

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

  assert.strictEqual(result, 'tkaer')
})

运行此测试会导致以下错误消息:

npm test 的终端输出显示失败

让我们将 npm test 的输出与 average 函数放入一个新文件 tests/average.test.js 中。

const { test, describe } = require('node:test')

// ...

const average = require('../utils/for_testing').average

describe('average', () => {
  test('of one value is the value itself', () => {
    assert.strictEqual(average([1]), 1)
  })

  test('of many is calculated right', () => {
    assert.strictEqual(average([1, 2, 3, 4, 5, 6]), 3.5)
  })

  test('of empty array is zero', () => {
    assert.strictEqual(average([]), 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 方法计算平均值。

关于我们刚刚编写的测试需要注意几件事。我们在给定名称为 average 的测试周围定义了一个 describe 块:

describe('average', () => {
  // tests
})

描述块可用于将测试分组到逻辑集合中。测试输出也使用描述块的名称:

npm test 的屏幕截图,显示 describe 块

正如我们稍后将看到的,当我们想要为一组测试运行一些共享的设置或拆卸操作时,describe 块是必需的。

需要注意的另一件事是,我们以非常简洁的方式编写了测试,没有将被测函数的输出分配给变量:

test('of empty array is zero', () => {
  assert.strictEqual(average([]), 0)
})