2022年12月22日

Prisma 测试终极指南:模拟 Prisma Client

随着应用程序规模的增长,自动化测试变得越来越重要。在本文中,你将学习如何模拟 Prisma Client,以便在不实际访问数据库的情况下测试与数据库交互的函数。

The Ultimate Guide to Testing with Prisma: Mocking Prisma Client

目录

引言

随着应用程序的发展,测试变得越来越重要,因为它可以让开发人员对自己编写的代码更有信心,并更有效地迭代产品。

正如人们所想,自信高效地工作是任何开发人员工作流程的重要方面。那么……为什么不是所有开发人员都为他们的应用程序编写测试呢?这个问题的答案通常是:编写测试,尤其是在涉及数据库时,可能很棘手!

Testing meme

警告:不好的建议 👆🏻

在本系列中,你将学习如何针对与数据库交互的各种应用程序执行不同类型的测试。

本文将特别深入探讨模拟(Mocking)的主题,并逐步讲解如何模拟 Prisma Client。然后,你将了解如何使用模拟客户端进行测试。

将使用的技术

先决条件

假定知识

开始本系列之前,具备以下知识会有帮助

  • JavaScript 或 TypeScript 的基础知识
  • Prisma Client 及其功能的基础知识

开发环境

要按照提供的示例进行操作,你需要具备

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

什么是模拟 (Mock)?

本系列将介绍的第一个概念是模拟(Mocking)。这个术语指的是为某个对象创建一个受控的替代品,使其行为类似于它所替代的真实对象。

模拟的目标通常是让开发人员能够替换函数可能需要的任何外部依赖项,以便有效地针对该函数编写单元测试。这样,测试就可以只关注函数本身的逻辑,而无需担心不直接相关的外部模块的行为。

注意: 在本系列的下一篇文章中,我们将更深入地探讨单元测试。

为了说明这一点,考虑以下函数

这个函数完成三件事

  1. 检查确保提供了一个有效的电子邮件地址
  2. 如果提供了无效地址,则抛出错误
  3. 通过一个假定的 mailer 服务发送电子邮件

要编写测试来验证这个函数的行为是否符合预期,你可能会首先测试提供无效电子邮件地址的场景,并验证是否抛出了错误。

然而,这个函数依赖于两段外部代码:isValidEmailmailer。由于这些是独立的代码片段,并且在技术上与你正在测试的函数无关,你不会希望担心这些导入是否正常工作。相反,应该假定它们是功能正常的,并独立进行测试。

你可能也不希望在测试过程中调用 mailer.send() 时实际发送一封电子邮件,因为该功能独立于你正在测试的函数。

在这种情况下,通常的做法是模拟(Mock)这些依赖项,用一个返回受控值的“假”对象替换真实的导入对象。这样做,你就可以在不考虑另一个模块行为的情况下,在测试目标函数中触发特定的状态。

这是一个相当基础的场景,说明了模拟的用处。然而,本文的其余部分将更深入地探讨可用于模拟模块并使用这些模拟来测试特定场景的不同模式和工具。

设置 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 的内容替换为以下内容

The createUser 函数执行以下操作

  1. 接收一个 user 参数
  2. user 传递给 prisma.user.create 函数
  3. 返回响应,该响应应该是新的用户对象

接下来,你将为这个新函数编写一个测试。这个测试将确保在提供有效用户时,createUser 返回预期的数据:新的用户对象。

更新 test/sample.test.ts,使其与下面的代码片段匹配

注意: 上面的测试没有使用模拟的 Prisma Client。它使用了真实的客户端实例来演示你在针对真实数据库进行测试时可能遇到的问题。

假设你的数据库中还没有任何用户记录,那么第一次运行此测试时应该会通过。但存在一些问题

  • 下次运行此测试时,创建的用户 id 将不再是 1,导致测试失败。
  • 你的 Prisma schema 中的 email 字段具有 @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 中,添加以下内容

上面的代码片段执行以下操作

  1. 导入创建模拟客户端所需的所有工具。
  2. 告诉 Vitest 在每个单独的测试之间,模拟应该重置回其原始状态。
  3. 使用 vitest-mock-extended 库的 mockDeep 函数创建并导出了一个 Prisma Client 的“深度模拟”,该函数确保对象的所有属性(即使是深度嵌套的属性)都被模拟。

注意: 本质上,mockDeep 会将每个 Prisma Client 函数的值设置为 Vitest 辅助函数:vi.fn()

此时,如果你再次运行测试(使用 npm t),你应该会发现不再收到之前的错误了!但还有一个问题……

Failed test

查询返回 `undefined`

实际上,这个错误发生是因为模拟已经正确到位了。你在 script.ts 中调用的 prisma.user.create 不再访问数据库。目前,那个函数实际上什么也没做,并返回 undefined

你需要通过模拟(Mocking)其行为来告诉 Vitest prisma.user.create 应该做什么。既然你已经有了正确的 Prisma Client 模拟版本,这只需要对你的测试进行简单的修改。

test/sample.test.ts 文件中,添加以下内容来告诉 Vitest 该函数在单个测试过程中应该如何行为

上面,导入了“假”客户端,因为它导出了 Prisma Client 的深度模拟。

在这个对象上,你会注意到每个 Prisma Client 属性和函数都附加了一组新的函数

Mock Functions

上面代码片段中使用的 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 函数。

结果是,unpublishedpublished 数组将包含完全相同的值,包括 published 属性中的 true 值。

为了在这种场景下生成更真实的响应,你可以使用另一个函数:mockResolvedValueOnce。这个函数可以被多次调用,以模拟一个函数的响应和后续调用的响应。

在你的 getPosts 函数中,你可以使用 mockResolvedValueOnce 来模拟该函数应该返回的第一个和第二个响应。

注意: Vitest 提供的许多函数都带有 mockXValueOnce 方法以及 mockXValue。请参阅文档了解更多详情。

触发和捕获错误

你可能想要测试的另一个场景是查询失败并返回或抛出错误的情况。一个很好的例子是 Prisma Client 的 findUniqueOrThrow 函数。

此函数搜索唯一的记录,如果找不到记录则抛出错误。然而,由于你的 Prisma Client 函数已被模拟,findUniqueOrThrow 函数不再那样行为。你必须手动触发错误状态。下面展示了如何测试此行为的示例

mockImplementation 允许你提供一个函数来替换被模拟函数的行为。在上面的例子中,替换函数只是简单地抛出一个错误。

虽然乍一看这可能有些繁琐,但在这种情况下手动定义函数的行为实际上是一个额外的优势。这允许你对函数在不同状态下的输出进行细粒度控制,即使是在错误状态下。

同样地,如果你正在测试的方法旨在抛出实际的错误,而不是返回与错误相关的某些消息,你也可以对此进行测试!

通过在 expect 函数的响应上使用 rejects 关键字,Vitest 知道要解析传递给 expectPromise 并查找错误响应。一旦 Promise 解析,toThrowtoThrowError 函数可以让你检查错误的具体细节。

模拟事务

Prisma Client 中你可能需要模拟的另一个部分是 $transaction

事务有不同的类型:顺序操作交互式事务。模拟它们的方式很大程度上取决于你的测试目标以及你使用 $transaction 的上下文。然而,有两种主要的方法可以模拟此函数。

对于顺序操作和交互式事务,完成的事务结果最终会从 $transaction 函数返回。如果你的测试只关心事务的结果,那么你的测试将与上面模拟函数响应的测试非常相似。

一个例子可能看起来像这样

在上面的测试中,你

  1. 模拟了你打算创建的帖子的数据。
  2. 模拟了从 $transaction 返回的响应应该是什么样的。
  3. 在 Prisma Client 方法被模拟后调用了函数。
  4. 确保函数返回的值与你期望的值匹配。

通过模拟 $transaction 函数本身的响应,您不必担心事务的顺序操作(或交互式事务,如果适用)内部发生了什么。

如果您想测试一个包含重要业务逻辑的交互式事务,并且需要验证它怎么办?这种方法将不起作用,因为它完全忽略了事务的内部工作原理。

为了测试包含重要业务逻辑的交互式事务,您可以编写如下所示的测试

这个测试稍微复杂一些,因为它需要考虑很多不同的活动部件。

以下是发生的情况

  1. post 和 response 对象被模拟。
  2. createcount 方法的响应被模拟。
  3. 模拟 $transaction 函数的实现,以便您可以将模拟的 Prisma Client 提供给交互式事务函数,而不是实际的客户端实例。
  4. 调用 addPost 方法。
  5. 验证响应的值,以确保交互式事务中的业务逻辑正常工作。更具体地说,它确保新帖子的 published 标志被设置为 true

Spy on methods(侦测方法)

您将探索的最后一个概念是侦测(spying)。Vitest 通过一个名为 TinySpy 的包,使您能够侦测(spy)一个函数。侦测允许您在代码执行过程中观察一个函数,并确定诸如:它被调用了多少次、传递给它的参数是什么、它返回的值是什么等等。

注意:侦测一个函数允许您在代码执行时观察函数的详细信息,而无需修改目标函数或其行为。

您可以使用 vi.spyOn() 侦测一个未模拟的函数,但是使用 vi.fn() 模拟的函数默认具有所有侦测功能。由于 Prisma Client 已经被模拟,每个函数都应该能够被侦测。

Spy functions.

下面是一个使用侦测(spy)的测试示例

这些侦测(spy)函数在您尝试确保基于各种输入触发特定场景时特别有用。

Why Vitest?(为何选择 Vitest?)

您可能很好奇为什么本文重点关注 Vitest 作为测试框架,而不是像 Jest 这样更成熟和流行的框架。

这一决定的理由与不同工具对 Node.js 的兼容性有关,特别是在处理 Error 对象时。Node.js 技术指导委员会成员之一 Matteo Collina(他还有许多其他了不起的成就)在他最近的一次直播中很好地描述了这个问题。

简而言之,问题在于 Jest 开箱即无法确定一个错误是否是 Error 类的实例。

这可能会在您为应用程序的不同情况编写测试时导致各种意想不到的问题。

How are they different?(它们有何不同?)

幸运的是,大多数测试框架都非常相似,概念可以相当无缝地迁移。例如,如果您习惯于使用 Jest 并正在考虑转向 Vitest 或 node-tap(另一个测试框架),您已有的知识将非常容易转移到新技术上。

只需要做非常微小的调整:例如函数命名约定和配置。

Should you ever use Jest?(您应该使用 Jest 吗?)

是的!Jest 是一个由非常有能力的人编写的优秀工具。虽然 Vitest 在测试 Node.js 后端应用程序时可能是“最适合的工具”,但 Jest 仍然完全能够胜任前端 JavaScript 应用程序的测试。

总结与下一步

在本文中,您重点学习了模拟(mocking)侦测(spying)的概念,这两个概念在应用程序的单元测试中都扮演着重要角色。具体来说,您探索了

  • 什么是模拟以及它为何有用
  • 如何使用 Vitest 和 Prisma 配置项目
  • 如何模拟 Prisma Client
  • 如何使用模拟的 Prisma Client 实例

通过这些知识和对测试世界的理解,您现在拥有了单元测试应用程序所需的工具集。在本系列的下一篇文章中,您将正是这样做!

我们希望您能继续阅读本系列的后续部分,我们将探索测试使用 Prisma Client 的应用程序的各种方法。

不要错过下一篇文章!

订阅 Prisma 时事通讯