2023 年 1 月 31 日

使用 Prisma 进行测试的终极指南:单元测试

单元测试涉及测试单个、隔离的代码单元,以确保它们按预期工作。在本文中,您将学习如何识别代码库中应进行单元测试的区域、如何编写这些测试以及如何处理针对使用 Prisma Client 的函数的测试。

The Ultimate Guide to Testing with Prisma: Unit Testing

目录

简介

单元测试是确保应用程序中的各个代码单元(例如,函数)按预期工作的主要方法之一。

对于刚接触测试的人来说,理解什么是单元测试可能非常困难。他们不仅要理解应用程序如何工作、如何编写测试以及如何准备测试环境,还要理解应该测试什么!

因此,开发人员通常会采用这种测试方法

注意:感谢 @RoxCodes 的坦诚 😉

在本系列中,您将使用一个功能齐全的应用程序。其代码库中唯一缺少的是一组测试来验证它是否按预期工作。

在本系列的过程中,您将考虑代码的各个区域,并逐步了解应该测试什么、为什么需要测试以及如何编写这些测试。这将包括单元测试集成测试端到端测试,以及设置运行这些测试的持续集成(CI) 和持续开发(CD) 工作流程。

在本文中,您将专门深入研究代码的特定区域,并针对它们编写单元测试,以确保这些区域的各个构建块正常工作。

什么是单元测试?

单元测试是一种测试类型,涉及针对小型、隔离的代码片段编写测试。单元测试的目标是小型代码单元,以确保它们在各种情况下按预期工作。

通常,单元测试将针对单个 function,因为函数通常是 JavaScript 应用程序中最小的单个代码单元。

以下面的函数为例

虽然这个函数很简单,但它是单元测试的理想选择。它包含一组包装在一个函数中的单一功能。为了确保此函数正常工作,您可以向其提供字符串 'abcde',并确保返回字符串 'edcba'

相关的测试套件,或测试集,可能如下所示

正如您可能在上面注意到的那样,单元测试的目标只是确保应用程序中最小的构建块正常工作。通过这样做,您可以建立信心,当您开始组合这些构建块时,最终的行为是可预测的。

Test graphic

上述内容说明了这一点如此重要的原因。当您运行单元测试时,如果所有测试都通过,您可以确定每个构建块都正常工作,并且因此,您的应用程序按预期工作。但是,如果甚至一个测试失败,您可以假设您的应用程序未按预期工作,并且您将根据失败的测试确切地知道哪里出了问题。

什么不是单元测试?

在单元测试中,目标是确保您的自定义代码按预期工作。从上一句话中需要注意的重要一点是短语“自定义代码”

作为一名 JavaScript 开发人员,您可以通过 npm 访问由社区构建的丰富模块和软件包生态系统。使用外部库可以让您节省大量时间,否则您可能需要重新发明轮子。

虽然使用外部模块没有错,但在考虑测试使用这些模块的函数时,需要考虑一些事项。最重要的是,请记住这一点

如果您不信任外部软件包并认为应该针对它编写测试,您可能不应该使用该特定软件包。

以下面的函数为例

此函数接收正方形一边的长度,并返回一个包含更多定义正方形的对象,包括正方形的唯一颜色。

为上面的函数编写单元测试时,您可能需要验证以下内容

  • 当提供的数字小于 1 时,函数返回 null
  • 该函数正确计算面积
  • 该函数返回具有正确值的正确形状的对象
  • randomColor 函数被调用一次

请注意,没有提到测试以确保每个正方形实际上都获得唯一的颜色。这是因为假定 randomColor 正常工作,因为它是一个外部模块。

注意:无论 randomColor 是通过 npm 软件包提供的,还是甚至是另一个文件中自定义构建的函数,都应在此上下文中假设它能正常工作。如果 randomColor 是您在另一个文件中编写的函数,则应在其自己的隔离上下文中进行测试。想想“构建块”!

这个概念很重要,因为它也适用于 Prisma Client。在你的应用程序中使用 Prisma 时,Prisma Client 是一个外部模块。因此,任何测试都应假定你的客户端提供的函数按预期工作。

你将使用的技术

先决条件

预期的知识

具备以下知识对学习本系列教程有所帮助

  • JavaScript 或 TypeScript 的基本知识
  • Prisma Client 及其功能的基本知识
  • 具备一些 Express 的经验会更好

开发环境

要跟随提供的示例进行操作,你需要具备以下条件

  • 已安装 Node.js
  • 你选择的代码编辑器(我们推荐 VSCode
  • 已安装 Git

本系列将大量使用这个 GitHub 存储库。请确保克隆该存储库并检出 main 分支。

克隆存储库

在终端中,转到你存储项目的目录。在该目录中运行以下命令

上面的命令会将项目克隆到一个名为 express_sample_app 的文件夹中。该存储库的默认分支是 main,所以此时你应该可以开始使用了!

克隆存储库后,需要执行几个步骤来设置项目。

首先,导航到项目并安装 node_modules

接下来,在项目根目录创建一个 .env 文件

该文件应包含一个名为 API_SECRET 的变量,你可以将其值设置为任何你想要的 string,以及一个名为 DATABASE_URL 的变量,该变量现在可以为空

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

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

最后,你需要根据你的 Prisma 架构生成 Prisma Client

探索 API

现在你已经对单元测试是什么以及不是什么有了大致了解,请看一下你将在本系列中测试的应用程序。

你从 Github 克隆的项目包含一个功能齐全的 Express API。此 API 允许用户登录、存储和整理他们最喜欢的名言。

应用程序的文件按功能组织在 src 目录中的文件夹中。

src 中有三个主要文件夹

  • /auth:包含与 API 身份验证直接相关的所有文件
  • /quotes:包含与 API 名言功能直接相关的所有文件
  • /lib:包含任何通用的辅助文件

API 本身提供以下端点

端点描述
POST /auth/signup创建一个具有用户名和密码的新用户。
POST /auth/signin使用用户名和密码登录用户。
GET /quotes返回与登录用户相关的所有名言。
POST /quotes存储与登录用户相关的新名言。
DELETE /quotes/:id按 ID 删除属于登录用户的名言。

请随意花一些时间探索此项目中的文件,并了解 API 的工作原理。

在对单元测试是什么以及应用程序的工作原理有了大致了解之后,你现在可以开始编写测试以验证应用程序是否按预期工作。

注意:在实际环境中,这些测试将有助于确保随着应用程序的发展和变化,现有功能保持完整。测试可能会在你开发应用程序时编写,而不是在应用程序完成后编写。

设置 Vitest

要开始测试,你需要设置一个测试框架。在本系列中,你将使用 Vitest

首先,使用以下命令安装 vitestvitest-mock-extended

注意:有关上面安装的两个软件包的信息,请务必阅读本系列中的第一篇文章

接下来,你需要配置 Vitest,以便它知道你的单元测试在哪里,以及如何解析你可能需要导入到这些测试中的任何模块。

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

此文件将使用 Vitest 提供的 defineConfig 函数定义和导出你的单元测试的配置

上面你为 Vitest 配置了两个选项

  • test.include 选项告诉 Vitest 在 src 目录中查找与命名约定 *.test.ts 匹配的任何文件中的测试。
  • resolve.alias 配置设置文件路径别名。这允许你缩短文件导入路径,例如:src/auth/auth.service 变为 auth/auth.service

最后,为了更轻松地运行测试,你将在 package.json 中配置脚本以运行 Vitest CLI 命令。

将以下内容添加到 package.jsonscripts 部分

上面添加了两个新脚本

  • test:unit:这将使用你上面创建的配置文件运行 vitest CLI 命令。
  • test:unit:ui:这将使用你上面创建的配置文件以UI 模式运行 vitest CLI 命令。这会在你的浏览器中打开一个 GUI,其中包含用于搜索、筛选和查看测试结果的工具。

要运行这些命令,你可以在项目根目录的终端中执行以下命令

注意:如果你现在运行其中任何一个命令,你会发现命令失败。那是因为没有要运行的测试!

此时,Vitest 已配置完成,你可以开始考虑编写单元测试了。

不需要测试的文件

在直接开始编写测试之前,你将首先查看不需要测试的文件,并考虑为什么。

下面是不需要测试的文件列表

  • src/index.ts
  • src/auth/auth.router.ts
  • src/auth/auth.schemas.ts
  • src/quotes/quotes.router.ts
  • src/quotes/quotes.schemas.ts
  • src/quotes/quotes.service.ts
  • src/lib/prisma.ts
  • src/lib/createServer.ts

这些文件没有任何需要单元测试的自定义行为。

在接下来的两节中,你将查看这些文件中导致它们不需要测试的两个主要场景。

该文件没有自定义行为

查看应用程序中的以下示例

src/quotes/quotes.router.ts 中,实际发生的事情只是调用 Express 框架提供的函数。有一些自定义函数(validateQuoteController.*)在发挥作用,但这些函数在单独的文件中定义,并将在它们自己的上下文中进行测试。

第二个文件 src/auth/auth.schemas.ts 非常相似。虽然此文件对应用程序很重要,但实际上没有什么需要测试的地方。该代码只是导出使用外部模块 zod 定义的架构。

函数仅调用外部模块

另一个需要指出的重要场景是 src/quotes/quotes.service.ts 中的场景

此服务导出两个函数。这两个函数都包装了 Prisma Client 函数调用并返回结果。

正如本文前面提到的,不需要测试外部代码。因此,可以跳过此文件。

如果你查看上面列表中其余不需要测试的文件,你会发现每个文件都不需要测试的原因是这里概述的原因之一。

你将测试什么

项目中其余的 .ts 文件都包含应进行单元测试的功能。需要测试的完整文件列表如下

  • src/auth/auth.controller.ts
  • src/auth/auth.service.ts
  • src/lib/middlewares.ts
  • src/lib/utility-classes.ts
  • src/quotes/quotes.controller.ts
  • src/quotes/tags.service.ts

这些文件中的每个函数都应有自己的测试套件,以验证其行为是否正确。

你可以想象,这可能会导致很多测试!为了将其转换为数字,Express API 包含十三个不同的需要测试的函数,每个函数都可能有一个包含两个以上测试的套件。这意味着至少需要编写二十六个测试!

为了使本文保持在可管理的长度内,你将为一个文件编写测试,src/quotes/tags.service.ts,因为该文件的测试涵盖了本文希望涵盖的所有重要的单元测试概念。

注意:如果你对该 API 的完整测试集是什么样子感到好奇,则 Github 存储库的 unit-tests 分支包含每个函数的完整测试集。

测试标签服务

标签服务导出两个函数,upsertTagsdeleteOrphanedTags

首先,在与 tags.service.ts 相同的目录中创建一个名为 tags.service.test.ts 的新文件

注意:组织测试的方法有很多。在本系列中,测试将编写在与测试目标相邻的文件中,也称为将测试并置。

如果您使用的是 VSCode 并且版本为 v1.64 或更高版本,您可以使用一个很酷的功能,该功能可以在并置测试及其目标时清理项目的文件树。

在 VSCode 中,转到屏幕顶部选项栏中的 代码 > 首选项 > 设置

在设置页面中,通过键入 file nesting 来搜索文件嵌套设置。启用以下设置

File nesting option in VSCode

接下来,在这些设置中向下滚动一点,您将看到一个 资源管理器 > 文件嵌套:模式 部分。

如果不存在名为 *.ts 的项,请创建一个。然后将 *.ts 项的值更新为 ${capture}.*.ts

File nesting setting in VSCode

这允许 VSCode 将任何文件嵌套在名为 ${capture}.ts 的主文件下。为了更好地说明,请参阅以下示例

Nested files

在上面,您可以看到一个名为 quotes.controller.ts 的文件。嵌套在该文件下的是 quotes.controller.test.ts。虽然不是绝对必要,但此设置可能有助于在并置单元测试时稍微清理一下文件树。

导入必需的模块

在新 tags.service.test.ts 文件的顶部,您需要导入一些允许您编写测试的内容

下面是每个导入的用途

  • TagsService:这是您正在针对其编写测试的服务。您需要导入它,以便可以调用其函数。
  • prismaMock:这是在 lib/__mocks__/prisma 中提供的 Prisma 客户端的模拟版本。
  • randomColorupsertTags 函数中用于生成随机颜色的库。
  • describevitest 提供的函数,允许您描述一组测试。

需要注意的是 prismaMock 导入。这是模拟的 Prisma 客户端实例,允许您执行 prisma 查询而无需实际访问数据库。由于它是模拟的,您还可以操作查询响应并监视其方法。

注意:如果您不确定 prismaMock 导入是什么以及它是如何工作的,请务必阅读本系列中的上一篇文章,其中解释了此模块的作用。

描述测试套件

现在,您可以使用 Vitest 提供的 describe 函数来描述这组特定的测试

这会将此文件中的测试分组到一个部分中,并在输出测试结果时更容易查看哪些套件通过和失败。

模拟目标文件使用的任何模块

在编写实际测试套件之前,要做的最后一件事是模拟 tags.service.ts 文件中使用的外部模块。这将使您能够控制这些模块的输出,并确保您的测试不会受到外部代码的污染。

在此服务中,有两个模块要模拟:PrismaClientrandomColor

通过添加以下内容来模拟这些模块

在上面,使用 Vitest 的自动模拟检测算法模拟了 lib/prisma 模块,该算法在“真实” Prisma 模块的同一目录中查找名为 __mocks__ 的文件夹和一个 __mocks__/prisma.ts 文件。此文件的导出用作模拟模块,代替真实模块的导出。

randomColor 模拟有点不同,因为该模块仅导出一个默认值,这是一个函数。vi.mock 的第二个参数是一个函数,该函数返回模块在导入时应返回的对象。上面的代码段向此对象添加一个 default 键,并将其值设置为一个可监视的函数,该函数具有 '#ffffff' 的静态返回值。

在测试套件的上下文中,beforeEachvi.restoreAllMocks 用于确保在每个单独的测试之间,模拟都恢复到其原始状态。这很重要,因为在某些测试中,您将修改特定测试的模拟行为。

注意:如果您不确定这些模拟的工作方式,请务必参考本系列中的上一篇文章,其中介绍了模拟。

每当在 TagsService 中导入这些模块时,现在将导入模拟版本。

测试 upsertTags 函数

upsertTags 函数接受一个标签名称数组,并为每个名称创建一个新标签。但是,如果数据库中现有的标签具有相同的名称,则不会创建标签。该函数的返回值是与提供给函数的所有标签名称(包括新标签和现有标签)关联的标签 ID 数组。

在测试套件内的 beforeEach 调用下方,添加另一个 describe 以描述与 upsertTags 函数相关的一系列测试。同样,这样做是为了对测试的输出进行分组,从而更容易查看与此特定函数相关的哪些测试通过了。

现在是时候决定您编写的测试应该涵盖哪些内容了。查看 upsertTags 函数,考虑它具有哪些特定行为。应该测试每个期望的行为。

下面,添加了注释,显示了应该在此函数中测试的每个行为。注释已编号,指示将编写测试的顺序

准备好要测试的场景列表后,您现在可以开始为每个场景编写测试。

验证函数返回标签 ID 列表

第一个测试将确保该函数的返回值是一个标签 ID 数组。在此函数的 describe 块中,添加新测试

上面的测试执行以下操作

  1. 模拟 Prisma 客户端的 $transaction 函数的响应
  2. 调用 upsertTags 函数
  3. 确保函数的响应等于 $transaction 的预期模拟响应

此测试很重要,因为它专门测试了该函数的预期结果。如果此函数将来发生更改,此测试可确保该函数的结果保持预期。

注意:如果您不确定 Vitest 提供的特定方法的作用,请参阅 Vitest 的文档

如果您现在运行 npm run test:unit,您应该会看到您的测试成功通过。

验证该函数仅创建尚不存在的标签

上面计划的下一个测试将验证该函数不会在数据库中创建重复的标签。

该函数提供了一个表示标签名称的字符串列表。该函数首先检查是否存在具有这些名称的现有标签,并根据结果仅筛选创建新标签。

测试应

  • 模拟 prisma.tag.findMany 的第一次调用以返回单个标签。这表示根据提供给函数的名称找到一个现有标签。
  • 使用三个标签名称调用 upsertTags。一个名称应为 tag1,即模拟现有标签的名称。
  • 确保仅向 prisma.tag.createMany 提供了与 tag1 不匹配的两个标签。

upsertTags 函数的 describe 块内的上一个测试下方添加以下测试

再次运行 npm run test:unit 现在应该显示您的两个通过测试。

验证该函数为新标签提供随机颜色

在下一个测试中,您需要验证是否在创建新标签时为其提供了新的随机颜色。

为此,请编写一个插入三个新标签的基本测试。调用 upsertTags 函数后,您可以确保 randomColor 函数被调用了三次。

下面的代码段显示了此测试的外观。在为 upsertTags 函数的 describe 块内编写的上一个测试下方添加新测试

npm run test:unit 命令应导致三个成功的测试。

您可能想知道上面的测试如何检查 randomColor 被调用的次数。

请记住,在此文件的上下文中,randomColor 模块被模拟,其默认导出被配置为 vi.fn,该 vi.fn 提供一个返回静态字符串值的函数。

因为使用了 vi.fn,所以模拟函数现在在 Vitest 中注册为一个您可以监视的函数。

因此,您可以访问特殊的属性,例如在当前测试期间调用该函数的次数计数。

验证该函数在其返回的数组中包含新创建的标签 ID

在此测试中,您需要验证该函数是否返回与提供给该函数的每个标签名称关联的标签 ID。这意味着它应该返回现有标签 ID 和任何新创建的标签的 ID。

此测试应

  1. 使 tag.findMany 的第一次调用返回一个标签,以模拟查找现有标签
  2. 模拟 tag.createMany 的响应
  3. 使 tag.findMany 的第二次调用返回两个标签,表示它找到了两个新创建的标签
  4. 使用三个标签调用 upsertTags 函数
  5. 确保返回所有三个 ID

添加以下测试以实现此目的

通过运行 npm run test:unit 来验证上述测试是否有效。

验证当未提供任何标签名称时,该函数返回一个空数组

正如您可能预期的那样,如果没有为该函数提供任何标签名称,它应该无法返回任何标签 ID。

在此测试中,通过添加以下内容来验证此行为是否正常工作

至此,已对该函数确定的所有场景进行了测试!

如果您使用您添加到 package.json 的任何脚本运行测试,您应该会看到所有测试运行并成功通过!

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

Successful suite of tests

测试 deleteOrphanedTags 函数

此函数与之前的函数的情况非常不同。

正如您可能已经确定的那样,此函数只是封装了 Prisma Client 函数的调用。因此...您猜对了!此函数实际上不需要测试!

总结 & 后续步骤

在本文中,您

  • 了解了单元测试是什么以及为什么它对您的应用程序很重要
  • 看到了一些情况示例,其中单元测试不是严格必需的
  • 设置 Vitest
  • 学习了一些在编写测试时使生活更轻松的技巧
  • 尝试为 API 中的服务编写单元测试

虽然本文仅涵盖了 quotes API 中的一个文件,但用于测试标签服务的概念和方法也适用于应用程序的其余部分。我建议您为 API 的其余部分编写测试以进行练习!

在本系列的下一部分中,您将深入了解集成测试,并为此同一应用程序编写集成测试。

不要错过下一篇文章!

注册 Prisma 新闻通讯