跳到内容

b

测试后端应用

我们现在将开始为后端编写测试。因为后端不包含任何复杂的逻辑,所以为它写单元测试没有意义。我们唯一可能进行单元测试的是用于格式化笔记的toJSON方法。

在某些情况下,通过模拟数据库而不是使用真正的数据库来实现一些后端测试是有益的。一个可以用于此的库是mongodb-memory-server

由于我们的应用的后端仍然相对简单,我们将决定通过其REST API测试整个应用,这样数据库也包括在内。这种将系统的多个组件作为一个整体进行测试的测试,被称为集成测试

Test environment

在课程材料的前几章中,我们提到当后端服务器在 Fly.io 或 Render 中运行时,它处于production(生产)模式。

Node中的惯例是用NODE_ENV环境变量来定义应用的执行模式。在我们当前的应用中,如果应用不是在生产模式下,我们只加载.env文件中定义的环境变量。

通常的做法是为开发和测试定义不同的模式。

接下来,让我们改变package.json中的脚本,以便当测试运行时,NODE_ENV获得test值。

{
  // ...
  "scripts": {
    "start": "NODE_ENV=production node index.js",    "dev": "NODE_ENV=development nodemon index.js",    "test": "NODE_ENV=test node --test",    "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 .",
  },
  // ...
}

我们还在执行测试的npm脚本中加入了runInBand选项。这个选项将阻止Jest并行运行测试;一旦我们的测试开始使用数据库,我们将讨论其意义。

我们在使用nodemon的npm run dev脚本中指定应用的模式为development。我们还指定默认的npm start命令将定义模式为production

我们在脚本中指定应用模式的方式有一个小问题:它在Windows上将无法工作。我们可以通过安装cross-env包作为开发依赖的命令来纠正这个问题。

npm install --save-dev cross-env

然后我们可以通过在package.json中定义的npm脚本中使用cross-env库来实现跨平台兼容。

{
  // ...
  "scripts": {
    "start": "cross-env NODE_ENV=production node index.js",
    "dev": "cross-env NODE_ENV=development nodemon index.js",
    // ...
    "test": "cross-env NODE_ENV=test jest --verbose --runInBand",
  },
  // ...
}

*nb:如果你要把这个应用部署到heroku,请记住,如果cross-env被保存为开发依赖项,它将在你的Web服务器上引起应用错误。为了解决这个问题,通过在命令行中运行这个命令,将cross-env改为生产依赖关系。

npm i cross-env -P

现在我们可以修改我们的应用在不同模式下的运行方式。作为一个例子,我们可以定义应用在运行测试时使用一个单独的测试数据库。

我们可以在Mongo DB Atlas中创建我们单独的测试数据库。在有很多人开发同一个应用的情况下,这不是一个最佳解决方案。特别是测试执行,通常需要一个数据库实例不被同时运行的测试所使用。

最好是使用安装在开发者本地机器上运行的数据库来运行我们的测试。最佳的解决方案是让每个测试执行都使用它自己的独立数据库。通过在内存中运行Mongo或使用Docker容器,这是 "相对简单 "的实现。我们不会将事情复杂化,而是继续使用MongoDB Atlas数据库。

让我们对定义应用配置的模块做一些修改。

require('dotenv').config()

const PORT = process.env.PORT

const MONGODB_URI = process.env.NODE_ENV === 'test'  ? process.env.TEST_MONGODB_URI  : process.env.MONGODB_URI
module.exports = {
  MONGODB_URI,
  PORT
}

.env 文件中有独立的变量,用于开发和测试数据库的数据库地址。

MONGODB_URI=mongodb+srv://fullstack:thepasswordishere@cluster0.o1opl.mongodb.net/noteApp?retryWrites=true&w=majority
PORT=3001

TEST_MONGODB_URI=mongodb+srv://fullstack:thepasswordishere@cluster0.o1opl.mongodb.net/testNoteApp?retryWrites=true&w=majority

我们实现的config模块与node-config包略有相似。编写我们自己的实现是合理的,因为我们的应用很简单,同时也因为它给我们带来了宝贵的经验。

这些是我们需要对我们的应用的代码进行的唯一修改。

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

supertest

让我们使用supertest包来帮助我们编写测试API的测试。

我们将把这个包作为开发依赖项来安装。

npm install --save-dev supertest

让我们在tests/note_api.test.js文件中编写第一个测试。

const { test, after } = require('node:test')
const mongoose = require('mongoose')
const supertest = require('supertest')
const app = require('../app')

const api = supertest(app)

test('notes are returned as json', async () => {
  await api
    .get('/api/notes')
    .expect(200)
    .expect('Content-Type', /application\/json/)
})

after(async () => {
  await mongoose.connection.close()
})

该测试从 app.js 模块中导入 Express 应用程序,并用 supertest 函数将其封装到一个所谓的 superagent 对象中。该对象分配给 api 变量,测试可以使用它向后端发出 HTTP 请求。

我们的测试向 api/notes url 发出 HTTP GET 请求,并验证请求是否已使用状态代码 200 作出响应。测试还验证 Content-Type 标头是否设置为 application/json,表示数据采用所需格式。

检查标头值使用一个看起来有点奇怪的语法:

.expect('Content-Type', /application\/json/)

期望值现在定义为 正则表达式,简称 regex。regex 以斜杠 / 开始和结束,因为期望字符串 application/json 也包含相同的斜杠,因此在其之前添加一个 \,这样就不会将其解释为 regex 终止字符。

原则上,测试也可以定义为一个字符串:

.expect('Content-Type', 'application/json')

但是,此处的问题在于,在使用字符串时,标头的值必须完全相同。对于我们定义的正则表达式,标头 包含 相关字符串是可以接受的。标头的实际值为 application/json; charset=utf-8,即它还包含有关字符编码的信息。但是,我们的测试对此不感兴趣,因此最好将测试定义为正则表达式,而不是确切的字符串。

该测试包含一些我们将在 稍后 探讨的详细信息。定义测试的箭头函数前有async关键字,而api对象的函数调用前有await关键字。我们将编写一些测试,然后仔细了解此async/await的魔力。现在不必担心它们,只要确保示例测试正确工作即可。async/await语法与向API发出请求是异步操作的事实有关。async/await语法可用于编写异步代码,使其看起来像同步代码。

一旦所有测试(当前只有一个)运行完毕,我们必须关闭Mongoose使用的数据库连接。可以使用after方法轻松实现此目的:

after(async () => {
  await mongoose.connection.close()
})

一个微小但重要的细节:在本部分的 开头,我们将Express应用程序提取到app.js文件中,而index.js文件的作用已更改为通过app.listen在指定端口启动应用程序:

const app = require('./app') // the actual Express app
const config = require('./utils/config')
const logger = require('./utils/logger')

app.listen(config.PORT, () => {
  logger.info(`Server running on port ${config.PORT}`)
})

测试只使用app.js文件中定义的Express应用。

const mongoose = require('mongoose')
const supertest = require('supertest')
const app = require('../app')
const api = supertest(app)
// ...

supertest的文档说如下:

如果服务器还没有监听连接,那么它就会为你绑定一个短暂的端口,所以不需要跟踪端口。

换句话说,supertest注意到被测试的应用是在它内部使用的端口启动的。

让我们再写几个测试:

test('there are two notes', async () => {
  const response = await api.get('/api/notes')

  assert.strictEqual(response.body.length, 2)
})

test('the first note is about HTTP methods', async () => {
  const response = await api.get('/api/notes')

  const contents = response.body.map(e => e.content)
  assert.strictEqual(contents.includes('HTML is easy'), true)
})

这两个测试都将请求的响应存储在 response 变量中,并且与使用 supertest 提供的方法来验证状态代码和标头的前一个测试不同,这次我们正在检查存储在 response.body 属性中的响应数据。我们的测试使用 assert-library 的 strictEqual 方法验证响应数据的格式和内容。

我们可以稍微简化第二个测试,并使用 assert 本身来验证该笔记属于返回的笔记之一:

test('the first note is about HTTP methods', async () => {
  const response = await api.get('/api/notes')

  const contents = response.body.map(e => e.content)
  // is the parameter truthy
  assert(contents.includes('HTML is easy'))
})

使用async/await语法的好处开始变得明显了。通常情况下,我们必须使用回调函数来访问由 promise 返回的数据,但有了新的语法,事情就好办多了。

const response = await api.get('/api/notes')

// execution gets here only after the HTTP request is complete
// the result of HTTP request is saved in variable response
assert.strictEqual(response.body.length, 2)

输出HTTP请求信息的中间件阻碍了测试执行的输出。让我们修改记录器,使其在测试模式下不打印到控制台。

const info = (...params) => {
  if (process.env.NODE_ENV !== 'test') {    console.log(...params)  }}

const error = (...params) => {
  if (process.env.NODE_ENV !== 'test') {    console.error(...params)  }}

module.exports = {
  info, error
}

Initializing the database before tests

测试看起来很简单,我们的测试目前通过。但是,我们的测试很糟糕,因为它们依赖于数据库的状态,而现在恰好有两个笔记。为了使我们的测试更加稳健,我们必须在运行测试之前以一种可控的方式重置数据库并生成所需的测试数据。

我们的测试已经使用了 after 函数在测试执行完成后关闭与数据库的连接。Node:test 库提供了许多其他函数,可用于在任何测试运行之前或每次在测试运行之前执行操作。

让我们使用 beforeEach 函数在每次测试之前初始化数据库:

const { test, after, beforeEach } = require('node:test')const Note = require('../models/note')
const initialNotes = [  {    content: 'HTML is easy',    important: false,  },  {    content: 'Browser can execute only JavaScript',    important: true,  },]
// ...

beforeEach(async () => {  await Note.deleteMany({})  let noteObject = new Note(initialNotes[0])  await noteObject.save()  noteObject = new Note(initialNotes[1])  await noteObject.save()})// ...

数据库在开始时被清空,之后我们将存储在 initialNotes 数组中的两个笔记保存到数据库中。这样做,我们确保数据库在每次测试运行前处于相同的状态。

让我们也对最后两个测试做如下修改。

test('there are two notes', async () => {
  const response = await api.get('/api/notes')

  assert.strictEqual(response.body.length, initialNotes.length)
})

test('the first note is about HTTP methods', async () => {
  const response = await api.get('/api/notes')

  const contents = response.body.map(e => e.content)
  assert(contents.includes('HTML is easy'))
})

Running tests one by one

npm test 命令将执行应用程序的所有测试。当我们编写测试时,通常明智的做法是只执行一两个测试。

有几种不同的方法可以实现此目的,其中之一是 only 方法。使用该方法,我们可以在代码中定义应执行哪些测试:

test.only('notes are returned as json', async () => {
  await api
    .get('/api/notes')
    .expect(200)
    .expect('Content-Type', /application\/json/)
})

test.only('there are two notes', async () => {
  const response = await api.get('/api/notes')

  assert.strictEqual(response.body.length, 2)
})

当使用选项 --test-only 运行测试时,即使用命令

npm test -- --test-only

只有标记为 only 的测试才会被执行。

only 的危险在于人们忘记从代码中删除它们。

另一种选择是将需要运行的测试指定为 npm test 命令的参数。

以下命令只运行 tests/note_api.test.js 文件中找到的测试:

npm test -- tests/note_api.test.js

--tests-by-name-pattern 选项可用于运行具有特定名称的测试:

npm test -- --test-name-pattern="the first note is about HTTP methods"

提供的参数可以引用测试的名称或 describe 块。该参数也可以只包含名称的一部分。以下命令将运行所有名称中包含 notes 的测试:

npm run test -- --test-name-pattern="notes"

async/await

在我们写更多的测试之前,让我们看一下asyncawait关键字。

ES7中引入的async/await语法使得使用返回 promise 的异步函数的方式可以使代码看起来是同步的。

作为一个例子,用 promise 从数据库中获取笔记的过程看起来是这样的。

Note.find({}).then(notes => {
  console.log('operation returned the following notes', notes)
})

Note.find()方法返回一个 promise ,我们可以通过用then方法注册一个回调函数来访问操作的结果。

一旦操作完成,我们想要执行的所有代码都写在回调函数中。如果我们想依次进行几个异步函数的调用,情况很快就会变得很痛苦。异步调用将不得不在回调中进行。这将可能导致复杂的代码,并有可能诞生所谓的回调地狱

通过链式 promise ,我们可以在一定程度上控制局面,并通过创建一个相当干净的then方法调用链来避免回调地狱。我们在课程中已经看到了一些这样的情况。为了说明这一点,你可以查看一个人为的例子,这个函数获取了所有的笔记,然后删除了第一条。

Note.find({})
  .then(notes => {
    return notes[0].remove()
  })
  .then(response => {
    console.log('the first note is removed')
    // more code here
  })

然后链是好的,但我们可以做得更好。ES6中引入的生成器函数提供了一种聪明的方法,将异步代码写得 "看起来是同步的"。该语法有点笨重,没有得到广泛使用。

ES7中引入的asyncawait关键字带来了与生成器相同的功能,但以一种可理解的、语法上更简洁的方式送到了JavaScript世界所有公民的手中。

我们可以通过利用await操作符来获取数据库中的所有笔记,像这样。

const notes = await Note.find({})

console.log('operation returned the following notes', notes)

这段代码看起来和同步代码完全一样。代码的执行在const notes = await Note.find({})处暂停,等待相关的 promise 被满足,然后继续执行到下一行。当继续执行时,返回 promise 的操作结果被分配给notes变量。

上面介绍的稍微复杂的例子可以通过使用await这样来实现。

const notes = await Note.find({})
const response = await notes[0].remove()

console.log('the first note is removed')

由于新的语法,代码比以前的then-chain简单多了。

使用async/await语法时,有几个重要的细节需要注意。为了在异步操作中使用await操作符,它们必须返回一个 promise 。这并不是一个问题,因为使用回调的常规异步函数很容易被 promise 所包裹。

await关键字不能在JavaScript代码中随便使用。只有在async函数中才能使用await。

这意味着,为了使前面的例子能够工作,它们必须使用异步函数。注意箭头函数定义中的第一行。

const main = async () => {  const notes = await Note.find({})
  console.log('operation returned the following notes', notes)

  const response = await notes[0].remove()
  console.log('the first note is removed')
}

main()

该代码声明分配给main的函数是异步的。在这之后,代码用main()调用该函数。

async/await in the backend

让我们开始把后端改成异步和await。由于目前所有的异步操作都是在一个函数内完成的,所以只需将路由处理函数改为异步函数即可。

获取所有笔记的路由被改成如下:

notesRouter.get('/', async (request, response) => {
  const notes = await Note.find({})
  response.json(notes)
})

我们可以通过浏览器测试端点和运行我们之前写的测试来验证我们的重构是否成功。

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

More tests and refactoring the backend

当代码被重构时,总是有(regression)回归的风险,这意味着现有的功能可能被破坏。让我们先为API的每条路线写一个测试,来重构剩下的操作。

让我们从添加一个新笔记的操作开始。让我们写一个测试,添加一个新的笔记,并验证API返回的笔记数量是否增加,以及新添加的笔记是否在列表中。

test('a valid note can be added ', async () => {
  const newNote = {
    content: 'async/await simplifies making async calls',
    important: true,
  }

  await api
    .post('/api/notes')
    .send(newNote)
    .expect(201)
    .expect('Content-Type', /application\/json/)

  const response = await api.get('/api/notes')

  const contents = response.body.map(r => r.content)

  assert.strictEqual(response.body.length, initialNotes.length + 1)

  assert(contents.includes('async/await simplifies making async calls'))
})

测试实际上是失败的,因为当一个新的笔记被创建时,我们意外地返回状态代码200 OK。让我们把它改为201 CREATED

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.status(201).json(savedNote)    })
    .catch(error => next(error))
})

我们也写一个测试,验证一个没有内容的笔记不会被保存到数据库。

test('note without content is not added', async () => {
  const newNote = {
    important: true
  }

  await api
    .post('/api/notes')
    .send(newNote)
    .expect(400)

  const response = await api.get('/api/notes')

  assert.strictEqual(response.body.length, initialNotes.length)
})

这两个测试都是通过获取应用的所有笔记,来检查保存操作后存储在数据库的状态。

const response = await api.get('/api/notes')

同样的验证步骤将在以后的其他测试中重复出现,将这些步骤提取到辅助函数中是个好主意。让我们把这个函数添加到一个新的文件中,叫做tests/test_helper.js,与测试文件在同一目录下。

const Note = require('../models/note')

const initialNotes = [
  {
    content: 'HTML is easy',
    important: false
  },
  {
    content: 'Browser can execute only JavaScript',
    important: true
  }
]

const nonExistingId = async () => {
  const note = new Note({ content: 'willremovethissoon' })
  await note.save()
  await note.deleteOne()

  return note._id.toString()
}

const notesInDb = async () => {
  const notes = await Note.find({})
  return notes.map(note => note.toJSON())
}

module.exports = {
  initialNotes, nonExistingId, notesInDb
}

该模块定义了 notesInDb 函数,可用于检查存储在数据库中的笔记。包含初始数据库状态的 initialNotes 数组也在该模块中。我们还提前定义了 nonExistingId 函数,它可以用来创建一个不属于数据库中任何笔记对象的数据库对象ID。

我们的测试现在可以使用helper模块,并进行如下更改:

const { test, after, beforeEach } = require('node:test')
const assert = require('node:assert')
const supertest = require('supertest')
const mongoose = require('mongoose')
const helper = require('./test_helper')const app = require('../app')
const api = supertest(app)

const Note = require('../models/note')

beforeEach(async () => {
  await Note.deleteMany({})

  let noteObject = new Note(helper.initialNotes[0])  await noteObject.save()

  noteObject = new Note(helper.initialNotes[1])  await noteObject.save()
})

test('notes are returned as json', async () => {
  await api
    .get('/api/notes')
    .expect(200)
    .expect('Content-Type', /application\/json/)
})

test('all notes are returned', async () => {
  const response = await api.get('/api/notes')

   assert.strictEqual(response.body.length, helper.initialNotes.length)})

test('a specific note is within the returned notes', async () => {
  const response = await api.get('/api/notes')

  const contents = response.body.map(r => r.content)

  assert(contents.includes('Browser can execute only JavaScript'))
})

test('a valid note can be added ', async () => {
  const newNote = {
    content: 'async/await simplifies making async calls',
    important: true,
  }

  await api
    .post('/api/notes')
    .send(newNote)
    .expect(201)
    .expect('Content-Type', /application\/json/)

  const notesAtEnd = await helper.notesInDb()  assert.strictEqual(notesAtEnd.length, helper.initialNotes.length + 1)
  const contents = notesAtEnd.map(n => n.content)  assert(contents.includes('async/await simplifies making async calls'))
})

test('note without content is not added', async () => {
  const newNote = {
    important: true
  }

  await api
    .post('/api/notes')
    .send(newNote)
    .expect(400)

  const notesAtEnd = await helper.notesInDb()
  assert.strictEqual(notesAtEnd.length, helper.initialNotes.length)})

after(async () => {
  await mongoose.connection.close()
})

使用 promise 的代码有效并测试通过。我们已准备好重构我们的代码以使用 async/await 语法。

我们对负责添加新note的代码进行了以下更改(注意路由处理程序的定义前面有 async 关键字)。

notesRouter.post('/', async (request, response, next) => {
  const body = request.body

  const note = new Note({
    content: body.content,
    important: body.important || false,
  })

  const savedNote = await note.save()
  response.status(201).json(savedNote)
})

我们的代码有一个小问题:我们没有处理错误情况。我们应该如何处理它们呢?

Error handling and async/await

如果在处理POST请求时出现了异常,我们就会陷入一个熟悉的情况。

fullstack content

换句话说,我们最终会得到一个未经处理的 promise 拒绝,并且请求永远不会收到响应。

使用 async/await 时,处理异常的推荐方式是古老而熟悉的 try/catch 机制。

notesRouter.post('/', async (request, response, next) => {
  const body = request.body

  const note = new Note({
    content: body.content,
    important: body.important || false,
  })
  try {    const savedNote = await note.save()    response.status(201).json(savedNote)  } catch(exception) {    next(exception)  }})

catch 块简单地调用 next 函数,它将请求处理传递给错误处理中间件。

在做了这个改变之后,我们所有的测试将再次通过。

接下来,让我们编写获取和删除单个笔记的测试。

test('a specific note can be viewed', async () => {
  const notesAtStart = await helper.notesInDb()

  const noteToView = notesAtStart[0]

  const resultNote = await api    .get(`/api/notes/${noteToView.id}`)    .expect(200)    .expect('Content-Type', /application\/json/)
  assert.deepStrictEqual(resultNote.body, noteToView)
})

test('a note can be deleted', async () => {
  const notesAtStart = await helper.notesInDb()
  const noteToDelete = notesAtStart[0]

  await api    .delete(`/api/notes/${noteToDelete.id}`)    .expect(204)
  const notesAtEnd = await helper.notesInDb()

  const contents = notesAtEnd.map(r => r.content)
  assert(!contents.includes(noteToDelete.content))

  assert.strictEqual(notesAtEnd.length, helper.initialNotes.length - 1)
})

两个测试都有一个类似的结构。在初始化阶段,它们从数据库中获取一个笔记。之后,测试调用被测试的实际操作,这在代码块中被强调。最后,测试验证操作的结果是否符合预期。

在第一个测试中有一点值得注意。它使用了方法 deepStrictEqual,而不是之前使用的方法 strictEqual

assert.deepStrictEqual(resultNote.body, noteToView)

这是因为 strictEqual 使用方法 Object.is 来比较相似性,即它比较对象是否相同。在我们的例子中,检查对象的內容(即其字段的值)是否相同就足够了。为此,deepStrictEqual 是合适的。

测试通过,我们可以安全地重构已测试的路由以使用 async/await。

notesRouter.get('/:id', async (request, response, next) => {
  try {
    const note = await Note.findById(request.params.id)
    if (note) {
      response.json(note)
    } else {
      response.status(404).end()
    }
  } catch(exception) {
    next(exception)
  }
})

notesRouter.delete('/:id', async (request, response, next) => {
  try {
    await Note.findByIdAndDelete(request.params.id)
    response.status(204).end()
  } catch(exception) {
    next(exception)
  }
})

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

Eliminating the try-catch

Async/await使代码更加简洁,但其代价是捕捉异常所需的try/catch结构。

所有的路由处理程序都遵循相同的结构

try {
  // do the async operations here
} catch(exception) {
  next(exception)
}

人们开始怀疑,是否有可能重构代码以消除方法中的catch

express-async-errors库对此有一个解决方案。

让我们安装这个库

npm install express-async-errors

使用这个库 非常 容易。

app.js中引入该库。

const config = require('./utils/config')
const express = require('express')
require('express-async-errors')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')

// ...

module.exports = app

该库的"magic"使我们可以完全消除try-catch块。

例如,删除一个笔记的路由

notesRouter.delete('/:id', async (request, response, next) => {
  try {
    await Note.findByIdAndDelete(request.params.id)
    response.status(204).end()
  } catch (exception) {
    next(exception)
  }
})

变成

notesRouter.delete('/:id', async (request, response) => {
  await Note.findByIdAndDelete(request.params.id)
  response.status(204).end()
})

因为有了这个库,我们不再需要next(exception)的调用。

库处理了引擎盖下的一切。如果在async路由中发生异常,执行会自动传递给错误处理中间件。

其他路由成为:

notesRouter.post('/', async (request, response) => {
  const body = request.body

  const note = new Note({
    content: body.content,
    important: body.important || false,
  })

  const savedNote = await note.save()
  response.status(201).json(savedNote)
})

notesRouter.get('/:id', async (request, response) => {
  const note = await Note.findById(request.params.id)
  if (note) {
    response.json(note)
  } else {
    response.status(404).end()
  }
})

Optimizing the beforeEach function

让我们回到编写我们的测试,仔细看看设置测试的 beforeEach 函数:

beforeEach(async () => {
  await Note.deleteMany({})

  let noteObject = new Note(helper.initialNotes[0])
  await noteObject.save()

  noteObject = new Note(helper.initialNotes[1])
  await noteObject.save()
})

该函数通过两个独立的操作将 helper.initialNotes 数组中的前两个笔记保存到数据库中。这个方案还不错,但有一个更好的方法来保存多个对象到数据库:

beforeEach(async () => {
  await Note.deleteMany({})
  console.log('cleared')

  helper.initialNotes.forEach(async (note) => {
    let noteObject = new Note(note)
    await noteObject.save()
    console.log('saved')
  })
  console.log('done')
})

test('notes are returned as json', async () => {
  console.log('entered test')
  // ...
}

我们在一个forEach循环中把存储在数组中的笔记保存到数据库中。然而,这些测试似乎并不奏效,所以我们添加了一些控制台日志来帮助我们找到问题所在。

控制台显示以下输出。


cleared
done
entered test
saved
saved

尽管我们使用了async/await语法,我们的解决方案并没有像我们预期的那样工作。测试执行在数据库初始化之前就开始了!

问题是forEach循环的每个迭代都会产生自己的异步操作,而 beforeEach 不会等待它们执行完毕。换句话说,在 forEach 循环内部定义的 await 命令不在 beforeEach 函数中,而是在 beforeEach 不会等待的独立函数中。

由于测试的执行是在 beforeEach 完成执行之后立即开始的,因此测试的执行在初始化数据库状态之前就开始了。

解决这个问题的一个方法是用Promise.all方法来等待所有的异步操作执行完毕。

beforeEach(async () => {
  await Note.deleteMany({})

  const noteObjects = helper.initialNotes
    .map(note => new Note(note))
  const promiseArray = noteObjects.map(note => note.save())
  await Promise.all(promiseArray)
})

尽管外观紧凑,但该解决方案非常先进。 noteObjects变量被分配给一个Mongoose对象数组,这些对象是用Note构造函数为helper.initialNotes数组中的每个笔记创建的。下一行代码创建了一个新的数组,由 promise 组成,这些 promise 是通过调用noteObjects数组中每个项目的save方法创建的。换句话说,它是一个 promise 数组,用于将每个项目保存到数据库中。

Promise.all方法可用于将 promise 数组转换为单个 promise,一旦解析作为参数传递给它的数组中的每个 promise,该 promise 就会被 fulfilled。最后一行代码await Promise.all(promiseArray)等待每个保存笔记的 promise 完成,这意味着数据库已经初始化。

使用 Promise.all 方法时,数组中每个 promise 的返回值仍然可以被访问。如果我们用 await 语法 const results = await Promise.all(promiseArray) 来等待 promise 的解析,该操作将返回一个数组,其中包含 promiseArray 中每个 promise 的解析值,并且它们以与数组中 promise 相同的顺序显示。

Promise.all以并行方式执行它收到的promise。如果这些promise需要以特定的顺序执行,这将是有问题的。在这样的情况下,可以在for...of块内执行操作,这样可以保证一个特定的执行顺序。

beforeEach(async () => {
  await Note.deleteMany({})

  for (let note of helper.initialNotes) {
    let noteObject = new Note(note)
    await noteObject.save()
  }
})

JavaScript的异步性可能会导致令人惊讶的行为,为此,在使用 async/await 语法时,一定要仔细注意。即使该语法使处理 promise 变得更容易,但仍然有必要了解 promise 是如何工作的!

我们应用的代码可以从github,分支part4-5找到。

Refactoring tests

我们的测试覆盖率目前还很欠缺。一些请求,如GET /api/notes/:idDELETE /api/notes/:id在请求被发送时,没有测试无效的id。测试的分组和组织也可以使用一些改进,因为所有的测试都存在于测试文件的同一个 "顶层"。如果我们用describe块来分组相关的测试,测试的可读性会得到改善。

下面是做了一些小改进后的测试文件的例子。

const { test, after, beforeEach, describe } = require('node:test')
const assert = require('node:assert')
const mongoose = require('mongoose')
const supertest = require('supertest')
const app = require('../app')
const api = supertest(app)

const helper = require('./test_helper')

const Note = require('../models/note')

describe('when there is initially some notes saved', () => {
  beforeEach(async () => {
    await Note.deleteMany({})
    await Note.insertMany(helper.initialNotes)
  })

  test('notes are returned as json', async () => {
    await api
      .get('/api/notes')
      .expect(200)
      .expect('Content-Type', /application\/json/)
  })

  test('all notes are returned', async () => {
    const response = await api.get('/api/notes')

    assert.strictEqual(response.body.length, helper.initialNotes.length)
  })

  test('a specific note is within the returned notes', async () => {
    const response = await api.get('/api/notes')

    const contents = response.body.map(r => r.content)
    assert(contents.includes('Browser can execute only JavaScript'))
  })

  describe('viewing a specific note', () => {

    test('succeeds with a valid id', async () => {
      const notesAtStart = await helper.notesInDb()

      const noteToView = notesAtStart[0]

      const resultNote = await api
        .get(`/api/notes/${noteToView.id}`)
        .expect(200)
        .expect('Content-Type', /application\/json/)

      assert.deepStrictEqual(resultNote.body, noteToView)
    })

    test('fails with statuscode 404 if note does not exist', async () => {
      const validNonexistingId = await helper.nonExistingId()

      await api
        .get(`/api/notes/${validNonexistingId}`)
        .expect(404)
    })

    test('fails with statuscode 400 id is invalid', async () => {
      const invalidId = '5a3d5da59070081a82a3445'

      await api
        .get(`/api/notes/${invalidId}`)
        .expect(400)
    })
  })

  describe('addition of a new note', () => {
    test('succeeds with valid data', async () => {
      const newNote = {
        content: 'async/await simplifies making async calls',
        important: true,
      }

      await api
        .post('/api/notes')
        .send(newNote)
        .expect(201)
        .expect('Content-Type', /application\/json/)

      const notesAtEnd = await helper.notesInDb()
      assert.strictEqual(notesAtEnd.length, helper.initialNotes.length + 1)

      const contents = notesAtEnd.map(n => n.content)
      assert(contents.includes('async/await simplifies making async calls'))
    })

    test('fails with status code 400 if data invalid', async () => {
      const newNote = {
        important: true
      }

      await api
        .post('/api/notes')
        .send(newNote)
        .expect(400)

      const notesAtEnd = await helper.notesInDb()

      assert.strictEqual(notesAtEnd.length, helper.initialNotes.length)
    })
  })

  describe('deletion of a note', () => {
    test('succeeds with status code 204 if id is valid', async () => {
      const notesAtStart = await helper.notesInDb()
      const noteToDelete = notesAtStart[0]

      await api
        .delete(`/api/notes/${noteToDelete.id}`)
        .expect(204)

      const notesAtEnd = await helper.notesInDb()

      assert.strictEqual(notesAtEnd.length, helper.initialNotes.length - 1)

      const contents = notesAtEnd.map(r => r.content)
      assert(!contents.includes(noteToDelete.content))
    })
  })
})

after(async () => {
  await mongoose.connection.close()
})

测试输出根据describe块进行分组。

fullstack content

仍有改进的余地,但现在是向前推进的时候了。

这种测试API的方式,即通过HTTP请求和用Mongoose检查数据库,决不是对服务器应用进行API级集成测试的唯一或最佳方式。编写测试没有通用的最佳方式,因为它完全取决于被测试的应用和可用的资源。

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