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 Client 的模拟版本。
  • randomColor:在 upsertTags 函数中用于生成随机颜色的库。
  • describevitest 提供的函数,允许您描述一组测试。

需要注意的是 prismaMock 导入。这是模拟的 Prisma Client 实例,允许您执行 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 Client 的 $transaction 函数的响应
  2. 调用 upsertTags 函数
  3. 确保函数的响应等于 $transaction 的预期模拟响应

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

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

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

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

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

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

测试应

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

upsertTags 函数的 describe 代码块中,在前一个测试下方添加以下测试。

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

验证函数为新标签赋予随机颜色

在下一个测试中,你需要验证每当创建新标签时,都会为其提供一个新的随机颜色。

为了做到这一点,编写一个基本测试,插入三个新标签。在调用 upsertTags 函数之后,你可以确保 randomColor 函数被调用了三次。

下面的代码片段展示了这个测试应该是什么样子。在 upsertTags 函数的 describe 代码块中,在你编写的前一个测试下方添加新的测试。

npm run test:unit 命令应该会产生三个成功的测试。

你可能想知道上面的测试是如何检查 randomColor 被调用了多少次的。

请记住,在这个文件的上下文中,randomColor 模块被模拟了,并且它的默认导出被配置为一个 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 新闻邮件