随着应用程序的增长,自动化测试变得越来越重要。在本文中,您将学习如何模拟 Prisma Client,以便您可以在不访问实际数据库的情况下测试具有数据库交互的函数。
目录
- 目录
- 简介
- 先决条件
- 什么是模拟?
- 设置 Prisma 项目
- 设置 Vitest
- 为什么要模拟 Prisma Client?
- 模拟 Prisma Client
- 使用模拟客户端
- 监视方法
- 为什么选择 Vitest?
- 总结和下一步
简介
测试在应用程序中变得越来越重要,因为它使开发人员对其编写的代码更有信心,并能更有效地迭代其产品。
正如人们可能想象的那样,能够自信和高效地工作是任何开发人员工作流程的重要方面。那么……为什么不是每个开发人员都为他们的应用程序编写测试呢?这个问题的答案通常是:编写测试,尤其是当涉及到数据库时,可能会很棘手!
警告:不好的建议 👆🏻
在本系列中,您将学习如何针对与数据库交互的各种应用程序执行不同类型的测试。
本文将专门深入探讨模拟的主题,并逐步讲解如何模拟 Prisma Client。然后,您将了解可以使用模拟客户端执行哪些操作。
您将使用的技术
先决条件
假设的知识
以下内容对您开始本系列会有所帮助
- JavaScript 或 TypeScript 的基本知识
- Prisma Client 及其功能的基本知识
开发环境
要遵循所提供的示例,您需要具备以下条件
什么是模拟?
您将在本系列中研究的第一个概念是模拟。该术语指的是为对象创建受控的替代项的做法,该替代项的行为类似于它所替代的真实对象。
模拟的目标通常是允许开发人员替换函数可能需要的任何外部依赖项,以便他们可以有效地针对该函数编写单元测试。这样,测试可以隔离到函数的行为,而无需担心不直接相关的外部模块的行为。
注意:您将在本系列的下一篇文章中仔细研究单元测试。
为了说明这一点,请考虑以下函数
此函数执行三项操作
- 检查是否提供了有效的电子邮件地址
- 如果提供了无效地址,则抛出错误
- 通过一个虚构的
mailer
服务发送电子邮件
要编写一个测试来验证此函数的行为是否符合预期,您可能会首先测试该函数被提供无效电子邮件地址的情况并验证是否抛出错误。
但是,该函数依赖于两段外部代码:isValidEmail
和 mailer
。因为这些是单独的代码段,并且在技术上与您正在测试的函数无关,所以您不希望担心这些导入是否正常工作。相反,应该假定这些是功能性的并且经过独立测试。
您可能也不希望在测试期间调用 mailer.send()
时实际发送电子邮件,因为该功能独立于您正在测试的函数。
在这种情况下,通常的做法是模拟这些依赖项,将真实的导入对象替换为返回受控值的“伪造”对象。这样做,您可以在测试的目标函数中触发特定状态,而无需考虑另一个模块的行为。
这是一个非常基本的场景,说明了模拟的有用性,但是本文的其余部分将深入探讨您可以用来模拟模块的不同模式和工具,并使用这些模拟来测试特定场景。
设置 Prisma 项目
在开始编写测试之前,您需要一个项目进行实验。要设置一个项目,您将使用 try-prisma
,该工具允许您快速设置一个包含 Prisma 的示例项目。
在终端中运行以下命令
完成上述步骤后,一个起始项目应该已经在您当前的工作目录下建立,并位于名为 mocking_playground
的文件夹中。
您还将在终端看到额外的输出,其中包含后续步骤的说明。按照这些说明进入您的项目并运行您的第一个 Prisma 迁移。
现在已经生成了一个 SQLite 数据库,您的 schema 已应用,并且 Prisma Client 也已生成。您已准备好开始在您的项目中工作!
设置 Vitest
为了创建测试和模拟,您需要一个测试框架。在本系列中,您将使用越来越流行的 Vitest 测试框架,它提供了一组工具,允许您构建和运行测试,以及创建模块的模拟。
注意:Vitest 还做了很多其他很酷的事情!如果您好奇,可以看看他们的 文档。
在您的项目中运行以下命令来安装 Vitest 框架及其 CLI 工具
接下来,在您的项目根目录下创建一个名为 test
的新文件夹,您的所有测试都将位于其中
注意:Vitest 没有要求您将测试放在
/test
文件夹中。默认情况下,Vitest 将根据这些 命名约定 检测测试文件。
最后,在 package.json
中,添加一个名为 test
的新脚本,该脚本仅运行命令 vitest
您现在可以使用 npm run test
来运行您的测试。您也可以运行 npm t
作为简写。目前,您的测试将失败,因为没有测试文件。
在 /test
目录中创建一个名为 sample.test.ts
的新文件
添加以下测试,以便您可以验证 Vitest 是否已正确设置
现在有一个有效的测试,运行 npm t
应该会成功!Vitest 已设置好并可以使用了。
为什么模拟 Prisma Client?
说明在单元测试中模拟 Prisma Client 为什么有用的最好方法是编写一个使用 Prisma Client 的函数,并为该函数编写一个不使用模拟客户端的测试。
在您的项目根目录中,创建一个名为 libs
的新文件夹。然后在该文件夹中创建一个名为 prisma.ts
的文件
将以下代码片段添加到该新文件中
上面的代码实例化了 Prisma Client 并将其作为单例实例导出。这是“真正的”Prisma Client 实例。
现在有一个可用的 Prisma Client 实例,编写一个使用它的函数。
将 script.ts
的内容替换为以下内容
createUser
函数执行以下操作
- 接收一个
user
参数 - 将
user
传递给prisma.user.create
函数 - 返回响应,应该是新的用户对象
接下来,您将为该新函数编写一个测试。此测试将确保在提供有效用户(即新用户)时,createUser
返回预期的数据。
更新 test/sample.test.ts
,使其与下面的代码片段匹配
注意:上面的测试没有使用模拟的 Prisma Client。它使用真实的客户端实例来演示在针对真实数据库进行测试时可能遇到的问题。
假设您的数据库尚未包含任何用户记录,则此测试在您第一次运行时应该会通过。但存在一些问题
- 下次运行此测试时,创建的用户的
id
将不是1
,从而导致测试失败。 email
字段在您的 Prisma schema 中具有@unique
属性,表示该列在数据库中具有唯一索引。这将导致在后续运行测试时发生错误。- 此测试假设您正在针对开发数据库运行,并且需要数据库可用。每次运行此测试时,都会向您的数据库添加一条记录。
在诸如专注于单个函数的单元测试的情况下,最佳实践是假设您的数据库操作将正常运行,并使用客户端或驱动程序的模拟版本,从而使您可以专注于测试您所针对函数的特定行为。
注意:在某些情况下,您可能希望针对数据库进行测试并实际对其执行操作。集成和端到端测试是这些情况的很好的例子。这些测试可能依赖于在应用程序的多个函数和区域中发生的多个数据库操作。
模拟 Prisma Client
由于上一节中概述的原因,最好创建客户端的模拟,以正确地对使用 Prisma Client 的函数进行单元测试。此模拟将替换您的函数通常会使用的导入模块。
为了实现这一点,您将使用 Vitest 的模拟工具和一个名为 vitest-mock-extended
的外部库。
首先,在您的项目中安装 vitest-mock-extended
接下来,转到 test/sample.test.ts
文件并进行以下更改,以告知 Vitest 它应该模拟 libs/prisma.ts
模块
vi
对象中可用的 mock
函数让 Vitest 知道它应该模拟在提供的文件路径中找到的模块。mock
函数可以通过几种不同的方式决定如何模拟目标模块,如 文档中所述。
目前,Vitest 将尝试模拟在 '../libs/prisma'
中找到的模块,但是它将无法自动模拟 prisma
对象的“深层”或“嵌套”属性。例如,prisma.user.create()
将不会被正确模拟,因为它是一个 Prisma Client 实例的深层嵌套属性。这会导致测试失败,因为该函数仍将像往常一样针对实际数据库运行。
要解决此问题,您需要让 Vitest 知道您希望如何精确地模拟该模块,并提供在导入模拟模块时应返回的值,其中包括深层嵌套属性的模拟版本。
在 libs
目录中创建一个名为 __mocks__
的新文件夹
文件夹名称 __mocks__
是测试框架中的一个常见约定,您可以在其中放置任何模块的手动创建的模拟。__mocks__
文件夹必须与您正在模拟的模块直接相邻,这就是为什么我们在 libs/prisma.ts
文件旁边创建了该文件夹。
在该新文件夹中,创建一个名为 prisma.ts
的文件
请注意,此文件与“真实”文件 prisma.ts
的名称相同。通过遵循此约定,Vitest 将知道当它通过 vi.mock
模拟模块时,它应该使用该文件来查找客户端的模拟版本。
有了这个结构,您现在将创建手动模拟。
在新的 libs/__mocks__/prisma.ts
文件中,添加以下内容
上面的代码片段执行以下操作
- 导入创建模拟客户端所需的所有工具。
- 让 Vitest 知道在每个单独的测试之间,应将模拟重置为其原始状态。
- 使用
vitest-mock-extended
库的mockDeep
函数创建和导出 Prisma Client 的“深层模拟”,该函数可确保对象的所有属性(甚至是深层嵌套的属性)都被模拟。
注意:本质上,
mockDeep
会将每个 Prisma Client 函数的值设置为 Vitest 辅助函数:vi.fn()
。
此时,如果您再次使用 npm t
运行测试,您应该会看到您不再收到与之前相同的错误!但是仍然存在一个问题...
查询返回 `undefined`
实际上,发生此错误是因为模拟已正确到位。您在 script.ts
中的 prisma.user.create
调用不再访问数据库。目前,该函数实际上什么也不做,并返回 undefined
。
您需要通过模拟其行为来告诉 Vitest prisma.user.create
应该做什么。现在您有了 Prisma Client 的正确模拟版本,这只需要对您的测试进行简单的更改。
在 test/sample.test.ts
文件中,添加以下内容,以告诉 Vitest 在该单独的测试过程中该函数应如何表现
上面,导入了“假”客户端,因为它导出了 Prisma Client 的深层模拟。
在此对象上,您会注意到附加到每个 Prisma Client 属性和函数的一组新函数
上面代码片段中使用的 mockResolvedValue
将正常的 prisma.user.create
函数替换为一个返回提供值的函数。对于该单个测试,该函数的行为就好像您执行了以下赋值
注意:在本文的后面,您将深入了解模拟的 Prisma Client 中可用的一些有用函数,以及如何使用它们。
您现在可以通过预先模拟客户端的行为来运行使用 Prisma Client 的函数,以确保获得所需的结果。这样,您无需担心各个查询,而可以专注于该函数的实际业务逻辑。
如果您现在再次运行测试,您最终应该看到您的所有测试都已通过!✅
使用模拟客户端
因此,您已经有了一个模拟的 Prisma Client 实例,并且可以操作客户端以生成您需要测试函数中特定场景的查询结果...接下来呢?
本文的其余部分将深入探讨您的模拟客户端和 Vitest 可用的许多函数,以及它们如何在不同场景中使用以增强您的测试体验。
注意:下面的示例将不是可行的、成熟的单元测试。相反,它们将是通过模拟客户端提供的可用工具的功能示例。本系列的下一篇文章将深入介绍单元测试。
模拟查询响应
您使用模拟客户端最常见的用途之一是模拟查询的响应。您已经在本文前面模拟了 create
方法的响应,但是有多种方法可以实现这一点,每种方法都有其自己的用例。
例如,考虑以下场景
注意:此处使用
toStrictEqual
非常重要。比较对象时,toStrictEqual
确保对象具有相同的结构和类型。
尽管此测试成功通过,但它没有多大意义。当调用 prisma.post.findMany.mockResolvedValue
时,提供给该函数的值将用作测试的其余部分中 prisma.post.findMany
的响应。更具体地说,直到在 libs/__mocks__/prisma.ts
中调用 mockReset
函数。
因此,unpublished
和 published
数组将包含完全相同的值,包括 published
属性中的 true
值。
为了在此场景中生成更真实的响应,您可以利用另一个函数:mockResolvedValueOnce
。可以多次调用此函数以模拟函数的响应以及后续调用的响应。
在您的 getPosts
函数中,您可以使用 mockResolvedValueOnce
来模拟该函数应返回的第一个和第二个响应。
注意:通过 Vitest 提供的许多函数都有一个
mockXValueOnce
方法以及mockXValue
。有关更多详细信息,请参阅文档。
触发和捕获错误
您可能要测试的另一个场景是查询失败并返回或抛出错误的情况。这可能非常有用的一个很好的例子是 Prisma 客户端的 findUniqueOrThrow
函数。
此函数搜索唯一记录,但如果找不到记录则抛出错误。但是,由于您的 Prisma 客户端的函数是模拟的,因此 findUniqueOrThrow
函数不再以这种方式工作。您必须手动触发错误状态。下面显示了一个如何测试此行为的示例
mockImplementation
允许您提供一个函数来替换模拟函数的行为。在上面的例子中,替换函数只是抛出一个错误。
虽然乍一看这似乎有点乏味,但在这种情况下需要手动定义函数的行为实际上是一个额外的好处。这使您可以精细地控制函数在不同状态下的输出,即使是错误状态。
与上述类似,如果您正在测试的方法旨在抛出实际错误,而不是返回与错误相关的某些消息,您也可以对此进行测试!
通过在 expect
函数的响应中使用 rejects
关键字,Vitest 知道要解析提供给 expect
的 Promise
并查找错误响应。一旦 Promise
解析,toThrow
和 toThrowError
函数允许您检查有关错误的特定详细信息。
模拟事务
您可能需要模拟的 Prisma 客户端的另一部分是 $transaction
。
事务有不同的类型:顺序操作 和 交互式事务。您模拟这些的方式将在很大程度上取决于您的测试目标以及您使用 $transaction
的上下文。但是,您通常可以通过两种方式模拟此函数。
对于顺序操作和交互式事务,完成的事务结果最终都从 $transaction
函数返回。如果您的测试只关心事务的结果,那么您的测试将非常类似于上面您模拟函数响应的测试。
一个例子可能如下所示
在上面的测试中,您
- 模拟了您打算创建的帖子的数据。
- 模拟了
$transaction
的响应应该是什么样子。 - 在模拟 Prisma 客户端方法后调用了该函数。
- 确保从您的函数返回的值与您期望的值相匹配。
通过模拟 $transaction
函数本身的响应,您不必担心事务的顺序操作(或交互式事务,如果属于这种情况)中发生了什么。
如果您想测试具有重要业务逻辑的交互式事务(您需要验证),该怎么办?此方法将不起作用,因为它完全放弃了事务的内部工作。
要测试具有重要业务逻辑的交互式事务,您可以编写如下所示的测试
此测试涉及更多,因为要考虑很多不同的移动部件。
以下是发生的情况
- 帖子和响应对象被模拟。
- 模拟
create
和count
方法的响应。 - 模拟
$transaction
函数的实现,以便您可以将模拟的 Prisma 客户端提供给交互式事务函数,而不是实际的客户端实例。 - 调用
addPost
方法。 - 验证响应的值,以确保交互式事务中的业务逻辑正常工作。更具体地说,它确保新帖子的
published
标志设置为true
。
侦听方法
您将探讨的最后一个概念是侦听。Vitest 通过一个名为 TinySpy 的包,使您能够侦听一个函数。侦听允许您在代码执行过程中观察一个函数,并确定诸如:调用了多少次、传递给它的参数、它返回的值等等。
注意:侦听一个函数允许您在执行代码时观察有关该函数的详细信息,而无需修改目标函数或其行为。
您可以使用 vi.spyOn()
侦听未模拟的函数,但是具有 vi.fn()
的模拟函数默认具有所有可用的侦听功能。因为 Prisma 客户端已被模拟,所以每个函数都应该能够被侦听。
下面是一个快速示例,说明使用侦听的测试可能是什么样子
当您尝试确保基于各种输入触发某些场景时,这些侦听函数特别有用。
为什么选择 Vitest?
您可能想知道为什么本文重点介绍 Vitest 作为测试框架,而不是像 Jest 这样更成熟和流行的框架。
此决定背后的原因与不同工具与 Node.js 的兼容性有关,特别是在处理 Error
对象时。 Matteo Collina,他是 Node.js 技术指导委员会的成员,以及其他出色的成就,在他的最近一次直播中很好地描述了这个问题。
简而言之,问题在于 Jest 无法开箱即用地确定错误是否是 Error
类的实例。
当您为应用程序中的不同情况编写测试时,这可能会导致各种意外问题。
它们有何不同?
幸运的是,在大多数情况下,每个测试框架都非常相似,并且概念可以相当无缝地转换。例如,如果您习惯使用 Jest,并且正在考虑迁移到 Vitest 或 node-tap
(另一个测试框架)之类的东西,那么您已经拥有的知识将很容易转移到新技术上。
将需要进行非常小的调整:诸如函数命名约定和配置之类的内容。
您应该使用 Jest 吗?
是的!Jest 是一个由非常有能力的人编写的优秀工具。虽然在 Node.js 中测试后端应用程序时,Vitest 可能是“最适合该工作的工具”,但 Jest 仍然完全有能力测试前端 JavaScript 应用程序。
总结 & 下一步
在本文中,您专注于模拟和侦听的概念,这两个概念都在应用程序的单元测试中发挥着重要作用。具体来说,您探索了
- 什么是模拟以及为什么它很有用
- 如何设置配置了 Vitest 和 Prisma 的项目
- 如何模拟 Prisma 客户端
- 如何使用模拟的 Prisma 客户端实例
有了这些知识和测试世界的背景,您现在拥有了单元测试应用程序所需的工具集。在本系列的下一篇文章中,您将完全做到这一点!
我们希望您加入本系列的下一部分,我们将探索您可以使用 Prisma 客户端测试应用程序的各种方式。
不要错过下一篇文章!
注册 Prisma 新闻通讯