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 的模拟版本。
  • randomColorupsertTags 函数中用于生成随机颜色的库。
  • 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 的第一次调用,以返回单个标签。这表示根据提供给函数的名称找到一个现有标签。
  • 使用三个标签名称调用 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,模拟函数现在在 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 中的一个文件,但用于测试 tags 服务的概念和方法也适用于应用程序的其余部分。我鼓励您为 API 的其余部分编写测试以进行练习!

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

不要错过下一篇文章!

注册 Prisma 新闻通讯