2022年12月22日

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

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

The Ultimate Guide to Testing with Prisma: Mocking Prisma Client

目录

简介

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

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

Testing meme

警告:不好的建议 👆🏻

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

本文将专门深入探讨“模拟”主题,并逐步介绍如何模拟 Prisma Client。然后,您将了解模拟客户端可以做什么。

您将使用的技术

先决条件

假定知识

进入本系列前,具备以下知识会有帮助:

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

开发环境

要跟随提供的示例,您需要具备:

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

什么是模拟?

本系列中您将研究的第一个概念是“模拟”。这个术语指的是创建受控替代品来模拟一个对象的行为,使其与它所替代的真实对象类似。

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

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

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

此函数执行三个操作:

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

为了编写测试以验证此函数是否按预期运行,您可能会首先测试提供无效电子邮件地址的场景,并验证是否抛出了错误。

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

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

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

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

设置 Prisma 项目

在开始编写测试之前,您需要一个项目来实验。要设置一个项目,您将使用 try-prisma,这是一个允许您快速设置带有 Prisma 的示例项目的工具。

在终端中运行以下命令:

完成后,一个入门项目应该已在您当前工作目录中的名为 mocking_playground 的文件夹中设置好。

您还将在终端中看到其他输出,其中包含有关后续步骤的说明。按照这些说明进入您的项目并运行您的第一次 Prisma 迁移:

现在已经生成了一个 SQLite 数据库,您的模式已应用,并且 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 函数执行以下操作:

  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

您需要通过模拟其行为来告诉 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 在测试其余部分的响应。更具体地说,直到 mockReset 函数在 libs/__mocks__/prisma.ts 中被调用。

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

监听方法

您将探索的最后一个概念是“监听”。Vitest,通过一个名为 TinySpy 的包,为您提供了监听函数的能力。监听允许您在代码执行过程中观察函数,并确定诸如:它被调用了多少次,传递给它的参数是什么,它返回的值等等。

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

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

Spy functions.

以下是一个使用监听的测试示例:

这些监听函数在您试图确保某些场景根据各种输入触发时特别有用。

为什么选择 Vitest?

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

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

问题的症结在于 Jest 无法开箱即用地确定一个错误是否是 Error 类的实例。

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

它们有什么不同?

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

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

您应该使用 Jest 吗?

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

总结与展望

在本文中,您重点了解了“模拟”和“监听”的概念,两者在单元测试应用程序中都扮演着重要角色。具体来说,您探索了:

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

有了这些知识和测试领域的背景,您现在拥有了单元测试应用程序所需的工具集。在本系列的下一篇文章中,您将确切地做到这一点!

我们希望您能加入本系列的下一部分,探索测试使用 Prisma Client 的应用程序的各种方法。

不要错过下一篇文章!

注册 Prisma 新闻通讯

© . All rights reserved.