2023年2月14日

Prisma 测试终极指南:集成测试

集成测试可确保应用程序的各个组件能够正常协作。在本文中,您将了解如何设置测试环境以及如何编写集成测试。

The Ultimate Guide to Testing with Prisma: Integration Testing

目录

简介

到目前为止,本系列文章探讨了如何模拟您的 Prisma Client,并使用模拟的 Prisma Client 对应用程序中小型、独立的部分进行单元测试。

在本系列文章的这一部分,您将告别模拟的 Prisma Client,并针对真实数据库编写集成测试!在本文结束时,您将设置好集成测试环境并为您的 Express API 编写集成测试。

什么是集成测试?

在本系列上一篇文章中,您学习了如何编写单元测试,重点测试小型独立的代码单元,以确保应用程序的最小构建块正常运行。这些测试的目标是测试特定的场景和功能片段,而无需担心底层数据库、外部模块或组件之间的交互。

然而,集成测试则是一种完全不同的思路。这种测试涉及选取应用程序中相关的区域或组件,并确保它们能够正常协作。

diagram of request

上图展示了一个示例场景,其中获取用户的帖子可能需要多次访问数据库,以验证用户是否具有访问 API 或任何帖子的权限,然后才能实际检索数据。

正如上图所示,应用程序的多个组件可能参与处理单个请求或操作。这通常意味着在单个请求或调用过程中,数据库交互会在不同组件之间发生多次。因此,集成测试通常包含一个用于测试的数据库测试环境。

通过对集成测试的简要概述,您现在将开始准备一个运行集成测试的测试环境。

您将使用的技术

前提条件

假设您具备以下知识

在完成以下步骤时,具备以下知识会有所帮助:

  • JavaScript 或 TypeScript 的基础知识
  • Prisma Client 及其功能的基础知识
  • 对 Docker 的基本了解
  • 有使用测试框架的经验

开发环境

为了跟随提供的示例操作,您需要具备以下条件:

本系列大量使用了此 GitHub 仓库。请务必克隆该仓库并切换到 unit-tests 分支,因为该分支是本文的起始点。

克隆仓库

在您的终端中,进入存放项目的目录。在该目录中运行以下命令:

上述命令会将项目克隆到一个名为 express_sample_app 的文件夹中。该仓库的默认分支是 main,因此您需要切换到 unit-tests 分支。

克隆完仓库后,需要采取几个步骤来设置项目。

首先,进入项目目录并安装 node_modules

接下来,在项目的根目录创建 .env 文件:

此文件应包含一个名为 API_SECRET 的变量,其值可以设置为您想要的任何 string,以及一个名为 DATABASE_URL 的变量,该变量暂时可以留空。

.env 文件中,API_SECRET 变量提供了身份验证服务用于加密密码的密钥。在实际应用中,该值应替换为包含数字和字母的长随机字符串。

DATABASE_URL,顾名思义,包含您的数据库 URL。您当前没有也不需要真实的数据库。

在 Docker 容器中设置 Postgres

您准备测试环境的第一件事是使用 Docker Compose 构建一个 Docker 容器,该容器提供一个 Postgres 服务器。这将是您的应用程序在运行集成测试时使用的数据库。

但是,在继续之前,请确保您的机器上已安装并正在运行 Docker。您可以按照此处的步骤在您的机器上设置 Docker。

要开始配置您的 Docker 容器,请在项目的根目录创建一个名为 docker-compose.yml 的新文件:

此文件是您配置容器的地方,它告诉 Docker 如何设置数据库服务器,使用哪个镜像(Docker 镜像是构建容器的详细指令集),以及如何存储容器的数据。

注意:在 docker-compose.yml 文件中可以配置很多内容。您可以在此处找到文档。

您的容器应创建并公开一个 Postgres 服务器。

为此,首先指定您将使用的 Compose 文件格式版本:

此版本号也决定了您将使用的 Docker Engine 版本。3.8 是本文撰写时的最新版本。

接下来,您需要一个用于运行数据库服务器的 service。创建一个名为 db 的新 service,配置如下:

上面添加的配置指定了一个名为 db 的服务,其配置如下:

  • image:定义构建此服务时使用的 Docker 镜像
  • restartalways 选项告知 Docker 在发生任何故障或 Docker 重启时始终重启此服务
  • environment:配置在容器内公开的环境变量
  • ports:指定 Docker 应将您机器的 5432 端口映射到容器的 5432 端口,Postgres 服务器将在此端口运行
  • volumes:指定卷的名称以及在您本地机器上容器将持久化数据的位置

为了完成您的服务配置,您需要告知 Docker 如何配置 volumes 配置中定义的卷的网络。

在您的 docker-compose.yml 文件中添加以下内容,以便告知 Docker 卷应存储在您的本地 Docker 主机机器上(在您的文件系统中):

如果您现在转到终端并导航到项目根目录,您应该能够运行以下命令来启动运行 Postgres 的容器:

Docker Compose running

您的数据库服务器现已可用,并且可以通过 URL postgres://postgres:postgres@localhost:5432 进行访问。

更新您的 .env 文件的 DATABASE_URL 变量,使其指向该 URL 并指定 quotes 作为数据库名称:

为集成测试添加 Vitest 配置文件

在上一篇文章中,您为 Vitest 创建了一个配置文件。该配置文件 vitest.config.unit.ts 专门用于项目中的单元测试。

现在,您将创建第二个名为 vitest.config.integration.ts 的配置文件,您将在其中配置 Vitest 如何运行集成测试。

注意:在本系列中,这些文件会非常相似。取决于您的项目复杂度,像这样拆分配置会带来更明显的益处。

在项目根目录创建一个名为 vitest.config.integration.ts 的新文件:

将以下内容粘贴到该新文件中:

上述代码片段与 vitest.config.unit.ts 的内容基本相同,不同之处在于 test.include glob 指向 src/tests 内的任何 .ts 文件,而不是像单元测试配置那样指向 src 内的任何文件。这意味着您所有的集成测试都应该放在 src 内一个名为 tests 的新文件夹中。

接下来,向这个新配置文件添加另一个键,告诉 Vitest 不要在不同的线程中同时运行多个测试:

这非常重要,因为您的集成测试会与数据库交互并期望特定的数据集。如果多个测试同时运行并与数据库交互,很可能会因意外数据而在测试中引起问题。

同样地,您还需要一种方法在测试之间重置数据库。在此应用程序中,您将在每个测试之间完全清空数据库,以便每个测试都能从一个空白状态开始。

src 中创建一个名为 tests 的新文件夹,并在 tests 中创建一个名为 helpers 的新文件夹:

在该新目录内,创建一个名为 prisma.ts 的文件:

此文件是一个助手文件,它简单地实例化并导出了 Prisma Client。

将以下内容添加到该文件中:

现在在 src/tests/helpers 中创建另一个名为 reset-db.ts 的文件:

此文件是您编写和导出用于重置数据库的函数的地方。

您的数据库只有三个表:TagQuoteUser。编写并导出一个函数,该函数在事务中对这些表中的每一个运行 deleteMany

通过上面编写的文件,您现在有了一种清除数据库的方法。这里要做的最后一件事是在每个集成测试之间实际调用该函数。

一种很好的方法是使用 setup file。您可以配置 Vitest 在运行任何测试之前处理此文件。在这里,您可以使用 Vitest 的生命周期钩子来自定义其行为。

src/tests/helpers 中创建另一个名为 setup.ts 的文件。

您的目标是在每个测试之前重置数据库,以确保您有一个干净的环境。您可以通过在 Vitest 提供的 beforeEach 生命周期函数中运行由 reset-db.ts 导出的函数来实现这一点。

setup.ts 中,使用 beforeEach 在每个测试之间运行您的重置函数:

现在,当您运行测试套件时,src/tests 内所有文件中的每个单独测试都将从一个干净的环境开始。

注意:您可能想知道在特定测试上下文中需要一些数据的情况下如何处理。在您编写的每个单独的测试文件中,您也可以使用这些生命周期函数并按文件自定义行为。后面会展示一个示例。

最后,您现在需要告知 Vitest 有关此 setup 文件,并告诉它在您运行测试时始终运行该文件。

使用以下内容更新 vitest.config.integration.ts

更新单元测试配置

当前,单元测试配置文件也会运行您的集成测试,因为它会搜索 src 内的任何 .ts 文件。

更新 vitest.config.unit.ts 中的配置,以忽略 src/tests 内的文件:

现在您的单元测试和集成测试是完全独立的,只能使用各自的命令来运行。

编写脚本以启动测试环境

到目前为止,您已经构建了以下方法:

  • 在 Docker 容器中启动数据库服务器
  • 使用特定的测试配置运行集成测试
  • 将单元测试与集成测试分开运行

缺少的是一种实际协调创建 Docker 容器和运行集成测试的方法,以确保您的数据库正在运行并可供您的测试环境使用。

为了实现这一点,您将编写一组自定义的 bash 脚本,这些脚本用于启动 Docker 容器,等待服务器准备就绪,然后运行测试。

在项目根目录创建一个名为 scripts 的新目录:

在该目录内,创建一个名为 run-integration.sh 的文件:

在此文件中,您需要执行以下步骤:

  1. 加载 .env 文件中的所有环境变量,以便您可以访问数据库 URL。
  2. 在分离模式下启动 Docker 容器。
  3. 等待数据库服务器可用。
  4. 运行 Prisma 迁移以将您的 Prisma schema 应用到数据库。
  5. 运行您的集成测试。额外好处是,您还可以使用 --ui 标志运行此文件以启动 Vitest 的 GUI 界面

加载您的环境变量

第一步是读取 .env 文件,并在脚本的上下文中使这些变量可用。

scripts 中创建另一个名为 setenv.sh 的文件:

在此文件中,添加以下代码片段:

这将读取您的 .env 文件并导出每个变量,使其在您的脚本中可用。

返回 scripts/run-integration.sh,您现在可以使用此文件并通过 source 命令访问环境变量:

在上面,DIR 变量用于查找 setenv.sh 的相对路径,并使用该路径执行脚本。

在分离模式下启动 Docker 容器

下一步是启动您的 Docker 容器。需要注意的是,您需要在分离模式下启动容器。

通常,当您运行 docker-compose up 时,您的终端将连接到容器的输出,以便您可以看到发生了什么。然而,这会阻止终端执行任何其他操作,直到您停止 Docker 容器。

在分离模式下运行容器允许它在后台运行,从而释放您的终端继续运行命令(例如运行集成测试的命令)。

将以下内容添加到 run-integration.sh 中:

此处,-d 标志表示容器应在分离模式下运行。

让脚本等待数据库服务器准备就绪

在运行 Prisma 迁移和测试之前,您需要确保您的数据库已准备好接受请求。

为此,您将使用一个著名的脚本,名为 wait-for-it.sh。该脚本允许您提供一个 URL 以及一些时序配置,并会让脚本等待给定 URL 的资源可用后才继续执行。

通过运行以下命令将该脚本的内容下载到一个名为 scripts/wait-for-it.sh 的文件中:

警告:如果 wait-for-it.sh 脚本对您无效,请参阅 GitHub 讨论,了解连接到数据库并确保测试成功运行的其他方法。

然后,返回 run-integration.sh 并使用以下内容更新它:

您的脚本现在将等待 DATABASE_URL 环境变量中指定位置的数据库可用后才会继续。

如果您使用的是 Mac,您还需要运行以下命令来安装并别名 wait-for-it.sh 脚本中使用的命令:

准备数据库并运行测试

最后两个步骤现在可以安全地执行了。

运行 wait-for-it 脚本后,运行 Prisma 迁移以将任何新更改应用到数据库:

然后,为了完成所有这些操作,添加以下语句来运行您的集成测试:

注意此处使用的 if/else 语句。这使您可以查找传递给脚本的标志。如果找到标志,则假定它是 --ui 并使用 Vitest 用户界面运行测试。

使脚本可执行

运行测试所需的脚本都已完成,但是如果您尝试执行其中任何一个,您将会遇到权限错误。

为了使这些脚本可执行,您需要运行以下命令,该命令将赋予您当前用户运行它们的权限:

配置您的 npm 脚本

您的脚本现在是可执行的。下一步是在 package.json 中创建 scripts 记录,这些记录将调用这些自定义脚本并启动您的测试。

package.jsonscripts 部分添加以下内容:

现在,如果您运行以下任一脚本,您应该会看到您的 Docker 容器启动,执行 Prisma 迁移,最后运行您的测试:

tests running and failing

注意:目前您的测试将会失败。这是因为 Vitest 找不到任何包含测试的文件来运行。

编写集成测试

现在是时候利用您的测试环境并编写一些测试了!

在考虑应用程序的哪些部分需要集成测试时,重要的是思考组件之间的重要交互以及这些交互是如何被调用的。

在您正在使用的 Express API 的例子中,重要的交互组发生在 路由控制器服务 之间。当用户访问您 API 中的一个端点时,路由处理程序将请求传递给控制器,控制器可能会调用服务函数与数据库交互。

考虑到这一点,您将把集成测试重点放在单独测试每个路由上,确保每个路由都能正确响应 HTTP 请求。这包括对 API 的有效请求和无效请求。目标是让您的测试模拟 API 消费者与之交互时的体验。

注意:关于集成测试应该覆盖什么,存在许多不同的观点。在某些情况下,开发人员可能希望编写专门的集成测试,以确保较小的组件(例如您的 控制器服务)能够正确协作,同时编写测试来验证整个 API 路由是否正常工作。关于测试应该涵盖哪些内容的决定完全取决于您的应用程序需求以及您作为开发人员认为需要测试的内容。

与本系列之前的文章类似,为了将本教程中的信息保持在可管理的长度,您将重点关注为 API 路由 /auth/signin/auth/signup 编写测试。

注意: 如果您对 /quotes/* 路由的测试感兴趣,可以在 GitHub 仓库的 integration-tests 分支中找到完整的测试集。

/auth/signup 编写测试

src/tests 中创建一个名为 auth.test.ts 的新文件

所有与 API 的 /auth 路由相关的测试都将放在这里。

在此文件中,从 Vitest 导入 describeexpectit 函数,并使用 describe 来定义此测试套件

您将测试的第一个端点是 POST /auth/signup 路由。

在测试套件上下文中,添加另一个 describe 块来描述与此特定路由相关的测试套件

此路由允许您提供用户名和密码来创建新用户。通过查看 src/auth/auth.controller.ts 中的逻辑和 src/auth/auth.routes.ts 中的路由定义,可以确定以下是需要重点测试的行为:

  • 它应该以 200 状态码和用户详情响应
  • 成功时,它应该以有效的会话令牌响应
  • 如果提供了已存在的用户名,它应该以 400 状态码响应
  • 如果提供了无效的请求体,它应该以 400 状态码响应

注意: 测试应包含您期望从 API 获得的所有响应变体,包括成功响应和错误响应。通常,通读 API 的路由定义和控制器足以确定您应该测试的不同场景。

接下来的四个部分将详细介绍如何为这些场景编写测试。

它应该以 200 状态码和用户详情响应

要开始为这个场景编写测试,使用从 Vitest 导入的 it 函数来描述“它”应该做什么。

/auth/signup 路由的 describe 块中添加以下内容:

为了实际向应用程序中的端点 POST 数据,您将使用一个名为 supertest 的库。此库允许您向其提供一个 HTTP 服务器,并通过简单的 API 向该服务器发送请求。

安装 supertest 作为开发依赖项

然后在 src/tests/auth.test.ts 的顶部导入 supertest 并命名为 request。同时导入 src/lib/createServer 的默认导出,它提供了 app 对象

您现在可以使用 request 函数向您的 Express API 发送请求

上面,将 app 实例传递给了 request 函数。该函数的响应是一组函数,允许您与传递给 request 的 HTTP 服务器进行交互。

接着使用 post 函数定义了您打算交互的 HTTP 方法 和路由。最后调用 send 来发送 POST 请求以及请求体。

返回的值包含请求响应的所有详细信息,但这里专门从响应中提取了 statusbody 值。

现在您可以访问响应的状态和主体了,您可以验证路由是否执行了预期操作并以正确的值响应。

在此测试中添加以下内容以验证这些情况:

以上更改做了以下事情:

  1. 导入 prisma,以便您可以查询数据库以再次检查数据是否正确创建
  2. 使用 prisma 获取新创建的用户
  3. 确保请求以 200 状态码响应
  4. 确保找到了用户记录
  5. 确保响应体包含一个 user 对象,其中包含用户的 usernameid

如果在终端中运行 npm run test:int:ui,应该会看到 Vitest GUI 打开,并显示测试成功的消息。

注意: 如果您尚未运行此命令,系统可能会提示您安装 @vitest/ui 包并重新运行该命令。

Successful test run

注意: 此测试中没有模拟任何模块,包括 Prisma Client!您的测试是针对真实的数据库运行的,并验证了此路由中的数据交互是否正常工作。

成功时,它应该以有效的会话令牌响应

下一个测试将验证,当创建用户时,响应应包含一个会话令牌,该令牌可用于验证该用户对 API 的请求。

在之前的测试下方为这个场景创建一个新测试

这个测试将比之前的测试简单一些。它只需发送一个有效的注册请求并检查响应,以验证是否发送回了有效的令牌。

使用 supertest/auth/signup 端点发送一个 POST 请求并获取响应体

响应体应包含一个名为 token 的字段,其中包含会话令牌字符串。

添加一组断言来验证响应中存在 token 字段,并使用 jwt 库来验证令牌是有效的会话令牌

如果提供了已存在的用户名,它应该以 400 状态码响应

到目前为止,您已经验证了对 /auth/signup 的有效请求会按预期响应。现在您将转换思路,确保应用程序能够适当处理无效请求。

为这个场景添加另一个测试

为了触发使用现有用户名进行注册请求时应该发生的 400 错误,数据库中必须已经存在一个用户。

在此测试中添加一个查询,创建一个名为 'testusername'、密码任意的用户

现在,您应该可以通过发送与该用户同名的注册请求来触发错误了。

注意: 请记住,此用户记录(以及由于您的注册测试而创建的其他记录)会在每个单独的测试之间被删除。

/auth/signup 发送请求,提供与上面创建的用户相同的用户名:'testusername'

现在已经向该端点发送了请求,是时候思考在这种情况下您期望发生什么了。您会期望:

  • 请求以 400 状态码响应
  • 响应体不包含 user 对象
  • 数据库中的用户数量只有 1

为此测试添加以下断言以验证这些点都已满足:

如果提供了无效的请求体,它应该以 400 状态码响应

您将为此端点编写的最后一个测试是验证当向 API 发送无效请求体时,请求会以 400 状态码响应。

src/auth/auth.router.ts 所示,此端点使用 zod 通过在 src/lib/middlewares.ts 中定义的名为 validate 的中间件来验证其请求体是否包含有效的 usernamepassword 字段。

此测试将特别确保 validate 中间件和 zod 定义按预期工作。

为这个场景添加一个新测试

这个测试将非常直接。它应该只需向 /auth/signup 端点发送一个 POST 请求,并提供一个无效的请求体。

使用 supertest/auth/signup 发送一个 POST 请求,但不是发送 username 字段,而是发送一个 email 字段

此请求体应该导致验证中间件在继续执行控制器之前,以 400 错误码响应请求。

使用以下一组断言来验证此行为:

至此,您为 /auth/signup 端点编写的测试套件就完成了!如果您回头看看 Vitest GUI,应该会发现所有测试都成功通过了

Full suite of signup tests complete

/auth/signin 编写测试

您将为其编写测试的下一个端点与前一个端点有很多相似之处,但它不是创建新用户,而是验证现有用户。

/auth/signin 端点接收一个 username 和一个 password,确保存在具有所提供数据的用户,生成一个会话令牌,并以会话令牌和用户详情响应请求。

注意: 此功能的实现可以在 src/auth/auth.controller.tssrc/auth/auth.router.ts 中找到。

在您的测试套件中,您将验证此端点以下几点为真:

  • 提供有效凭据时,它应该以 200 状态码响应
  • 成功时,它应该以用户详情响应
  • 成功时,它应该以有效的会话令牌响应
  • 提供无效凭据时,它应该以 400 状态码响应
  • 找不到用户时,它应该以 400 状态码响应
  • 给定无效请求体时,它应该以 400 状态码响应

在测试每个场景之前,您需要定义另一个测试套件来对与此端点相关的所有测试进行分组。

在定义 /auth/signup 测试套件的闭合标签下方,为 /auth/signin 路由添加另一个 describe

您在此套件中编写的测试也将需要数据库中存在用户,因为您将测试登录功能。

在刚刚添加的 describe 块内,您可以使用 Vitest 的 beforeEach 函数在每次测试之前向数据库添加一个用户。

向新的测试套件添加以下内容:

注意: 重要的是要注意,这里的密码加密方法必须与 src/auth/auth.service.ts 中使用的加密方法完全匹配。

现在此测试套件的初始设置已完成,您可以继续编写测试了。

就像之前一样,接下来的六个部分将分别涵盖每个场景,并逐步说明测试是如何工作的。

提供有效凭据时,它应该以 200 状态码响应

第一个测试将简单地验证使用正确凭据的有效登录请求是否会导致 API 返回 200 响应码。

首先,在测试套件的 describe 块内,紧随 beforeEach 函数下方添加您的新测试

为了测试所需的行为,使用创建测试用户时相同的用户名和密码向 /auth/signin 端点发送一个 POST 请求。然后验证响应的状态码是否为 200

成功时,它应该以用户详情响应

下一个测试与前一个测试非常相似,不同之处在于它不是检查 200 响应状态,而是检查响应体中是否存在 user 对象并验证其内容。

添加另一个包含以下内容的测试

上述测试的内容执行以下操作:

  1. /auth/signin 发送一个 POST 请求,请求体中包含测试用户的用户名和密码
  2. 提取响应体中 user 对象的键
  3. 验证响应中存在两个键,idusername,并且 user.username 的值与测试用户的用户名匹配

成功时,它应该以有效的会话令牌响应

在此测试中,您将再次遵循与前两个测试非常相似的过程,只是此测试将验证响应体中是否存在有效的会话令牌。

在之前的测试下方添加以下测试

如您所见,上面向目标端点发送了一个请求,并从结果中抽象出了响应体。

使用了 toHaveProperty 函数来验证响应体中存在 token 键。然后使用 jwt.verify 函数验证会话令牌。

注意: 重要的是要注意,与密码加密类似,使用与 src/auth/auth.service.ts 中使用的函数相同的函数来验证会话令牌非常重要。

提供无效凭据时,它应该以 400 状态码响应

现在您将验证发送包含无效凭据的请求体是否会产生正确的错误响应。

要重现此场景,只需向 /auth/signin 发送一个 POST 请求,使用您的测试用户的正确用户名,但使用错误的密码。

添加以下测试

如您所见,预期的响应状态为 400

还添加了一个断言,要求响应体不包含 token 属性,因为无效的登录请求不应该触发会话令牌的生成。

注意: 这个测试的第二个断言并非绝对必要,因为 400 状态码已经足够说明您控制器中的条件已经满足,从而短路了请求并返回了错误。

找不到用户时,它应该以 400 状态码响应

在这里,您将测试在提供用户名时找不到用户的情况。就像之前的测试一样,这应该会短路请求并导致早期响应返回错误状态码。

向您的测试添加以下内容:

给定无效请求体时,它应该以 400 状态码响应

在最后一个测试中,您将验证发送无效请求体是否会导致错误响应。

src/auth/auth.router.ts 中使用的 validate 中间件应该会捕获无效的请求体,从而完全短路 auth 控制器。

添加以下测试来结束这个测试套件:

如您所见,上面将 username 字段替换为 email 字段,就像本文之前的一个测试中所做的那样。因此,请求体与请求体的 zod 定义不匹配,从而触发了错误。

如果您前往 Vitest GUI,应该会看到两个端点的整个测试套件都成功通过了所有检查。

Screen showing a full set of passing integration tests

总结与后续步骤

恭喜您读完这篇文章!测试系列的这一部分信息量很大,让我们回顾一下。在本文中,您:

  • 了解了什么是集成测试
  • 设置了一个 Docker 容器以在测试环境中运行 Postgres 数据库
  • 配置了 Vitest,以便您可以独立运行单元测试和集成测试
  • 编写了一组启动 shell 脚本来启动您的测试环境并运行您的集成测试套件
  • 为您 Express API 中的两个主要端点编写了测试

在本系列的下一节中,您将了解本文将涵盖的最后一种测试:端到端测试。

我们希望您能继续关注本系列的其余部分!

不要错过下一篇文章!

订阅 Prisma 新闻通讯