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

a

从后端结构到测试入门

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

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.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,
    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手册提供了以下解释。

一个路由器对象是一个孤立的中间件和路由实例。你可以把它看作是一个 "小型应用",只能够执行中间件和路由功能。每个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')

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

该文件使用了不同的中间件,其中一个是连接到/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
  },
  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确实需要一个特定的结构。我们目前的结构只是简单地遵循了一些你可以在网上看到的最佳实践。

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

如果你为自己克隆了这个项目,在用npm start启动应用之前,先运行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。在本课程中,我们将使用一个由Facebook开发并在内部使用的测试库,名为jest,它与之前的JavaScript测试库之王Mocha很相似。

Jest是本课程的自然选择,因为它在测试后端时效果很好,而在测试React应用时,它又很出色。

*Windows用户: 如果项目目录的路径包含一个名称中有空格的目录,Jest可能无法工作。

由于测试只在我们的应用的开发过程中执行,我们将用命令安装jest作为开发依赖。

npm install --save-dev jest

让我们定义npm脚本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,并创建一个新文件,叫做reverse.test.js,内容如下。

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

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

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

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

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

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

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

我们在前一部分添加到项目中的ESLint配置产生警告我们测试文件中的testexpect命令,因为配置不允许globals。让我们通过在.eslintrc.js文件的env属性中添加"jest": true来摆脱这些产生警告。

module.exports = {
  'env': {
    'commonjs': true,
    'es2021': true,
    'node': true,
    'jest': true,  },
  'extends': 'eslint:recommended',
  'parserOptions': {
    'ecmaVersion': 12
  },
  "rules": {
    // ...
  },
}

在第一行,测试文件导入了要测试的函数,并将其分配给一个叫做reverse的变量。

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

个别测试案例是用test函数定义的。该函数的第一个参数是测试描述,是一个字符串。第二个参数是一个函数,它定义了测试案例的功能。第二个测试案例的功能如下所示:

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

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

首先我们执行要测试的代码,也就是说,我们为字符串react生成一个反向。接下来我们用expect函数验证结果。Expect将结果值包装成一个提供matcher函数集合的对象,可用于验证结果的正确性。因为在这个测试案例中,我们要比较两个字符串,我们可以使用toBe匹配器。

正如预期,所有的测试都通过了。

fullstack content

Jest默认期望测试文件的名称包含.test。在本课程中,我们将遵循惯例,用扩展名.test.js来命名我们的测试文件。

Jest有很好的错误信息,让我们打破测试来证明这一点。

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

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

运行上面的测试会出现以下错误信息。

fullstack content

让我们为average函数添加一些测试,放入一个新文件tests/average.test.js

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中,除以0的结果是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
})

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

fullstack content

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

另一件要注意的事情是,我们以相当紧凑的方式写了测试,没有把被测试的函数的输出分配给一个变量。

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