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,导致测试失败。
  • email 字段在您的 Prisma 架构中具有 @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 的响应,直到测试结束。更具体地说,直到在 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. 帖子和响应对象被模拟。
  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 应用程序时仍然绰绰有余。

总结和下一步

在本文中,您专注于模拟监听的概念,这两者在单元测试应用程序中都扮演着重要角色。具体来说,您探索了

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

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

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

不要错过下一篇文章!

订阅 Prisma 新闻通讯

© . This site is unofficial and not affiliated with Prisma Data, Inc.