精通 React 测试驱动开发第二版(三)
原文:
zh.annas-archive.org/md5/5e6d20182dc7eee4198d982cf82680c0译者:飞龙
第六章:探索测试替身
在本章中,我们将查看 TDD 谜题中最复杂的一部分:测试替身。
Jest 提供了一系列方便的测试替身函数,例如jest.spyOn和jest.fn。不幸的是,正确使用测试替身有点像一门黑暗的艺术。如果您不知道自己在做什么,可能会得到复杂、脆弱的测试。也许这就是为什么 Jest 没有将其作为其框架的一级特性来推广。
不要被吓倒:测试替身是一种高度有效且多功能的工具。诀窍是限制您的使用范围在一个小型、定义良好的模式集中,您将在接下来的几章中了解到这些模式。
在本章中,我们将构建自己的手工艺品测试替身函数集。它们几乎与 Jest 函数一样工作,但具有更简单(且更笨拙)的接口。目标是去除这些函数的魔力,向您展示它们是如何构建的以及如何使用它们来简化您的测试。
在您迄今为止构建的测试套件中,一些测试没有使用正常的expect.hasAssertions。在一个真实的代码库中,我会始终避免使用此函数,而是使用测试替身,这有助于将测试重新排序为 AAA 顺序。我们将从这里开始:重构现有测试以使用我们手工制作的测试替身,然后将其替换为 Jest 自己的测试替身函数。
本章将涵盖以下主题:
-
什么是测试替身?
-
使用间谍提交表单
-
监控 Fetch API
-
模拟
fetch响应 -
迁移到 Jest 内置的测试替身支持
到本章结束时,您将学会如何有效地使用 Jest 的测试替身功能。
技术要求
本章的代码文件可以在此处找到:
本章及以后的代码示例包含额外的提交,这些提交为应用程序添加了一个可工作的后端。这允许您发出请求以获取数据,您将在本章开始这样做。
在配套代码存储库中,从Chapter06/Start开始,npm run build命令将自动构建服务器。
然后,您可以通过使用npm run serve命令并浏览到http://localhost:3000或http://127.0.0.1:3000来启动应用程序。
如果您遇到问题
如果您无法运行应用程序,请查看存储库README.md文件的故障排除部分。
什么是测试替身?
单元测试中的单元指的是在测试期间我们关注的单个函数或组件。测试的行为阶段应该只涉及一个单元的一个动作。但单元不会孤立行动:函数调用其他函数,组件渲染子组件并调用从父组件传入的回调属性。可以将您的应用程序视为一个依赖关系的网络,测试替身帮助我们设计和测试这些依赖关系。
当我们编写测试时,我们会隔离被测试的单元。通常这意味着我们避免对任何协作对象进行操作。为什么?首先,这有助于我们朝着独立、专注的测试目标前进。其次,有时那些协作对象会有副作用,这会复杂化我们的测试。
以一个例子来说明,在 React 组件中,我们有时想避免渲染子组件,因为它们在挂载时会执行网络请求。
一个onSubmit函数,它被传递给CustomerForm和AppointmentForm两个组件。我们可以在测试中用测试替身替换它。正如我们将看到的,这有助于我们定义两者之间的关系。
在我们的系统中,测试替身最重要的使用地方是在与页面内容之外的任何外部事物交互的边缘:超文本传输协议(HTTP)请求、文件系统访问、套接字、本地存储等等。
测试替身被分为几种不同的类型:间谍、存根、模拟、哑元和伪造。我们通常只使用前两种,这也是本章我们将要集中讨论的内容。
学习避免伪造
伪造是指任何包含任何逻辑或控制结构的测试替身,例如条件语句或循环。其他类型的测试对象,如间谍和存根,完全由变量赋值和函数调用组成。
您会看到的一种伪造类型是内存中的存储库。您可以使用它来替代结构化查询语言(SQL)数据存储、消息代理和其他复杂的数据源。
伪造在测试两个单元之间的复杂协作时很有用。我们通常会先使用间谍和存根,然后在代码开始变得难以控制时重构为伪造。一个伪造可以覆盖一组测试,这比维护大量间谍和存根要简单。
我们避免使用伪造的原因如下:
-
任何逻辑都需要测试,这意味着我们必须为伪造编写测试,即使它们是测试代码的一部分。间谍和存根不需要测试。
-
通常,间谍和存根可以替代伪造。当我们使用伪造时,只有一小部分测试会更简单。
-
伪造增加了测试的脆弱性,因为它们在测试之间是共享的,而其他测试替身则不是。
既然我们已经涵盖了测试替身的理论,让我们继续在代码中使用它们。
使用间谍提交表单
在本节中,您将手动创建一个可重用的间谍函数,并调整您的测试以使它们回到 AAA 顺序。
这里是一个提醒,说明了CustomerForm测试套件中的一个测试看起来是怎样的。由于它被测试生成器包裹,所以有点复杂,但你现在可以忽略这一点——重要的是测试内容:
const itSubmitsExistingValue = (fieldName, value) =>
it("saves existing value when submitted", () => {
expect.hasAssertions();
const customer = { [fieldName]: value };
render(
<CustomerForm
original={customer}
onSubmit={(props) =>
expect(props[fieldName]).toEqual(value)
}
/>
);
click(submitButton());
});
这段代码有几个问题,如下所述:
-
测试的断言阶段——期望——似乎被包裹在行为阶段中。这使得测试难以阅读和理解。
-
调用
expect.hasAssertions很丑陋,它只在那里是因为我们的期望被作为onSubmit函数的一部分调用,而这个函数可能被调用也可能不被调用。
我们可以通过构建一个间谍来解决这两个问题。
什么是间谍?
间谍是一种测试双重角色,它记录了被调用的参数,以便稍后可以检查这些值。
解开 AAA
将期望放在传递给onSubmit函数的firstName值下面。然后我们针对存储的值编写期望。
让我们现在这样做,如下所示:
-
修改
test/CustomerForm.test.js中的saves existing value when submitted测试生成器函数,如下所示:const itSubmitsExistingValue = (fieldName, value) => it("saves existing value when submitted", () => { let submitArg; const customer = { [fieldName]: value }; render( <CustomerForm original={customer} onSubmit={submittedCustomer => ( submitArg = submittedCustomer )} /> ); click(submitButton()); expect(submitArg).toEqual(customer); });
submitArg变量在onSubmit处理程序中分配,然后在测试的最后一条语句中断言。这解决了第一个测试中的两个问题:我们的测试回到了 AAA 顺序,并且我们摆脱了丑陋的expect.hasAssertions()调用。
-
如果你现在运行测试,它们应该都是绿色的。然而,每次你以这种方式重构测试时,你应该通过展开生产代码并观察测试失败来验证你仍在测试正确的事情。为了检查我们的测试是否仍然有效,定位
src/CustomerForm.js中的这一行:<form id="customer" onSubmit={handleSubmit}>
完全移除onSubmit属性,如下所示:
<form id="customer">
-
运行
npm test。你会从不同的测试中得到多个测试失败。然而,我们只对这一个测试生成器感兴趣,所以将其声明更新为it.only而不是it,如下所示:it.only("saves existing value when submitted", () => { -
现在,你应该只有三个失败,每个字段使用此生成器函数一个,如下面的代码片段所示。这是一个好兆头;如果更少,我们就可能产生了假阳性:
FAIL test/CustomerForm.test.js ● CustomerForm › first name field › saves existing value when submitted expect(received).toEqual(expected) // deep equality Expected: {"firstName": "existingValue"} Received: undefined -
我们已经证明了测试是有效的,所以你可以继续将
it.only声明改回it,并重新插入你从CustomerForm.js中移除的onSubmit属性。
你在这个测试中编写的代码显示了间谍函数的本质:当间谍被调用时,我们设置一个变量,然后基于该变量的值编写期望。
但我们还没有一个真正的间谍函数。我们将在下一步创建它。
制作可重用的间谍函数
我们仍然在CustomerForm和AppointmentForm中都有其他使用expect.hasAssertions形式的测试。我们如何将这个测试中构建的内容重用于其他所有内容?我们可以创建一个通用的spy函数,这样我们就可以在需要间谍功能时使用它。
让我们首先定义一个可以替代任何单个参数函数的函数,例如我们传递给onSubmit表单属性的处理器,如下所示:
-
在
test/CustomerForm.test.js的顶部定义以下函数。注意fn定义的格式与我们在上一个测试中使用的onSubmit处理器相似:const singleArgumentSpy = () => { let receivedArgument; return { fn: arg => (receivedArgument = arg), receivedArgument: () => receivedArgument }; }; -
重新编写您的测试生成器以使用此函数。尽管您的测试应该仍然通过,但请记住通过撤销生产代码来观察测试失败。代码如下所示:
const itSubmitsExistingValue = (fieldName, value) => it("saves existing value when submitted", () => { const submitSpy = singleArgumentSpy(); const customer = { [fieldName]: value }; render( <CustomerForm original={customer} onSubmit={submitSpy.fn} /> ); click(submitButton()); expect(submitSpy.receivedArgument()).toEqual( customer ); }); -
通过将
singleArgumentSpy替换为以下函数,使你的间谍函数能够适用于任何数量的参数的函数:const spy = () => { let receivedArguments; return { fn: (...args) => (receivedArguments = args), receivedArguments: () => receivedArguments, receivedArgument: n => receivedArguments[n] }; };
这使用参数解构来保存整个参数数组。我们可以使用receivedArguments来返回该数组,或者使用receivedArgument(n)来检索第n个参数。
-
更新您的测试代码以使用这个新函数,如下面的代码片段所示。您可以在
receivedArguments上添加一个额外的期望来检查toBeDefined。这是一种表示“我期望函数被调用”的方式:const itSubmitsExistingValue = (fieldName, value) => it("saves existing value when submitted", () => { const submitSpy = spy(); const customer = { [fieldName]: value }; render( <CustomerForm original={customer} onSubmit={submitSpy.fn} /> ); click(submitButton()); expect( submitSpy.receivedArguments() ).toBeDefined(); expect(submitSpy.receivedArgument(0)).toEqual( customer ); });
间谍(spy)实际上很简单:它只是用来跟踪何时被调用以及调用时的参数。
使用 matcher 简化间谍期望
让我们编写一个 matcher,将这些期望封装成一个单独的语句,如下所示:
expect(submitSpy).toBeCalledWith(value);
这比在 matcher 上使用toBeDefined()参数更具有描述性。它还封装了如果receivedArguments尚未设置,则它尚未被调用的概念。
抛弃代码
我们将spike这段代码——换句话说,我们不会编写测试。这是因为不久之后,我们将用 Jest 的内置间谍功能来替换它。由于我们并不打算长时间保留它,所以深入到“真实”实现中是没有意义的。
我们将首先替换第一个期望的功能,如下所示:
-
在
test/domMatchers.js的底部添加以下代码。它添加了新的 matcher,为我们的测试做好准备:expect.extend({ toBeCalled(received) { if (received.receivedArguments() === undefined) { return { pass: false, message: () => "Spy was not called.", }; } return { pass: true, message: () => "Spy was called.", }; }, }); -
更新测试以使用新的 matcher,如下所示,替换使用
toBeDefined的第一个期望:const itSubmitsExistingValue = (fieldName, value) => it("saves existing value when submitted", () => { const submitSpy = spy(); const customer = { [fieldName]: value }; render( <CustomerForm original={customer} onSubmit={submitSpy.fn} /> ); click(submitButton()); expect(submitSpy).toBeCalled(customer); expect(submitSpy.receivedArgument(0)).toEqual( customer ); }); -
通过在您的生产代码中注释掉对
onSubmit的调用并观察测试失败来验证新的 matcher 是否工作。然后,取消注释并尝试在.not.toBeCalled测试中的否定形式。 -
现在,我们可以开始处理第二个期望——检查函数参数的期望。将以下代码添加到您的新 matcher 中,并将名称从
toBeCalled更改为toBeCalledWith:expect.extend({ toBeCalledWith(received, ...expectedArguments) { if (received.receivedArguments() === undefined) { ... } const notMatch = !this.equals( received.receivedArguments(), expectedArguments ); if (notMatch) { return { pass: false, message: () => "Spy called with the wrong arguments: " + received.receivedArguments() + ".", }; } return ...; }, });
在 matcher 中使用 this.equals
this.equals方法是一种特殊类型的相等函数,可以在 matcher 中使用。它执行深度相等匹配,这意味着它会递归遍历散列和数组以查找差异。它还允许使用expect.anything()、expect.objectContaining()和expect.arrayContaining()特殊函数。
如果你正在测试驱动此匹配器并将其提取到自己的文件中,你将不会使用 this.equals。相反,你会从 @jest/expect-utils 包中导入 equals 函数。我们将在 第七章* 测试 useEffect 和组件模拟* 中这样做。
-
更新你的测试以将这两个期望合并为一个,如下所示:
const itSubmitsExistingValue = (fieldName, value) => it("saves existing value when submitted", () => { ... click(submitButton()); expect(submitSpy).toBeCalledWith(customer); }); -
通过在
CustomerForm测试套件中更改onSubmit调用来发送明显错误的数据(例如,onSubmit(1, 2, 3))来使它失败。然后,也尝试匹配器的否定形式。
这完成了我们的监视器实现,你已经看到了如何测试回调属性。接下来,我们将探讨如何监视更复杂的函数:global.fetch。
监视 fetch API
在本节中,我们将使用 Fetch API 将客户数据发送到我们的后端服务。我们已经有了一个在表单提交时被调用的 onSubmit 属性。我们将在这个过程中将 onSubmit 调用转换为 global.fetch 调用,并调整我们的现有测试。
在我们更新的组件中,当通过 fetch 函数将 POST HTTP 请求发送到 /customers 端点时,请求体将是我们客户的 JavaScript 对象表示法(JSON)对象。
包含在 GitHub 仓库中的服务器实现将返回一个包含额外字段的更新后的 customer 对象:客户 id 值。
如果 fetch 请求成功,我们将调用一个新的 onSave 回调属性,并带有 fetch 响应。如果请求不成功,则不会调用 onSave,我们将渲染一个错误消息。
你可以将 fetch 视为 onSubmit 的更高级形式:两者都是我们将用客户对象调用的函数。但是 fetch 需要一组特殊的参数来定义正在进行的 HTTP 请求。它还返回一个 Promise 对象,因此我们需要考虑这一点,并且请求体需要是一个字符串,而不是一个普通对象,因此我们需要确保在我们的组件和测试套件中将其转换。
最后的一个区别是:fetch 是一个全局函数,可以通过 global.fetch 访问。我们不需要将其作为属性传递。为了监视它,我们用我们的监视器替换了原始函数。
理解 Fetch API
以下代码示例显示了 fetch 函数期望如何被调用。如果你不熟悉这个函数,请参阅本章末尾的 进一步阅读 部分。
考虑到所有这些,我们可以规划我们的前进路线:我们首先将全局函数替换为我们自己的监视器,然后添加新的测试以确保我们正确调用它,最后我们将更新 onSubmit 测试以调整其现有行为。
替换全局函数为监视器
我们已经看到了如何通过简单地将监视器作为回调属性的值来监视回调属性。要监视全局函数,我们只需在测试运行之前覆盖其值,并在之后将其重置回原始函数。
由于global.fetch是组件的必需依赖——没有它将无法工作——因此,在测试套件的beforeEach块中设置一个默认的 spy 是有意义的,这样 spy 就可以在所有测试中预先设置。beforeEach块也是设置 stubs 默认返回值的好地方,我们将在本章稍后进行操作。
按照以下步骤为你的测试套件在global.fetch上设置默认 spy:
-
在
test/CustomerForm.test.js的外部describe块的顶部添加以下声明:describe("CustomerForm", () => { const originalFetch = global.fetch; let fetchSpy; ... })
originalFetch常量将在测试完成后恢复 spy 时使用。fetchSpy变量将用于存储我们的fetch对象,这样我们就可以针对它编写期望。
-
将
beforeEach块修改如下。这将为你的测试套件中的每个测试设置global.fetch作为 spy:beforeEach(() => { initializeReactContainer(); fetchSpy = spy(); global.fetch = fetchSpy.fn; }); -
在
beforeEach块下方,添加一个afterEach块来取消 mock,如下所示:afterEach(() => { global.fetch = originalFetch; });
使用原始值重置全局 spies
重置任何用 spies 替换的全局变量是很重要的。这是测试相互依赖的常见原因:由于一个“脏”的 spy,一个测试可能会因为另一个测试未能重置其 spies 而失败。
在这个特定情况下,Node.js 运行时环境实际上没有global.fetch函数,所以originalFetch常量最终会是undefined。然后,你可以争论说这是不必要的:在我们的afterEach块中,我们只需简单地从global中删除fetch属性即可。
在本章的后面,我们将修改设置全局 spies 的方法,当我们使用 Jest 内置的 spy 函数时。
在全局 spy 就位后,你就可以在测试中使用它了。
测试驱动 fetch 参数值
是时候将global.fetch添加到我们的组件中了。当global.fetch使用正确的参数被调用时。类似于我们测试onSubmit的方式,我们将这个测试拆分为针对每个字段的测试,指定每个字段都必须传递。
结果表明global.fetch需要传递一大堆参数。我们不会在一个单独的单元测试中测试它们,而是根据它们的含义将测试拆分。
我们首先检查请求的基本情况:这是一个对/customers端点的POST请求。按照以下步骤操作:
-
在你的
CustomerForm测试套件的底部添加以下新测试。注意onSubmit被赋予了一个空函数定义—() => {}—而不是 spy,因为我们对这个测试中的属性不感兴趣:it("sends request to POST /customers when submitting the form", () => { render( <CustomerForm original={blankCustomer} onSubmit={() => {}} /> ); click(submitButton()); expect(fetchSpy).toBeCalledWith( "/customers", expect.objectContaining({ method: "POST", }) ); }); -
使用
npm test运行测试,并验证你是否收到一个期望失败的消息,显示为以下代码片段:● CustomerForm › sends request to POST /customers when submitting the form Spy was not called. 163 | ); 164 | click(submitButton()); > 165 | expect(fetchSpy).toBeCalledWith( | ^ 166 | "/customers", 167 | expect.objectContaining({ 168 | method: "POST", -
为了使其通过,通过在
onSubmit调用之前添加对global.fetch的调用,修改CustomerForm的handleSubmit函数,如下所示代码片段:const handleSubmit = (event) => { event.preventDefault(); global.fetch("/customers", { method: "POST", }); onSubmit(customer); };
并行实现
这是一个并行实现。我们保留“旧”实现——即对 onSubmit 的调用——以便其他测试继续通过。
-
在这个测试通过后,添加下一个测试。在这个测试中,我们测试了请求所需的全部管道,我们将其称为“配置”,但您可以将这视为将所有常量、不太相关的信息批量处理。这个测试还使用了两个新函数,
expect.anything和expect.objectContaining,如下面的代码片段所示:it("calls fetch with the right configuration", () => { render( <CustomerForm original={blankCustomer} onSubmit={() => {}} /> ); click(submitButton()); expect(fetchSpy).toBeCalledWith( expect.anything(), expect.objectContaining({ credentials: "same-origin", headers: { "Content-Type": "application/json", }, }) ); });
使用 expect.anything 和 expect.objectContaining 测试属性子集
expect.anything 函数是一种很有用的说法:“在这个测试中,我不关心这个参数;我在别处已经测试过了。”这是保持测试相互独立的好方法。在这种情况下,我们之前的测试检查第一个参数是否设置为 /customers,因此我们不需要在这个测试中再次测试这一点。
expect.objectContaining 函数与 expect.arrayContaining 函数类似,它允许我们测试完整参数值的一个子集。
-
运行该测试并观察测试失败。您可以在以下代码片段中看到,我们的匹配器在打印消息方面做得并不出色:第二个实际参数被打印为
[object Object]。现在我们先忽略这个问题,因为在本章的后面部分,我们将转向使用 Jest 的内置匹配器:● CustomerForm › calls fetch with the right configuration when submitting the form Spy was called with the wrong arguments: /customers,[object Object]. -
要使测试通过,只需将剩余的属性插入到您的
global.fetch调用中:const handleSubmit = (event) => { event.preventDefault(); global.fetch("/customers", { method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json" }, }); onSubmit(customer); };
这为我们的 global.fetch 调用设置了管道,每个常量参数都已定义并放在适当的位置。接下来,我们将添加动态参数:请求体。
使用并行实现重写现有测试
您已经通过使用新测试开始构建并行实现。现在,是时候重写现有测试了。我们将移除旧实现(在这个例子中是 onSubmit)并用新实现(global.fetch)替换它。
完成这一步后,所有测试都将指向 global.fetch,因此我们可以更新我们的实现,从 handleSubmit 函数中移除 onSubmit 调用。
我们有两个测试需要更新:一个是检查提交现有值的测试,另一个是检查提交新值的测试。由于它们被封装在测试生成函数中,这使得测试变得复杂。这意味着当我们修改它们时,我们应该预期所有生成的测试——每个字段一个——作为一个组都会失败。这不是理想的情况,但即使只是一个普通的测试,我们遵循的过程也会是相同的。
让我们从本章中已经练习过的测试开始,提交现有值。请按照以下步骤操作:
-
返回到
itSubmitsExistingValue测试生成函数,并在底部插入一个新的期望值。暂时保留现有的期望值。运行测试并确保生成的测试失败。代码如下所示:const itSubmitsExistingValue = (fieldName, value) => it("saves existing value when submitted", () => { const customer = { [fieldName]: value }; const submitSpy = spy(); render( <CustomerForm original={customer} onSubmit={submitSpy.fn} /> ); click(submitButton()); expect(submitSpy).toBeCalledWith(customer); expect(fetchSpy).toBeCalledWith( expect.anything(), expect.objectContaining({ body: JSON.stringify(customer), }) ); }); -
为了使它通过,更新你的
CustomerForm组件中的handleSubmit函数,如下所示代码片段。在此更改之后,你的测试应该会通过:const handleSubmit = (event) => { event.preventDefault(); global.fetch("/customers", { method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json" }, body: JSON.stringify(original), }); onSubmit(customer); }; -
对
onSubmit属性的最终测试引用是itSubmitsNewValue测试生成器。此测试仍然使用旧的expect.hasAssertions样式;我们稍后会删除它。现在,只需在测试底部添加一个新的期望,如下所示:const itSubmitsNewValue = (fieldName, value) => it("saves new value when submitted", () => { ... expect(fetchSpy).toBeCalledWith( expect.anything(), expect.objectContaining({ body: JSON.stringify({ ...blankCustomer, [fieldName]: value, }), }) ); }); -
运行测试并验证此测试失败,失败信息为
Spy was called with the wrong arguments: /customers,[object Object]。 -
为了使它通过,你需要在
handleSubmit函数中将original更改为customer,如下所示:const handleSubmit = (event) => { event.preventDefault(); global.fetch("/customers", { method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json" }, body: JSON.stringify(customer), }); onSubmit(customer); }; -
你对
fetch的调用现在已完成,因此你可以删除原始实现。首先从itSubmitsExistingValue测试生成器中移除onSubmit属性和submitSpy变量。新版本如下所示:const itSubmitsExistingValue = (fieldName, value) => it("saves existing value when submitted", () => { const customer = { [fieldName]: value }; render(<CustomerForm original={customer} />); click(submitButton()); expect(fetchSpy).toBeCalledWith( expect.anything(), expect.objectContaining({ body: JSON.stringify(customer), }) ); }); -
对于
itSubmitsNewValue也做同样的操作——你还可以删除hasAssertions调用。新版本如下所示:const itSubmitsNewValue = (fieldName, value) => it("saves new value when submitted", () => { render(<CustomerForm original={blankCustomer} />); change(field(fieldName), value); click(submitButton()); expect(fetchSpy).toBeCalledWith( expect.anything(), expect.objectContaining({ body: JSON.stringify({ ...blankCustomer, [fieldName]: value, }), }) ); }); -
从
handleSubmit方法中移除对onSubmit的调用。 -
从
CustomerForm组件定义中移除onSubmit属性。 -
最后,从
prevents the default action...测试中移除onSubmit属性。 -
使用
npm test验证所有测试是否通过。
你现在已经看到了如何通过重构测试来继续并行实现。一旦所有测试都已完成重构,你可以删除原始实现。
我们的测试又变得相当冗长。让我们通过一点清理来完成这一部分。
使用辅助函数改进间谍期望
当我们为我们的间谍编写期望时,我们不仅限于使用toBeCalledWith匹配器。我们可以提取参数并给它们命名,然后使用标准的 Jest 匹配器来处理它们。这样,我们可以避免使用expect.anything和expect.objectContaining的所有仪式。
让我们现在就做。按照以下步骤进行:
-
在
CustomerForm顶部添加一个新的辅助函数bodyOfLastFetchRequest,如下所示:const bodyOfLastFetchRequest = () => JSON.parse(fetchSpy.receivedArgument(1).body); -
更新你的
itSubmitsExistingValue测试生成器,使用这个新助手来简化其期望。注意这里使用了toMatchObject,它取代了之前版本此测试中的expect.objectContaining:const itSubmitsExistingValue = (fieldName, value) => it("saves existing value when submitted", () => { const customer = { [fieldName]: value }; render(<CustomerForm original={customer} />); click(submitButton()); expect(bodyOfLastFetchRequest()).toMatchObject( customer ); }); -
由于你已修改了测试,你应该验证它仍然测试正确的事情:将其标记为
it.only,然后从global.fetch调用中删除body属性。检查测试失败,然后撤销更改,使测试重新通过。 -
如此重复
itSubmitsNewValue测试生成器的操作,如下所示:const itSubmitsNewValue = (fieldName, value) => it("saves new value when submitted", () => { render(<CustomerForm original={blankCustomer} />); change(field(fieldName), value); click(submitButton()); expect(bodyOfLastFetchRequest()).toMatchObject({ [fieldName]: value, }); });
这些测试现在看起来非常聪明!
这是一次复杂的变更:我们用对global.fetch的调用替换了onSubmit属性。我们通过在beforeEach块中引入一个全局间谍并在重构测试的同时编写并行实现来完成这项工作。
在本章的下一部分,我们将扩展我们对间谍的了解,将它们变成存根。
模拟获取响应
就像许多 HTTP 请求一样,我们的 POST /customers 端点返回数据:它将返回客户对象以及后端为我们选择的新生成的标识符。我们的应用程序将通过获取新 ID 并将其发送回父组件来使用它(尽管我们不会在 第八章,构建应用程序组件)之前构建这个父组件)。
要做到这一点,我们将创建一个新的 CustomerForm 属性,onSave,它将使用 fetch 调用的结果被调用。
但是等等——我们不是刚刚移除了一个 onSubmit 属性吗?是的,但这不是同一回事。原始的 onSubmit 属性接收用户提交的表单值。这个 onSave 属性将接收在成功保存后从服务器返回的客户对象。
要为这个新的 onSave 属性编写测试,我们需要为 global.fetch 提供一个模拟值,这本质上意味着,“这是调用 POST /customers 端点时 global.fetch 的返回值。”
什么是模拟?
模拟是一个测试双胞胎,当它被调用时总是返回相同的值。你在构造模拟时决定这个值是什么。
在本节中,我们将升级我们手工制作的间谍函数,使其也能模拟函数返回值。然后,我们将使用它来测试 CustomerForm 中新 onSave 属性的添加。最后,我们将使用它来在服务器由于某种原因未能保存新的客户对象时向用户显示错误。
升级间谍为模拟
模拟与间谍不同,因为它对跟踪被模拟函数的调用历史不感兴趣——它只关心返回单个值。
然而,结果证明,我们现有的使用间谍的测试也需要模拟值。这是因为一旦我们在生产代码中使用返回值,间谍必须返回一些东西;否则,测试会失败。所以,所有间谍最终也变成了模拟。
由于我们已经有了一个 spy 函数,我们可以“升级”它,使其也有模拟值的能力。以下是我们可以这样做的方法:
-
在
test/CustomerForm.test.js中,将spy函数更改为在顶部包含以下新的变量声明。这个变量将存储值,以便我们的函数返回:let returnValue; -
将
fn定义更改为以下所示:fn: (...args) => { receivedArguments = args; return returnValue; }, -
将这个新函数添加到你的间谍对象中,该函数设置
returnValue变量:stubReturnValue: value => returnValue = value
就这么简单:你的函数现在既是间谍也是模拟。让我们在我们的测试中使用它。
对获取响应采取行动
到目前为止,handleSubmit 函数会导致发起一个 fetch 请求,但它对响应没有任何操作。特别是,它不会等待响应;fetch API 是异步的,并返回一个承诺。一旦这个承诺解决,我们就可以对返回的数据做些事情。
我们将要编写的下一个测试将指定我们的组件应该对解决的数据做什么。
act的异步形式
当我们在 React 回调中处理承诺时,我们需要使用act的异步形式。它看起来是这样的:
await act(async () => performAsyncAction());
performAsyncAction函数不一定需要返回一个承诺;act将在返回之前等待浏览器async任务队列完成。
动作可能是一个按钮点击、表单提交或任何类型的输入字段事件。它也可能是具有执行某些异步副作用(如加载数据)的useEffect钩子的组件渲染。
向现有组件添加异步任务
现在,我们将使用act的异步形式来测试fetch承诺是否被等待。不幸的是,将async/await引入我们的handleSubmit函数将需要我们更新所有提交测试以使用act的异步形式。
如同往常,我们从测试开始。按照以下步骤进行:
-
在
test/CustomerForm.test.js中定义一个测试辅助函数,该函数构建一个Response对象类型,以模拟fetchAPI 返回的内容。这意味着它返回一个具有ok属性值为true的Promise对象,以及一个json函数,该函数返回另一个Promise,当解决时返回我们传递的 JSON。你可以在你的spy函数下面定义这个,如下所示:const fetchResponseOk = (body) => Promise.resolve({ ok: true, json: () => Promise.resolve(body) });
fetch返回值
ok属性在 HTTP 响应状态码在2xx范围内时返回true。任何其他类型的响应,如404或500,都会导致ok为false。
-
在
test/reactTestExtensions.js中添加以下代码,位于click定义下方:export const clickAndWait = async (element) => act(async () => click(element)); -
现在,将新的辅助函数导入到
test/CustomerForm.test.js中,如下所示:import { ..., clickAndWait, } from "./reactTestExtensions"; -
将下一个测试添加到
CustomerForm测试套件中,该测试检查当用户提交表单时是否调用onSave属性函数,并返回客户对象。这个测试的最佳位置是在calls fetch with correct configuration测试之下。以下代码片段展示了代码示例:it("notifies onSave when form is submitted", async () => { const customer = { id: 123 }; fetchSpy.stubReturnValue(fetchResponseOk(customer)); const saveSpy = spy(); render( <CustomerForm original={customer} onSave={saveSpy.fn} /> ); await clickAndWait(submitButton()); expect(saveSpy).toBeCalledWith(customer); }); -
要使这个测试通过,首先在
src/CustomerForm.js中为CustomerForm定义一个新的onSave属性,如下所示:export const CustomerForm = ({ original, onSave }) => { ... }; -
在
handleSubmit的末尾添加以下代码。现在,该函数被声明为async,并使用await来展开global.fetch返回的承诺:const handleSubmit = async (event) => { event.preventDefault(); const result = await global.fetch(...); const customerWithId = await result.json(); onSave(customerWithId); }; -
如果你运行测试,你会注意到尽管你的最新测试通过了,但之前的测试失败了,并且有一大堆未处理的承诺异常。实际上,任何提交表单的操作都会失败,因为它们使用了在
beforeEach块中初始化的fetchSpy变量,而这不是一个存根——它只是一个普通的间谍。现在通过在beforeEach中给间谍一个返回值来修复这个问题。在这种情况下,我们不需要给它一个客户;一个空对象就足够了,以下代码片段展示了这一点:beforeEach(() => { ... fetchSpy.stubReturnValue(fetchResponseOk({})); });
beforeEach块中的占位值
当模拟全局函数,如global.fetch时,始终在beforeEach块中设置一个默认的虚拟值,然后在需要特定模拟值的单个测试中覆盖它。
-
再次运行测试。此时你可能会看到一些奇怪的行为;我看到我的最近测试据说运行了六次,并且失败了!发生的事情是,我们之前的测试现在正在触发一大堆承诺,即使在测试结束时这些异步任务仍在继续运行。这些异步任务导致 Jest 错误地报告失败。为了解决这个问题,我们需要更新所有测试以使用
await clickAndWait。此外,测试需要标记为async。现在为每个调用click的测试执行此操作。这里有一个示例:it("sends HTTP request to POST /customers when submitting data", async () => { render(<CustomerForm original={blankCustomer} />); await clickAndWait(submitButton()); ... }); -
删除
click导入,留下clickAndWait。 -
还有一个测试存在这个问题,那就是提交表单的测试:
在提交表单时阻止默认操作。这个测试调用了我们的submit辅助函数。我们也需要在act中包裹它。让我们在我们的测试扩展文件中创建一个submitAndWait辅助函数。将以下函数添加到submit下面,在test/reactTestExtensions.js中:export const submitAndWait = async (formElement) => act(async () => submit(formElement)); -
在你的
import语句中添加submitAndWait,在clickAndWait下面,如下所示:import { ..., submitAndWait, } from "./reactTestExtensions"; -
现在,你可以更新测试以使用新的辅助函数,如下所示:
it("prevents the default action when submitting the form", async () => { render(<CustomerForm original={blankCustomer} />); const event = await submitAndWait(form()); expect(event.defaultPrevented).toBe(true); }); -
如果你再次运行测试,我们仍然有测试失败(尽管幸运的是,
async任务被正确地考虑在内,事情看起来更有序)。你会看到现在有一大堆失败,说onSave 不是一个函数。为了解决这个问题,我们需要确保为每个提交表单的测试指定onSave属性。一个空的无操作函数就可以。这里有一个示例。现在将这个属性添加到每个提交表单的测试中。在这个更改之后,你的测试应该会通过,没有任何警告:it("calls fetch with correct configuration", async () => { render( <CustomerForm original={blankCustomer} onSave={() => {}} /> ); ... });
在需要时引入 testProps 对象
引入这个onSave无操作函数会导致噪音,这并不利于我们测试的可读性。这是一个引入testProps对象的绝佳机会,正如在第五章中所述,添加复杂表单交互。
-
添加另一个测试以确保在
fetch响应有错误状态(换句话说,当ok属性设置为false)时,我们不调用onSave。首先定义另一个辅助函数fetchResponseError,在fetchResponseOk下面,如下代码片段所示。这个不需要体,因为我们目前对它不感兴趣:const fetchResponseError = () => Promise.resolve({ ok: false }); -
在下一个
CustomerForm测试中使用这个新函数,如下所示:it("does not notify onSave if the POST request returns an error", async () => { fetchSpy.stubReturnValue(fetchResponseError()); const saveSpy = spy(); render( <CustomerForm original={blankCustomer} onSave={saveSpy.fn} /> ); await clickAndWait(submitButton()); expect(saveSpy).not.toBeCalledWith(); });
取反 toBeCalledWith
这个期望并不是我们真正想要的:如果我们仍然调用onSave但传递了错误的参数——例如,如果我们写了onSave(null),这个期望就会通过。我们真正想要的是.not.toBeCalled(),这将导致onSave以任何形式被调用时失败。但我们还没有构建这个匹配器。在本章的后面,我们将通过移动到 Jest 的内置 spy 函数来修复这个期望。
-
要使这通过,将
onSave调用移动到handleSubmit中的新条件中,如下所示:const handleSubmit = async (event) => { ... const result = ...; if (result.ok) { const customerWithId = await result.json(); onSave(customerWithId); } };
正如你所看到的,将组件从同步行为移动到异步行为真的会扰乱我们的测试套件。上面概述的步骤是这种情况所需工作的典型步骤。
异步组件操作可能导致 Jest 测试失败报告不准确
如果你看到测试失败而感到惊讶,并且无法解释为什么它失败了,请仔细检查测试套件中的所有测试,以确保在需要时使用了act的异步形式。Jest 在测试以异步任务完成时不会警告你,并且由于你的测试使用的是共享的 DOM 文档,这些异步任务将影响后续测试的结果。
这些就是处理测试中异步行为的基本方法。现在,让我们对我们的实现添加一些细节。
向用户显示错误
如果 fetch 返回的ok值为false,则向用户显示错误。这会在 HTTP 状态码返回在4xx或5xx范围内时发生,尽管对于我们的测试,我们不需要担心具体的状态码。遵循以下步骤:
-
将以下测试添加到
test/CustomerForm.test.js。这个测试检查页面上是否显示了错误区域。它依赖于 ARIA 角色alert,这是屏幕阅读器的特殊标识符,表示该区域可能改变以包含重要信息:it("renders an alert space", async () => { render(<CustomerForm original={blankCustomer} />); expect(element("[role=alert]")).not.toBeNull(); }); -
要使这通过,首先,定义一个新的
Error组件,如下所示。这可以放在src/CustomerForm.js中,正好在CustomerForm组件本身之上:const Error = () => ( <p role="alert" /> ); -
然后,在
CustomerForm的 JSX 中添加该组件的一个实例,正好在form元素顶部,如下所示:<form> <Error /> ... </form> -
回到
test/CustomerForm.test.js,添加下一个测试,该测试检查警告中的错误信息,如下所示:it("renders error message when fetch call fails", async () => { fetchSpy.mockReturnValue(fetchResponseError()); render(<CustomerForm original={blankCustomer} />); await clickAndWait(submitButton()); expect(element("[role=alert]")).toContainText( "error occurred" ); }); -
要使这通过,我们只需要在
Error组件中硬编码字符串。我们将使用另一个测试来三角定位以到达真正的实现,如下所示:const Error = () => ( <p role="alert"> An error occurred during save. </p> ); -
将最后的测试添加到
test/CustomerForm.test.js中,如下所示:it("initially hano text in the alert space", async () => { render(<CustomerForm original={blankCustomer} />); expect(element("[role=alert]")).not.toContainText( "error occurred" ); }); -
要使这通过,在
CustomerForm定义的顶部引入一个新的error状态变量,如下所示:const [error, setError] = useState(false); -
修改
handleSubmit函数,如下所示:const handleSubmit = async (event) => { ... if (result.ok) { ... } else { setError(true); } } -
在组件的 JSX 中,更新
Error实例以包括新的hasErrorprop 并将其设置为error状态,如下所示:<form> <Error hasError={error} /> ... </form> -
剩下的只是用新的 prop 完成
Error组件,如下所示:const Error = ({ hasError }) => ( <p role="alert"> {hasError ? "An error occurred during save." : ""} </p> );
我们的CustomerForm实现到此结束。现在是时候对我们的测试进行一点清理了。
在嵌套的 describe 上下文中对 stub 场景进行分组
一种常见的做法是使用嵌套的describe块来设置存根值作为一组测试的场景。我们刚刚编写了四个测试,这些测试处理了POST /customers端点返回错误的场景。其中两个是嵌套describe上下文的良好候选。
然后,我们可以将存根值拉入一个beforeEach块中。让我们从describe块开始。按照以下步骤进行:
-
看看您最后写的四个测试。其中两个是关于警报空间的,与错误情况无关。保留这两个测试,并将另外两个移动到一个新的
describe块中,命名为when POST requests return an error,如下所示:it("renders an alert space", ...) it("initially has no text in the alert space", ...) describe("when POST request returns an error", () => { it("does not notify onSave if the POST request returns an error", ...) it("renders error message when fetch call fails", ...) }); -
注意,两个测试描述是如何重复的,它们以不同的方式说同样的话,就像
describe块一样?从两个测试描述中删除if/when语句,如下所示:describe("when POST request returns an error", () => { it("does not notify onSave", ...) it("renders error message ", ...) }); -
这两个测试具有相同的
global.fetch存根。将这个存根拉入一个新的beforeEach块中,如下所示:describe("when POST request returns an error", () => { beforeEach(() => { fetchSpy.stubReturnValue(fetchResponseError()); }); ... }) -
最后,从两个测试中删除存根调用,只留下
beforeEach块中的存根调用。
您现在已经看到了如何使用嵌套的describe块来描述特定的测试场景,这涵盖了所有基本的存根技术。在下一节中,我们将通过介绍 Jest 的自有存根和存根函数来继续我们的清理工作,这些函数比我们自己构建的稍微简单一些。
迁移到 Jest 的内置测试双倍支持
到目前为止,在本章中,您已经构建了自己的手工制作的存根函数,支持存根值和具有自己的匹配器。这样做的目的是为了教您如何使用测试双倍,并展示您将在组件测试中使用的基本存根和存根模式。
然而,我们的存根函数和toBeCalledWith匹配器还远远不够完善。与其在我们手工制作的版本上投入更多时间,现在切换到 Jest 的自有函数似乎更有意义。这些函数基本上与我们的spy函数以相同的方式工作,但有一些细微的差别。
本节首先概述了 Jest 的测试双倍功能。然后,我们将CustomerForm测试套件从我们手工制作的存根函数迁移出去。最后,我们将通过提取更多测试辅助工具进行一些清理。
使用 Jest 进行存根和存根
下面是 Jest 测试双倍支持的概述:
-
要创建一个新的存根函数,请调用
jest.fn()。例如,您可能编写const fetchSpy = jest.fn()。 -
要覆盖现有属性,请调用
jest.spyOn(object, property)。例如,您可能编写jest.spyOn(global, "fetch")。 -
要设置返回值,请调用
spy.mockReturnValue()。您也可以直接将此值传递给jest.fn()调用。 -
您可以通过链式调用
spy.mockReturnValueOnce()来设置多个返回值。 -
当您的函数返回承诺时,您可以使用
spy.mockResolvedValue()和spy.mockRejectedValue()。 -
要检查您的存根是否被调用,请使用
expect(spy).toBeCalled()。 -
要检查传递给您的间谍的参数,您可以使用
expect(spy).toBeCalledWith(arguments)。或者,如果您的间谍被多次调用,并且您想检查它最后一次被调用的情况,您可以使用expect(spy).toHaveLastBeenCalledWith(arguments)。 -
调用
spy.mockReset()将从间谍中移除所有模拟实现、返回值和现有的调用历史。 -
调用
spy.mockRestore()将移除模拟并恢复原始实现。 -
在您的
package.json文件的 Jest 配置部分,您可以将restoreMocks设置为true,这样在每次测试之后,使用jest.spyOn创建的所有间谍都将自动恢复。 -
当使用
toBeCalledWith时,您可以将expect.anything()作为参数值传递,表示您不关心该参数的值是什么。 -
您可以使用
expect.objectMatching(object)来检查一个参数是否具有您传递的对象的所有属性,而不是与该对象完全相等。 -
当您的间谍被多次调用时,您可以使用
spy.mock.calls[n]来检查特定调用传递的参数,其中n是调用编号(例如,calls[0]将返回第一次被调用的参数)。 -
如果您需要对特定参数执行复杂的匹配,您可以使用
spy.mock.calls[0][n],其中n是参数编号。 -
您可以使用
jest.mock()函数来模拟和间谍化整个模块,我们将在下一章中探讨这一点。
Jest API 提供了更多功能,但这些是核心特性,应该覆盖您的大多数测试驱动用例。
将测试套件迁移到使用 Jest 的测试双工支持
让我们将 CustomerForm 测试从我们手工制作的间谍函数中转换出来。我们将从 fetchSpy 变量开始。
我们将使用 jest.spyOn 来完成这项工作。它本质上创建了一个使用 jest.fn() 的间谍,并将其分配给 global.fetch 变量。jest.spyOn 函数会跟踪所有被间谍监视的对象,以便在没有我们干预的情况下自动恢复它们,使用 restoreMock 配置属性。
它还有一个特性,阻止我们间谍化任何不是函数的属性。这会影响我们,因为 Node.js 没有默认的 global.fetch 实现。我们将在下一组步骤中看到如何解决这个问题。
值得指出的是,jest.fn() 函数的一个非常出色的特性。返回的间谍对象既充当函数本身,也充当模拟对象。它是通过将一个特殊的 mock 属性附加到返回的函数上来实现这一点的。结果是,我们不再需要一个 fetchSpy 变量来存储我们的间谍对象。我们可以直接引用 global.fetch,正如我们即将看到的。
按照以下步骤操作:
-
更新
beforeEach块,如下所示。这使用mockResolvedValue来设置一个被 promise 包装的返回值(与mockReturnedValue相反,后者只是返回一个值,不涉及任何 promise):beforeEach(() => { initializeReactContainer(); jest .spyOn(global, "fetch") .mockResolvedValue(fetchResponseOk({})); }); -
CustomerForm测试套件中有两行遵循此模式:fetchSpy.stubResolvedValue(...);
将它们替换为以下代码:
global.fetch.mockResolvedValue(...);
-
有两个期望检查
fetchSpy。将expect(fetchSpy)替换为expect(global.fetch)。移除fetchSpy变量可以提供更好的可读性和对正在发生的事情的理解。以下是一个期望的例子:expect(global.fetch).toBeCalledWith( "/customers", expect.objectContaining({ method: "POST", }) ); -
bodyOflastFetchRequest函数需要更新以使用 Jest 间谍对象的mock属性。更新如下所示:const bodyOfLastFetchRequest = () => { const allCalls = global.fetch.mock.calls; const lastCall = allCalls[allCalls.length - 1]; return JSON.parse(lastCall[1].body); }; -
打开
package.json并添加restoreMocks属性,这确保在每个测试之后将global.fetch间谍重置到其原始设置。代码如下所示:"jest": { ..., "restoreMocks": true } -
这应该就是你的
global.fetch间谍的全部内容。你可以删除afterEach块、fetchSpy变量声明和originalFetch常量定义。 -
让我们继续到
saveSpy。回到你的CustomerForm测试套件中,找到表单提交时通知 onSave测试。按照以下代码片段更新它。我们正在用jest.fn()替换spy()的使用。注意我们不再需要将onSave属性设置为saveSpy.fn,而是直接设置为saveSpy:it("notifies onSave when form is submitted", async () => { const customer = { id: 123 }; global.fetch.mockResolvedValue( fetchResponseOk(customer) ); const saveSpy = jest.fn(); render( <CustomerForm original={blankCustomer} onSave={saveSpy} /> ); await clickAndWait(submitButton()); expect(saveSpy).toBeCalledWith(customer); }); -
为
如果 POST 请求返回错误则不通知 onSave测试重复此操作。 -
在测试套件的顶部删除你的
spy函数定义。 -
在
test/domMatchers.js中删除你的toBeCalledWith匹配器。 -
我们现在几乎接近一个可工作的测试套件。尝试运行你的测试——你会看到以下错误:
Cannot spy the fetch property because it is not a function; undefined given instead -
为了解决这个问题,我们需要让 Jest 认为
global.fetch确实是一个函数。最简单的方法是在测试套件启动时设置一个虚拟实现。创建一个test/globals.js文件,并在其中添加以下定义:global.fetch = () => Promise.resolve({}); -
现在,回到
package.json中,将此文件添加到setupFilesAfterEnv属性中,如下所示:"setupFilesAfterEnv": [ "./test/domMatchers.js", "./test/globals.js" ], -
使用
npm test运行所有测试。它们应该会通过。 -
最后还需要做一件清理工作。找到以下期望:
expect(saveSpy).not.toBeCalledWith();
如本章前面所述,这个期望是不正确的,我们之所以使用它,仅仅是因为我们手工制作的匹配器并没有完全支持这个用例。我们想要的期望是在任何形式下调用onSave时失败。现在我们正在使用 Jest 自己的匹配器,我们可以更优雅地解决这个问题。用以下代码替换这个期望:
expect(saveSpy).not.toBeCalled();
你的CustomerForm测试套件现在已经完全迁移。我们将以提取一些额外的辅助函数来结束本章。
提取 fetch 测试功能
CustomerForm不是唯一会调用fetch的组件:其中一个练习是更新AppointmentForm以将预约提交到服务器。将我们使用的通用代码抽取出来作为一个单独的模块是有意义的。按照以下步骤进行:
-
创建一个名为
test/spyHelpers.js的文件,并添加以下函数定义,它与测试套件中的函数相同,但这次标记为导出:export const bodyOfLastFetchRequest = () => { const allCalls = global.fetch.mock.calls; const lastCall = allCalls[allCalls.length - 1]; return JSON.parse(lastCall[1].body); }; -
创建一个名为
test/builders/fetch.js的文件,并将以下两个函数添加到其中:export const fetchResponseOk = (body) => Promise.resolve({ ok: true, json: () => Promise.resolve(body), }); export const fetchResponseError = () => Promise.resolve({ ok: false }); -
从
test/CustomerForm.test.js内删除那些定义,并用以下代码片段中的import语句替换它们。在此更改后,运行您的测试并检查它们是否仍然通过:import { bodyOfLastFetchRequest } from "./spyHelpers"; import { fetchResponseOk, fetchResponseError, } from "./builders/fetch"; -
最后,我们可以通过移除此处显示的
Promise.resolve调用来简化fetchResponseOk和fetchResponseError。这是因为 Jest 的mockResolvedValue函数将自动将值包装在一个承诺中:export const fetchResponseOk = (body) => ({ ok: true, json: () => Promise.resolve(body), }); export const fetchResponseError = () => ({ ok: false, }); -
确保您已运行所有测试,并且在继续之前状态为绿色。
现在,您已经准备好在 AppointmentForm 测试套件中重用这些函数。
摘要
我们刚刚探讨了测试替身及其如何用于验证与协作对象的交互,例如组件属性(onSave)和浏览器 API 函数(global.fetch)。我们详细研究了间谍和存根,这是您将使用的两种主要类型的替身。您还看到了如何使用并排实现作为一项技术,在您从一个实现切换到另一个实现时,保持测试失败在可控范围内。
尽管本章涵盖了您在处理测试替身时将使用的核心模式,但我们还有一个主要模式尚未介绍,那就是如何监视和模拟 React 组件。这就是我们将在下一章中探讨的内容。
练习
尝试以下练习:
-
向
CustomerForm测试套件添加一个测试,指定当表单在所有验证错误纠正后第二次提交时,错误状态会被清除。 -
更新
AppointmentForm测试套件以使用jest.fn()和jest.spyOn()。 -
扩展
AppointmentForm以使其通过向/appointments发送POST请求来提交预约。/appointments端点返回一个没有主体的201 Created响应,因此您不需要在响应对象上调用json或向onSave发送任何参数。
进一步阅读
更多信息,请参考以下资源:
- 一个速查表,显示了您在测试 React 代码库时需要的所有 Jest 模拟构造函数
reacttdd.com/mocking-cheatsheet
- 不同类型测试替身的好介绍
martinfowler.com/articles/mocksArentStubs.xhtml
- Fetch API 使用简介
第七章:测试 useEffect 和模拟组件
在上一章中,你看到了如何使用测试替身来验证用户操作(如点击提交按钮)时发生的网络请求。我们还可以使用它们来验证组件挂载时的副作用,例如当我们从服务器获取组件需要的数据时。此外,测试替身可以用来验证子组件的渲染。这两种用例通常与 容器组件 一起发生,容器组件负责简单地加载数据并将其传递给另一个组件进行显示。
在本章中,我们将构建一个新的组件,AppointmentsDayViewLoader,它从服务器加载当天的预约并将其传递给我们在 第二章 中实现的 AppointmentsDayView 组件,渲染列表和详情视图。通过这样做,用户可以查看今天发生的预约列表。
在本章中,我们将涵盖以下主题:
-
模拟子组件
-
使用
useEffect在挂载时获取数据 -
jest.mock调用的变体
这些可能是你在测试驱动 React 组件时遇到的最困难的任务。
技术要求
本章的代码文件可以在以下位置找到:github.com/PacktPublishing/Mastering-React-Test-Driven-Development-Second-Edition/tree/main/Chapter07
模拟子组件
在本节中,我们将使用 jest.mock 测试辅助函数来用模拟实现替换子组件。然后,我们将编写期望来检查我们是否向子组件传递了正确的属性,并且它是否正确地渲染在屏幕上。
但首先,让我们详细了解一下模拟组件是如何工作的。
如何模拟组件,以及为什么?
本章我们将构建的组件具有以下形状:
export const AppointmentsDayViewLoader = ({ today }) => {
const [appointments, setAppointments] = useState([]);
useEffect(() => {
// fetch data from the server
const result = await global.fetch(...);
// populate the appointments array:
setAppointments(await result.json());
}, [today]);
return (
<AppointmentsDayView appointments={appointments} />
);
};
其目的是显示给定日期的所有当前预约。然后,这些信息作为 today 属性传递给组件。组件的职责是从服务器获取数据,然后将其传递给之前构建并已测试过的 AppointmentsDayView 组件。
考虑我们可能需要的测试。首先,我们想要一个测试来证明 AppointmentsDayView 在没有显示任何预约的情况下加载。然后,我们想要一些测试来验证我们的 global.fetch 调用是否成功调用,并且返回的数据被传递到 AppointmentsDayView。
我们如何测试 AppointmentsDayView 是否以正确数据调用?我们可以在 AppointmentsDayView 的测试套件中重复一些已经编写的测试 – 例如,通过测试是否显示了一个预约列表,以及显示的相关预约数据。
然而,这样我们就会在我们的测试套件中引入重复。如果我们修改AppointmentsDayView的工作方式,我们将有两个地方需要更新测试。
另一个选择是使用间谍对象模拟组件。为此,我们可以使用jest.mock函数,与间谍一起使用。这将是这样看起来:
jest.mock("../src/AppointmentsDayView", () => ({
AppointmentsDayView: jest.fn(() => (
<div id="AppointmentsDayView" />
)),
}));
函数的第一个参数是要模拟的文件路径。它必须与传递给import语句的路径匹配。这个函数正在模拟整个模块:
import { MyComponent } from "some/file/path";
jest.mock("/some/file/path", ...);
describe("something that uses MyComponent", () => {
});
在前面的代码中,Jest 将这个调用提升到文件顶部,并挂钩到导入逻辑,以便当import语句运行时,你的模拟会被返回。
无论何时在测试套件或被测试的组件中引用AppointmentsDayView,你都会得到这个模拟值而不是真实的组件。而不是渲染我们的日视图,我们会得到一个具有AppointmentsDayView的id值的单个div。
第二个参数是AppointmentsDayView。
因为模拟定义被提升到文件顶部,所以你无法在这个函数中引用任何变量:它们在你运行函数之前还没有被定义。然而,你可以编写 JSX,就像我们在这里所做的那样!
组件模拟设置的复杂性
这段代码非常晦涩难懂,我知道。幸运的是,你通常只需要写一次。当我需要在一个测试套件中引入一个新的模拟时,我经常发现自己是在复制粘贴模拟。我会查找我在其他测试套件中写的上一个模拟,并将其复制过来,更改相关细节。
所以,现在有一个大问题:你为什么要这样做?
首先,使用模拟可以通过鼓励具有独立表面的多个测试套件来改进测试组织。如果父组件和其子组件都是非平凡组件,那么为这些组件提供两个单独的测试套件可以帮助减少测试套件的复杂性。
父组件的测试套件将只包含少量测试,以证明子组件已被渲染并传递了预期的属性值。
通过在父组件的测试套件中模拟子组件,你实际上是在说,“我现在想忽略这个子组件,但我保证我会在其他地方测试其功能!”
另一个原因是,你可能已经对子组件进行了测试。这就是我们发现自己所处的场景:我们已经有AppointmentsDayView的测试,所以除非我们想要重复,否则在它被使用的地方模拟组件是有意义的。
这个原因的扩展是使用库组件。因为它们是由别人构建的,所以你有理由相信它们已经过测试并且能正确工作。而且由于它们是库组件,它们可能已经执行了一些相当复杂的操作,所以在你的测试中渲染它们可能会产生意外的副作用。
可能你有一个库组件,它可以构建各种复杂的 HTML 小部件,而你不想让测试代码知道这一点。相反,你可以将其视为黑盒。在这种情况下,最好是验证传递给组件的属性值,再次相信该组件按预期工作。
库组件通常具有复杂的组件 API,允许以多种方式配置组件。模拟组件允许你编写合同测试,确保你正确设置了属性。我们将在第十一章 测试驱动 React Router中看到这一点,当我们模拟 React Router 的Link组件时。
模拟组件的最后一个原因是它们在挂载时可能有副作用,例如执行网络请求以拉取数据。通过模拟组件,你的测试套件不需要考虑这些副作用。我们将在第八章 构建应用程序组件中这样做。
说了这么多,让我们开始构建我们的新组件。
测试初始组件属性
我们将首先为新的组件构建一个测试套件:
-
创建一个新文件,
test/AppointmentsDayViewLoader.js,并添加以下所有导入。我们不仅导入正在测试的组件(AppointmentsDayViewLoader),还导入我们将要模拟的子组件(AppointmentsDayView):import React from "react"; import { initializeReactContainer, render, element, } from "./reactTestExtensions"; import { AppointmentsDayViewLoader } from "../src/AppointmentsDayViewLoader"; import { AppointmentsDayView } from "../src/AppointmentsDayView"; -
在导入下面添加模拟设置:
jest.mock("../src/AppointmentsDayView", () => ({ AppointmentsDayView: jest.fn(() => ( <div id="AppointmentsDayView" /> )), })); -
从这里显示的第一个测试开始。这检查我们刚刚模拟的组件是否被渲染。模拟渲染一个具有
id值为AppointmentsDayView的div元素。测试使用element辅助函数查找id值,并检查它不为空:describe("AppointmentsDayViewLoader", () => { beforeEach(() => { initializeReactContainer(); }); it("renders an AppointmentsDayView", () => { await render(<AppointmentsDayViewLoader />); expect( element("#AppointmentsDayView") ).not.toBeNull(); }); });
使用 ID 属性
如果你熟悉用于识别组件的data-testid。如果你想使用这些模拟技术与 React Testing Library 一起使用,那么你可以使用data-testid而不是id属性,然后使用queryByTestId函数查找你的元素。
虽然通常建议不要在测试套件中选择元素时依赖data-testid,但这并不适用于模拟组件。你需要 ID 来区分它们,因为你可能会遇到多个由同一父组件渲染的模拟组件。给每个组件分配 ID 是找到这些 DOM 存在性测试的最简单方法。记住,模拟永远不会超出你的单元测试环境,所以使用 ID 没有害处。
更多关于使用 React Testing Library 的模拟策略的讨论,请访问reacttdd.com/mocking-with-react-testing-library。
-
让这个测试通过。创建一个新文件,
src/AppointmentsDayViewLoader.js,并继续填写实现,如下所示。它只是渲染组件,这正是测试所要求的:import React from "react"; import { AppointmentsDayView } from "./AppointmentsDayView"; export const AppointmentsDayViewLoader = () => ( <AppointmentsDayView /> ); -
下次测试的时间到了。我们将检查发送给函数的 props 的初始值。
AppointmentsDayView是我们期望的结果。我们将通过使用toBeCalledWith匹配器来实现这一点,我们已经使用过它了。注意expect.anything()的第二个参数值:这是必需的,因为 React 在渲染组件函数时传递第二个参数。你永远不会需要在你的代码中关心这个参数——这是 React 实现的一个内部细节——因此我们可以安全地忽略它。我们将使用expect.anything来断言我们不在乎那个参数是什么:
it("initially passes empty array of appointments to AppointmentsDayView", () => {
await render(<AppointmentsDayViewLoader />);
expect(AppointmentsDayView).toBeCalledWith(
{ appointments: [] },
expect.anything()
);
});
验证 props 及其在 DOM 中的存在
在这两个测试中,测试传递给模拟的 props 以及模拟值是否渲染在 DOM 中非常重要,正如我们所做的。在第八章 构建应用程序组件中,我们将看到一个案例,其中我们想要检查在用户操作后模拟组件是否已卸载。
-
通过更新你的组件定义来通过测试,如下所示:
export const AppointmentsDayViewLoader = () => ( <AppointmentsDayView appointments={[]} /> );
你刚刚使用了你的第一个模拟组件!你已经看到了如何创建模拟,以及验证其使用的两种类型的测试。接下来,我们将添加一个useEffect钩子,在组件挂载时加载数据,并通过appointments prop 传递它。
使用useEffect在挂载时获取数据
我们将加载的预约数据来自一个接受开始和结束日期的端点。这些值将结果过滤到特定的时间范围:
GET /appointments/<from>-<to>
我们的新组件接收一个today prop,它是一个包含当前时间值的Date对象。我们将从today prop 计算from和to日期,并构建一个 URL 传递给global.fetch。
要达到这个目标,首先,我们将简要介绍一些关于测试useEffect钩子的理论。然后,我们将实现一个新的renderAndWait函数,因为我们将在组件挂载时调用一个 promise。最后,我们将使用这个函数在我们的新测试中,构建完整的useEffect实现。
理解useEffect钩子
useEffect钩子是 React 执行副作用的方式。想法是,你提供一个函数,该函数将在钩子的任何依赖项更改时运行。这个依赖项列表是useEffect调用的第二个参数。
让我们再次看看我们的示例:
export const AppointmentsDayViewLoader = ({ today }) => {
useEffect(() => {
// ... code runs here
}, [today]);
// ... render something
}
当today prop 改变时,钩子代码将运行。这包括当组件首次挂载时。当我们进行测试驱动时,我们将从一个空的依赖项列表开始,然后使用一个特定的测试来强制在组件使用新的today prop 值重新挂载时刷新。
你传递给useEffect的函数应该返回另一个函数。这个函数执行清理操作:它在值改变时被调用,尤其是在钩子函数再次被调用之前,这使你能够取消任何正在运行的任务。
我们将在 第十五章 添加动画 中详细探讨这个返回函数。然而,现在你应该知道这会影响我们调用 Promise 的方式。我们不能这样做:
useEffect(async () => { ... }, []);
将外部函数定义为 async 意味着它返回一个 Promise,而不是一个函数。我们必须这样做:
useEffect(() => {
const fetchAppointments = async () => {
const result = await global.fetch(...);
setAppointments(await result.json());
};
fetchAppointments();
}, [today]);
当运行测试时,如果你在 useEffect Hook 内直接调用 global.fetch,你会收到 React 的警告。它会提醒你 useEffect Hook 不应该返回一个 Promise。
在 useEffect Hook 函数中使用设置器
React 保证像 setAppointments 这样的设置器保持静态。这意味着它们不需要出现在 useEffect 依赖列表中。
要开始我们的实现,我们需要确保我们的测试为运行 Promise 的 render 调用做好准备。
添加 renderAndWait 辅助函数
就像我们对 clickAndWait 和 submitAndWait 所做的那样,现在我们需要 renderAndWait。这将渲染组件,然后等待我们的 useEffect Hook 运行,包括任何 Promise 任务。
为了清晰起见,这个函数的必要性不是因为 useEffect Hook 本身——一个普通的同步 act 调用就能确保它运行——而是因为 useEffect 运行的 Promise:
-
在
test/reactTestExtensions.js中,在render定义下方添加以下函数:export const renderAndWait = (component) => act(async () => ( ReactDOM.createRoot(container).render(component) ) ); -
更新测试套件中的导入,使其引用这个新函数:
import { initializeReactContainer, renderAndWait, element, } from "./reactTestExtensions"; -
然后,更新第一个测试:
it("renders an AppointmentsDayView", async () => { await renderAndWait(<AppointmentsDayViewLoader />); expect( element("#AppointmentsDayView") ).not.toBeNull(); }); -
添加第二个测试,检查在服务器返回任何数据之前,我们是否向
AppointmentsDayView发送了一个空的预约数组:it("initially passes empty array of appointments to AppointmentsDayView", async () => { await renderAndWait(<AppointmentsDayViewLoader />); expect(AppointmentsDayView).toBeCalledWith( { appointments: [] }, expect.anything() ); });
在继续之前,请确保这些测试通过。
添加 useEffect Hook
我们即将引入一个带有对 global.fetch 调用的 useEffect Hook。我们首先将使用 jest.spyOn 模拟这个调用。然后,我们继续进行测试:
-
在测试套件的顶部添加以下新的导入:
import { todayAt } from "./builders/time"; import { fetchResponseOk } from "./builders/fetch"; -
在
describe块的顶部定义一组示例预约:describe("AppointmentsDayViewLoader", () => { const appointments = [ { startsAt: todayAt(9) }, { startsAt: todayAt(10) }, ]; ... }); -
要设置
global.fetch以返回此示例数组,修改测试套件的beforeEach块,如下所示:beforeEach(() => { initializeReactContainer(); jest .spyOn(global, "fetch") .mockResolvedValue(fetchResponseOk(appointments)); }); -
是时候进行我们的测试了。我们断言当组件挂载时,我们应该看到带有正确参数的
global.fetch调用。我们的测试计算了正确的参数值——应该是从今晚午夜到明天午夜:it("fetches data when component is mounted", async () => { const from = todayAt(0); const to = todayAt(23, 59, 59, 999); await renderAndWait( <AppointmentsDayViewLoader today={today} /> ); expect(global.fetch).toBeCalledWith( `/appointments/${from}-${to}`, { method: "GET", credentials: "same-origin", headers: { "Content-Type": "application/json" }, } ); }); -
要使这个测试通过,首先,我们需要在组件文件中引入一个 useEffect Hook:
import React, { useEffect } from "react"; -
现在,我们可以更新组件以进行调用,如下所示。尽管代码已经很多了,但请注意我们还没有使用
return返回值:没有存储任何状态,AppointmentsDayView仍然将appointments属性设置为空数组。我们稍后会填充它:export const AppointmentsDayViewLoader = ( { today } ) => { useEffect(() => { const from = today.setHours(0, 0, 0, 0); const to = today.setHours(23, 59, 59, 999); const fetchAppointments = async () => { await global.fetch( `/appointments/${from}-${to}`, { method: "GET", credentials: "same-origin", headers: { "Content-Type": "application/json" }, } ); }; fetchAppointments(); }, []); return <AppointmentsDayView appointments={[]} />; }; -
在继续下一个测试之前,让我们为
today属性设置一个默认值,这样任何调用者都不需要指定这个值:AppointmentsDayViewLoader.defaultProps = { today: new Date(), }; -
下一个测试将确保我们使用
global.fetch调用的返回值。注意我们如何使用toHaveBeenLastCalledWith匹配器。这很重要,因为组件的第一次渲染将是一个空数组。包含数据的是第二次调用:it("passes fetched appointments to AppointmentsDayView once they have loaded", async () => { await renderAndWait(<AppointmentsDayViewLoader />); expect( AppointmentsDayView ).toHaveBeenLastCalledWith( { appointments }, expect.anything() ); }); -
要使其通过,首先,更新你的组件的
import以引入useState函数:import React, { useEffect, useState } from "react"; -
现在,更新你的组件定义,如下所示:
export const AppointmentsDayViewLoader = ( { today } ) => { const [ appointments, setAppointments ] = useState([]); useEffect(() => { ... const fetchAppointments = async () => { const result = await global.fetch( ... ); setAppointments(await result.json()); }; fetchAppointments(); }, []); return ( <AppointmentsDayView appointments={appointments} /> ); };
这完成了基本的useEffect实现——我们的组件现在正在加载数据。然而,我们必须用useEffect实现来解决最后一个问题。
测试useEffect依赖列表
useEffect调用的第二个参数是一个依赖列表,它定义了应该导致效果重新评估的变量。在我们的例子中,today属性是重要的。如果组件以新的today值重新渲染,那么我们应该从服务器拉取新的预约。
我们将编写一个渲染组件两次的测试。这种测试在任何使用useEffect钩子的时候都非常重要。为了支持这一点,我们需要调整我们的渲染函数,确保它们只创建一个根:
-
在
test/reactTestExtensions.js中,添加一个名为reactRoot的新顶级变量,并更新initializeReactContainer以设置此变量:export let container; let reactRoot; export const initializeReactContainer = () => { container = document.createElement("div"); document.body.replaceChildren(container); reactRoot = ReactDOM.createRoot(container); }; -
现在,更新
render和renderAndWait的定义,使它们使用这个reactRoot变量。在做出这个更改后,你将能够在单个测试中重新挂载组件:export const render = (component) => act(() => reactRoot.render(component)); export const renderAndWait = (component) => act(async () => reactRoot.render(component)); -
在你的测试套件中,更新
import以包含today、tomorrow和tomorrowAt。我们将在下一个测试中使用这些:import { today, todayAt, tomorrow, tomorrowAt } from "./builders/time"; -
现在,添加测试。这会渲染组件两次,
today属性有两个不同的值。然后,它检查global.fetch是否被调用两次:it("re-requests appointment when today prop changes", async () => { const from = tomorrowAt(0); const to = tomorrowAt(23, 59, 59, 999); await renderAndWait( <AppointmentsDayViewLoader today={today} /> ); await renderAndWait( <AppointmentsDayViewLoader today={tomorrow} /> ); expect(global.fetch).toHaveBeenLastCalledWith( `/appointments/${from}-${to}`, expect.anything() ); }); -
如果你现在运行测试,你会看到
global.fetch只被调用了一次:AppointmentsDayViewLoader ' re-requests appointment when today prop changes expect( jest.fn() ).toHaveBeenLastCalledWith(...expected) Expected: "/appointments/1643932800000-1644019199999", Anything Received: "/appointments/1643846400000-1643932799999", {"credentials": "same-origin", "headers": {"Content-Type": "application/json"}, "method": "GET"} -
使其通过只需一个单词的更改。找到
useEffect调用的第二个参数,并将其从空数组更改为如下所示:useEffect(() => { ... }, [today]);
这就是本组件实现的全部内容。在下一节中,我们将使用一个新的匹配器清理我们的测试代码。
为组件模拟构建匹配器
在本节中,我们将介绍一个新的匹配器toBeRenderedWithProps,它简化了我们对模拟间谍对象的期望。
回想一下,我们的期望看起来是这样的:
expect(AppointmentsDayView).toBeCalledWith(
{ appointments },
expect.anything()
);
想象一下,如果你在一个有这种测试的团队中工作。新加入的人能理解第二个参数expect.anything()在做什么吗?如果你一段时间不回来,忘记了组件模拟的工作方式,你会理解这个吗?
让我们将它包装成一个匹配器,允许我们隐藏第二个属性。
我们需要两个匹配器来覆盖常见的用例。第一个,toBeRenderedWithProps,是我们将在本章中解决的问题。第二个,toBeFirstRenderedWithProps,留作你的练习。
匹配器 toBeRenderedWithProps 将在组件当前使用给定属性渲染时通过。这个函数将与使用 toHaveBeenLastCalledWith 匹配器等效。
这个匹配器的关键部分是当它从 mock.calls 数组中提取最后一个元素时:
const mockedCall =
mockedComponent.mock.calls[
mockedComponent.mock.calls.length – 1
];
mock.calls 数组
回想一下,每个使用 jest.spyOn 或 jest.fn 创建的模拟函数都将有一个 mock.calls 属性,它是一个包含所有调用的数组。这一点在 第六章 中有介绍,探索测试替身。
第二个匹配器是 toBeFirstRenderedWithProps。这对于任何检查子属性初始值并且在任何 useEffect 钩子运行之前的测试都很有用。我们不会选择 mock.calls 数组的最后一个元素,而是直接选择第一个:
const mockedCall = mockedComponent.mock.calls[0];
让我们从 toBeRenderedWithProps 开始:
-
在
test/matchers/toBeRenderedWithProps.test.js创建一个新的匹配器测试文件。添加以下导入:import React from "react"; import { toBeRenderedWithProps, } from "./toBeRenderedWithProps"; import { initializeReactContainer, render, } from "../reactTestExtensions"; -
添加以下测试设置。由于我们的测试将在一个间谍函数上操作,我们可以在
beforeEach块中设置它,如下所示:describe("toBeRenderedWithProps", () => { let Component; beforeEach(() => { initializeReactContainer(); Component = jest.fn(() => <div />); }); }); -
如同往常,我们的第一个测试是检查
pass返回true。注意我们必须在调用匹配器之前渲染组件:it("returns pass is true when mock has been rendered", () => { render(<Component />); const result = toBeRenderedWithProps(Component, {}); expect(result.pass).toBe(true); }); -
为了使这个测试通过,创建一个新的匹配器文件
test/matchers/toBeRenderedWithProps.js,并添加以下实现:export const toBeRenderedWithProps = ( mockedComponent, expectedProps ) => ({ pass: true }); -
是时候进行三角测量了。对于下一个测试,让我们检查在调用组件之前没有渲染它时,
pass是否为false:it("returns pass is false when the mock has not been rendered", () => { const result = toBeRenderedWithProps(Component, {}); expect(result.pass).toBe(false); }); -
为了让测试通过,我们只需检查模拟至少被调用了一次:
export const toBeRenderedWithProps = ( mockedComponent, expectedProps ) => ({ pass: mockedComponent.mock.calls.length > 0, }); -
接下来,我们需要检查如果属性不匹配,
pass是否为false。我们无法编写相反的测试——即如果属性匹配,则pass为true——因为根据我们当前的实现,这个测试已经通过了:it("returns pass is false when the properties do not match", () => { render(<Component a="b" />); const result = toBeRenderedWithProps( Component, { c: "d", } ); expect(result.pass).toBe(false); }); -
对于组件代码,我们将使用
expect-utils包内的equals函数,这个包已经作为 Jest 的一部分安装。这个函数测试深度相等,同时也允许你使用expect辅助函数,如expect.anything和expect.objectContaining:import { equals } from "@jest/expect-utils"; export const toBeRenderedWithProps = ( mockedComponent, expectedProps ) => { const mockedCall = mockedComponent.mock.calls[0]; const actualProps = mockedCall ? mockedCall[0] : null; const pass = equals(actualProps, expectedProps); return { pass }; }; -
对于我们的最终测试,我们想要一个例子来展示这个匹配器可以在模拟的最后渲染上匹配期望:
it("returns pass is true when the properties of the last render match", () => { render(<Component a="b" />); render(<Component c="d" />); const result = toBeRenderedWithProps( Component, { c: "d" } ); expect(result.pass).toBe(true); }); -
为了使这个测试通过,我们需要更新实现,使其选择
mock.calls数组的最后一个元素,而不是第一个:export const toBeRenderedWithProps = ( mockedComponent, expectedProps ) => { const mockedCall = mockedComponent.mock.calls[ mockedComponent.mock.calls.length – 1 ]; ... }; -
我们在这里留下我们的实现。完成消息属性的测试留作你的练习,但它们的顺序与 第三章 中显示的测试相同,重构测试套件。现在,转到
test/domMatchers.js并注册你的新匹配器:import { toBeRenderedWithProps, } from "./matchers/toBeRenderedWithProps"; expect.extend({ ..., toBeRenderedWithProps, }); -
最后,回到你的测试套件中,更新检查
appointments属性的测试。它应该看起来像这样;现在expect.anything参数值已经移除,它看起来更简洁了:it("passes fetched appointments to AppointmentsDayView once they have loaded", async () => { await renderAndWait(<AppointmentsDayViewLoader />); expect(AppointmentsDayView).toBeRenderedWithProps({ appointments, }); });
通过这样,你已经学会了如何为组件模拟构建一个匹配器,这减少了我们最初使用内置的toBeCalledWith匹配器时所拥有的冗余。
这个测试套件中的另一个测试需要一个第二个匹配器,toBeFirstRenderedWithProps。这个实现的细节留给你作为练习。
在下一节中,我们将探讨组件模拟可以变得更加复杂的各种方式。
jest.mock 调用的变体
在我们完成本章之前,让我们看看一些你可能最终会使用的jest.mock调用的变体。
需要记住的关键点是尽可能保持你的模拟简单。如果你开始觉得你的模拟需要变得更加复杂,你应该将其视为一个信号,表明你的组件过载了,并且应该以某种方式拆分。
话虽如此,有些情况下你必须使用基本组件模拟的不同形式。
移除间谍函数
首先,你可以通过不使用jest.fn来简化你的jest.mock调用:
jest.mock("../src/AppointmentsDayView", () => ({
AppointmentsDayView: () => (
<div id="AppointmentsDayView" />
),
}));
使用这种形式,你已经设置了一个存根返回值,但你将无法监视任何属性。如果,例如,你有多个文件正在测试同一个组件,但只有其中一些验证了模拟组件的属性,这有时是有用的。它也可以与第三方组件一起使用。
渲染模拟组件的子组件
有时候,你可能想要渲染孙组件,跳过子组件(它们的父组件)。这种情况经常发生,例如,当第三方组件渲染一个复杂且难以测试的 UI 时:例如,它可能通过阴影 DOM 加载元素。在这种情况下,你可以通过你的模拟传递children:
jest.mock("../src/AppointmentsDayView", () => ({
AppointmentsDayView: jest.fn(({ children }) => (
<div id="AppointmentsDayView">{children}</div>
)),
}));
我们将在第十一章中看到这个例子,测试驱动 React Router。
检查渲染组件的多个实例
有时候,你可能想要模拟一个被多次渲染到文档中的组件。你如何区分它们?如果它们有一个唯一的 ID 属性(如key),你可以在id字段中使用它:
jest.mock("../src/AppointmentsDayView", () => ({
AppointmentsDayView: jest.fn(({ key }) => (
<div id={`AppointmentsDayView${key}`} />
)),
}));
小心行事!
模拟组件的最大问题之一是模拟定义可能会失控。但是模拟设置很复杂,可能会非常令人困惑。因此,你应该避免编写除了最简单的模拟之外的内容。
幸运的是,大多数时候,组件模拟的普通形式就足够了。这些变体偶尔是有用的,但应该避免使用。
我们将在第十一章中看到这个变体的实际应用,测试驱动 React Router。
模块模拟的替代方案
模拟整个模块相当直接。你设置的模拟必须用于同一测试模块中的所有测试:你不能混合使用测试,一些使用模拟,一些不使用。如果你想要使用jest.mock来做这件事,你必须创建两个测试套件。一个会有模拟,另一个则不会。
您还遇到了模拟处于模块级别的问题。您不能只是模拟模块的一部分。Jest 有允许您引用原始实现的函数,称为requireActual。对我来说,这涉及到进入过于复杂的测试替身的风险区域,所以我避免使用它——我遇到了一个需要它的用例。
然而,使用jest.mock有替代方案。一个是浅渲染,它使用一个特殊的渲染器,只渲染单个父组件,忽略所有非标准 HTML 元素的子组件。从某种意义上说,这甚至更加直接,因为所有您的组件最终都会被模拟。
对于CommonJS模块,您也可以通过简单地给它们赋新值来覆盖模块内的特定导出!这为您在测试级别设置模拟提供了一个更细粒度的方法。然而,这在ECMAScript中不受支持,因此为了最大程度地发挥能力,您可能希望避免这种方法。
为了了解这些替代方法的示例以及何时可能需要使用它们,请参阅reacttdd.com/alternatives-to-module-mocks。
摘要
本章介绍了最复杂的模拟形式:使用jest.mock设置组件模拟。
由于模拟是一项复杂的艺术,因此最好坚持使用一组已建立的模式,我在本章中展示了这些模式。您还可以参考第十一章,测试驱动 React Router中的代码,以了解本章中描述的一些变体。
您还学习了如何在编写另一个匹配器之前测试驱动useEffect钩子。
现在,您应该对使用组件模拟测试子组件感到自信,包括通过useEffect动作将这些组件加载数据。
在下一章中,我们将通过从模拟组件中提取callback属性并在测试中调用它们来进一步扩展这项技术。
练习
以下是一些供您尝试的练习:
-
完成
toBeRenderedWithProps匹配器上的消息属性测试。 -
添加
toBeFirstRenderedWithProps匹配器,并更新您的测试套件以使用此匹配器。由于此匹配器与toBeRenderedWithProps非常相似,您可以将它添加到包含toBeRenderedWithProps匹配器的同一模块文件中。您还可以尝试将任何共享代码提取到其自己的函数中,这两个匹配器都可以使用。 -
添加一个
toBeRendered匹配器,该匹配器检查一个组件是否已渲染,而不检查其属性。 -
完成您编写的匹配器,以便在传递的参数不是 Jest 模拟时抛出异常。
-
创建一个新的组件,
AppointmentFormLoader,当组件挂载时调用GET /availableTimeSlots端点。它应该渲染一个AppointmentForm组件,并将其appointments属性设置为从服务器返回的数据。
进一步阅读
要了解如何在不依赖 jest.mock 的情况下模拟组件,请查看reacttdd.com/alternatives-to-module-mocks。
第八章:构建应用程序组件
您迄今为止构建的组件都是独立构建的:它们不能很好地结合在一起,用户在加载应用程序时没有遵循的工作流程。到目前为止,我们通过在src/index.js文件中替换组件来手动测试我们的组件。
在本章中,我们将通过创建一个根应用程序组件App,将所有这些组件整合到一个功能系统中,该组件将依次显示这些组件。
您现在已经看到了几乎所有的测试驱动开发(TDD)技术,这些技术对于测试驱动 React 应用程序都是必需的。本章将介绍最后一个技术:测试回调属性。
本章将涵盖以下主题:
-
制定计划
-
使用状态来控制活动视图
-
测试驱动回调属性
-
利用回调值
到本章结束时,您将学会如何使用模拟来测试应用程序的根组件,并且您将拥有一个将本书第一部分中您所工作的所有组件连接在一起的工作应用程序。
技术要求
本章的代码文件可以在以下位置找到:github.com/PacktPublishing/Mastering-React-Test-Driven-Development-Second-Edition/tree/main/Chapter08
制定计划
在我们深入到App组件的代码之前,让我们先进行一点前期设计,以便我们知道我们要构建什么。
以下图显示了您所构建的所有组件以及App如何将它们连接起来:
图 8.1 – 组件层次结构
这就是它的工作方式:
-
当用户首次加载应用程序时,他们将使用
AppointmentsDayView组件看到今天的预约列表,该组件的预约数据将由其容器AppointmentsDayViewLoader组件填充。 -
在屏幕顶部,用户将看到一个标签为
AppointmentsDayView的按钮消失,并出现CustomerForm。 -
当表单填写完毕并点击提交按钮时,用户将看到
AppointmentForm,并可以为该客户添加一个新的预约。 -
一旦他们添加了预约,他们将被带回到
AppointmentsDayView。
第一步如下截图所示。在这里,您可以看到右上角的新按钮。App组件将渲染此按钮,然后协调此工作流程:
图 8.2 – 显示在右上角的新按钮的应用程序
这是一个非常简单的流程,仅支持单一用例:同时添加新客户和预约。在本书的后面部分,我们将添加对为现有客户创建预约的支持。
到此为止,我们已经准备好构建新的App组件。
使用状态来控制活动视图
在本节中,我们将以通常的方式开始构建一个新的App组件。首先,我们将显示AppointmentsDayViewLoader组件。因为这个子组件在挂载时进行网络请求,所以我们将模拟它。然后,我们将在页面的顶部添加一个按钮,位于menu元素内。当这个按钮被点击时,我们将用CustomerForm组件替换AppointmentsDayViewLoader组件。
我们将引入一个名为view的状态变量,它定义了当前显示哪个组件。最初,它将设置为dayView。当按钮被点击时,它将更改为addCustomer。
JSX 构建将最初使用三元运算符在这两个视图之间切换。稍后,我们将添加一个名为addAppointment的第三个值。当我们这样做时,我们将“升级”我们的三元表达式为switch语句。
要开始,请按照以下步骤操作:
-
创建一个新文件
test/App.test.js,为新App组件添加以下导入:import React from "react"; import { initializeReactContainer, render, } from "./reactTestExtensions"; import { App } from "../src/App"; -
接下来,导入
AppointmentsDayViewLoader并模拟其实现:import { AppointmentsDayViewLoader } from "../src/AppointmentsDayViewLoader"; jest.mock("../src/AppointmentsDayViewLoader", () => ({ AppointmentsDayViewLoader: jest.fn(() => ( <div id="AppointmentsDayViewLoader" /> )), })); -
现在,让我们添加我们的第一个测试,该测试检查
AppointmentsDayViewLoader是否已渲染:describe("App", () => { beforeEach(() => { initializeReactContainer(); }); it("initially shows the AppointmentDayViewLoader", () => { render(<App />); expect(AppointmentsDayViewLoader).toBeRendered(); }); }); -
通过向新文件
src/App.js中添加以下代码来使该测试通过:import React from "react"; import ReactDOM from "react-dom"; import { AppointmentsDayViewLoader } from "./AppointmentsDayViewLoader"; export const App = () => ( <AppointmentsDayViewLoader /> ); -
对于第二个测试,我们将在页面的顶部添加一个菜单。为此,我们需要元素匹配器,所以将其添加到测试套件的导入中:
import { initializeReactContainer, render, element, } from "./reactTestExtensions"; -
添加第二个测试:
it("has a menu bar", () => { render(<App />); expect(element("menu")).not.toBeNull(); }); -
要使该测试通过,将
App组件更改为包括位于加载组件之上的menu元素:export const App = () => ( <> <menu /> <AppointmentsDayViewLoader /> </> ) -
接下来,我们希望在菜单中显示一个按钮,点击后可以切换到
CustomerForm。添加以下测试,它断言按钮出现在页面上,使用 CSS 选择器找到渲染的按钮元素。这使用了:first-of-type伪类来确保我们找到第一个按钮(在本书的后面部分,我们将向菜单中添加第二个按钮):it("has a button to initiate add customer and appointment action", () => { render(<App />); const firstButton = element( "menu > li > button:first-of-type" ); expect(firstButton).toContainText( "Add customer and appointment" ); }); -
要使该测试通过,将
App组件中的菜单更改为以下内容:<menu> <li> <button type="button"> Add customer and appointment </button> <li> </menu> -
对于下一个测试,我们必须检查点击按钮是否渲染
CustomerForm。我们必须模拟此组件。为此,我们需要已导入到测试套件中的组件。将以下行添加到test/App.test.js中:import { CustomerForm } from "../src/CustomerForm"; -
在此代码下方,添加以下模拟定义,这是我们的标准模拟定义:
jest.mock("../src/CustomerForm", () => ({ CustomerForm: jest.fn(() => ( <div id="CustomerForm" /> )), }));
为什么要模拟一个在挂载时没有影响的组件?
此组件已经有一个测试套件,这样我们就可以使用测试替身并验证正确的属性,以避免重新测试我们在其他地方已经测试过的功能。例如,CustomerForm测试套件有一个测试来检查提交按钮是否调用onSave属性并传递保存的客户对象。因此,而不是扩展App的测试范围以包括该提交功能,我们可以模拟该组件并直接调用onSave。我们将在下一节中这样做。
-
要点击按钮,我们需要我们的点击助手。现在将其引入:
import { initializeReactContainer, render, element, click, } from "./reactTestExtensions"; -
现在,添加测试。这引入了一个辅助函数
beginAddingCustomerAndAppointment,它找到按钮并点击它。我们现在将其提取出来,因为我们将在大多数剩余的测试中使用它:const beginAddingCustomerAndAppointment = () => click(element("menu > li > button:first-of-type")); it("displays the CustomerForm when button is clicked", async () => { render(<App />); beginAddingCustomerAndAppointment(); expect(element("#CustomerForm")).not.toBeNull(); }); -
使其通过需要添加一个组件状态来跟踪我们是否点击了按钮。在
src/App.js中,导入我们需要的两个钩子useState和useCallback,以及导入CustomerForm:import React, { useState, useCallback } from "react"; import { CustomerForm } from "./CustomerForm"; -
在
App组件中,定义新的视图状态变量并将其初始化为dayView字符串,我们将用它来表示AppointmentsDayViewLoader:const [view, setView] = useState("dayView"); -
在那下面,添加一个新的回调函数
transitionToAddCustomer,我们将在下一步将其附加到按钮的onClick处理程序。这个回调更新视图状态变量,使其指向第二页,我们将称之为addCustomer:const transitionToAddCustomer = useCallback( () => setView("addCustomer"), [] ); -
将其连接到按钮的
onClick属性:<button type="button" onClick={transitionToAddCustomer}> Add customer and appointment </button> -
现在,我们只剩下修改我们的 JSX,以确保当
view状态变量设置为addCustomer时,CustomerForm组件会被渲染。注意,测试并没有强迫我们隐藏AppointmentsDayViewLoader。这一点将在后续的测试中体现。目前,我们只需要最简单的代码来使测试通过。按照以下所示更新你的 JSX:return ( <> <menu> ... </menu> {view === "addCustomer" ? <CustomerForm /> : null} </> );
测试新组件的存在
严格来说,这不是使测试通过的最简单方法。我们可以通过总是渲染一个 CustomerForm 组件来使测试通过,无论 view 的值如何。然后,我们需要通过第二个测试来三角定位,以证明组件最初并没有被渲染。我跳过这一步以节省篇幅,但如果你愿意,可以添加它。
-
我们需要确保向
CustomerForm传递一个original属性。在这个工作流程中,我们正在创建一个新的客户,以便我们可以给它一个空白客户对象,就像我们在CustomerForm测试套件中使用的那样。在下面添加以下测试。我们将在下一步定义blankCustomer:it("passes a blank original customer object to CustomerForm", async () => { render(<App />); beginAddingCustomerAndAppointment(); expect(CustomerForm).toBeRenderedWithProps( expect.objectContaining({ original: blankCustomer }) ); }); -
创建一个新的文件,
test/builders/customer.js,并添加blankCustomer的定义:export const blankCustomer = { firstName: "", lastName: "", phoneNumber: "", }; -
将这个新定义导入到你的
App测试套件中:import { blankCustomer } from "./builders/customer";
值构建函数与函数构建函数
我们将 blankCustomer 定义为一个常量值,而不是一个函数。我们可以这样做,因为我们编写的所有代码都将变量视为不可变对象。如果不是这样,我们可能更喜欢使用一个函数 blankCustomer(),它在每次被调用时都会生成新的值。这样,我们可以确保一个测试不会意外地修改后续测试的设置。
-
让我们使那个测试通过。首先,在
src/App.js的顶部定义blankCustomer:const blankCustomer = { firstName: "", lastName: "", phoneNumber: "", };
在生产代码和测试代码中使用构建函数
现在,你的生产代码和测试代码中都有相同的 blankCustomer 定义。这种重复通常是可行的,特别是考虑到这个对象如此简单。但对于非平凡的构建函数,你应该考虑先进行测试驱动实现,然后在测试套件中充分利用它。
-
然后,只需通过将其设置为
CustomerForm的original属性来引用该值。进行此更改后,您的测试应该会通过:{view === "addCustomer" ? ( <CustomerForm original={blankCustomer} /> ) : null} -
接下来,添加以下测试以在添加客户时隐藏
AppointmentsDayViewLoader:it("hides the AppointmentsDayViewLoader when button is clicked", async () => { render(<App />); beginAddingCustomerAndAppointment(); expect( element("#AppointmentsDayViewLoader") ).toBeNull(); }); -
为了使测试通过,我们需要将
AppointmentsDayViewLoader移动到三元表达式中,以替换 null:{ view === "addCustomer" ? ( <CustomerForm original={blankCustomer} /> ) : ( <AppointmentsDayViewLoader /> )} -
让我们也将按钮栏隐藏:
it("hides the button bar when CustomerForm is being displayed", async () => { render(<App />); beginAddingCustomerAndAppointment(); expect(element("menu")).toBeNull(); }); -
为了解决这个问题,我们需要将三元表达式从 JSX 中完全提取出来,如下面的代码所示。这很混乱,但我们在下一节中会改进其实现:
return view === "addCustomer" ? ( <CustomerForm original={blankCustomer} /> ) : ( <> <menu> ... </menu> <AppointmentsDayViewLoader /> </> );
这样,您已经实现了工作流程中的第一步——即将屏幕从 AppointmentsDayViewLoader 组件更改为 CustomerForm 组件。您通过将 view 状态变量从 dayView 更改为 addCustomer 来完成此操作。对于下一步,我们将使用 CustomerForm 的 onSave 属性来提醒我们何时将 view 更新为 addAppointment。
测试驱动回调属性
在本节中,我们将介绍一个新的扩展函数 propsOf,它深入模拟的子组件并返回传递给它的属性。我们将使用它来获取 onSave 回调属性值,并在测试中调用它,模拟如果真实的 CustomerForm 已被提交会发生的情况。
值得重新审视我们为什么想这样做。直接深入组件并调用属性似乎很复杂。然而,替代方案更复杂,也更脆弱。
我们接下来要编写的测试是断言在 CustomerForm 提交并保存新客户后,AppointmentFormLoader 组件被显示:
it("displays the AppointmentFormLoader after the CustomerForm is submitted", async () => {
// ...
});
现在,假设我们想在没有模拟的 CustomerForm 的情况下测试这个功能。我们需要填写真实的 CustomerForm 表单字段并点击提交按钮。这可能看起来很合理,但我们会增加 App 测试套件的表面积,包括 CustomerForm 组件。任何对 CustomerForm 组件的更改不仅需要更新 CustomerForm 测试,现在还需要更新 App 测试。这正是我们将在 第九章 中看到的场景,表单验证,当我们更新 CustomerForm 以包括字段验证时。
通过模拟子组件,我们可以减少表面积并降低子组件更改时破坏测试的可能性。
模拟组件需要小心处理
即使是模拟组件,我们的父组件测试套件仍然可能受到子组件更改的影响。这可能发生在属性的含义发生变化时。例如,如果我们更新了 CustomerForm 上的 onSave 属性以返回不同的值,我们需要更新 App 测试以反映这一点。
这是我们需要做的事情。首先,我们必须在我们的扩展模块中定义一个propsOf函数。然后,我们必须编写模拟提交CustomerForm组件并将用户转移到AppointmentFormLoader组件的测试。我们将通过为视图状态变量引入一个新的addAppointment值来实现这一点。按照以下步骤操作:
-
在
test/reactTestExtensions.js中,添加以下propsOf的定义。它查找对模拟组件的最后调用,并返回其属性:export const propsOf = (mockComponent) => { const lastCall = mockComponent.mock.calls[ mockComponent.mock.calls.length – 1 ]; return lastCall[0]; }; -
在
test/App.test.js中,更新扩展导入,使其包括propsOf:import { initializeReactContainer, render, element, click, propsOf, } from "./reactTestExtensions"; -
你还需要从 React 的测试工具中导入
act函数。我们的测试将包装对回调属性的调用,以确保在调用返回之前运行任何设置器:import { act } from "react-dom/test-utils"; -
还有一个导入需要添加——
AppointmentFormLoader的导入:import { AppointmentFormLoader } from "../src/AppointmentFormLoader"; -
在那下面,使用标准的组件模拟定义定义它的模拟:
jest.mock("../src/AppointmentFormLoader", () => ({ AppointmentFormLoader: jest.fn(() => ( <div id="AppointmentFormLoader" /> )), })); -
我们几乎准备好进行测试了。不过,首先让我们定义一个辅助函数
saveCustomer。这是代码中调用属性的关键部分。注意,这设置了默认客户对象exampleCustomer。我们将使用这个默认值来避免在每个测试中指定客户,因为那里的值并不重要:const exampleCustomer = { id: 123 }; const saveCustomer = (customer = exampleCustomer) => act(() => propsOf(CustomerForm).onSave(customer));
在测试套件中使用 act
这是我们第一次自愿在我们的测试套件中留下对 act 的引用。在其他所有用例中,我们设法在我们的扩展模块中隐藏了对act的调用。不幸的是,这在这里是不可能的——至少,按照我们编写propsOf的方式是不可能的。另一种方法是将一个名为invokeProp的扩展函数写出来,它接受属性的名称并为我们调用它:
invokeProp(CustomerForm, "onSave", customer);
这种方法的缺点是,你现在已经将onSave从对象属性降级为字符串。所以,我们现在将忽略这种方法,并忍受在我们的测试套件中使用act。
-
让我们编写我们的测试。我们想要断言,一旦
CustomerForm被提交,AppointmentsFormLoader就会显示一次:it("displays the AppointmentFormLoader after the CustomerForm is submitted", async () => { render(<App />); beginAddingCustomerAndAppointment(); saveCustomer(); expect( element("#AppointmentFormLoader") ).not.toBeNull(); }); -
使这个通过将涉及向视图状态变量
addAppointment添加一个新值。有了这个第三个值,三元表达式就不再适合用途,因为它只能处理视图的两个可能值。所以,在我们继续使这个通过之前,让我们重构那个三元表达式,使其使用switch语句。跳过你刚刚编写的测试,使用it.skip。 -
用以下代码替换组件的返回语句:
switch (view) { case "addCustomer": return ( <CustomerForm original={blankCustomer} /> ); default: return ( <> <menu> <li> <button type="button" onClick={transitionToAddCustomer}> Add customer and appointment </button> </li> </menu> <AppointmentsDayViewLoader /> </> ); } -
一旦你验证了你的测试仍然通过,将你最新的测试从
it.skip改回it。 -
当
CustomerForm的onSave属性被调用时,组件应该更新视图到addAppointment。让我们用一个新的回调处理程序来实现这一点。在transitionToAddCustomer定义下面添加以下代码:const transitionToAddAppointment = useCallback( () => { setView("addAppointment") }, []); -
修改
CustomerForm渲染表达式,使其接受这个作为属性:<CustomerForm original={blankCustomer} onSave={transitionToAddAppointment} /> -
通过添加以下
case语句将新的addAppointment值连接起来。在做出这个更改后,你的测试应该会通过:case "addAppointment": return ( <AppointmentFormLoader /> ); -
对于下一个测试,我们需要为
original属性传递一个值,这次是传递给AppointmentFormLoader。注意expect.objectContaining的双重使用。这是必要的,因为我们的预约不会是一个简单的空白预约对象。这次,预约将传递一个客户 ID。这个客户 ID 是我们刚刚添加的客户 ID - 我们将在下一个测试中为它编写测试:it("passes a blank original appointment object to CustomerForm", async () => { render(<App />); beginAddingCustomerAndAppointment(); saveCustomer(); expect(AppointmentFormLoader).toBeRenderedWithProps( expect.objectContaining({ original: expect.objectContaining(blankAppointment), }) ); }); -
我们需要一个构建函数,就像
blankCustomer一样。创建一个新的文件,test/builders/appointment.js,并添加以下定义:export const blankAppointment = { service: "", stylist: "", startsAt: null, }; -
更新测试代码以导入它:
import { blankAppointment } from "./builders/appointment"; -
然后,在
src/App.js中创建相同的内容:const blankAppointment = { service: "", stylist: "", startsAt: null, }; -
最后,你可以通过设置
original属性来使测试通过,如下所示:<AppointmentFormLoader original={blankAppointment} />
我们几乎完成了 AppointmentFormLoader 的显示,但还不完全:我们仍然需要从 onSave 回调中接收客户 ID,并通过 original 属性值传递给它,这样 AppointmentForm 就知道我们正在为哪个客户创建预约。
利用回调值
在本节中,我们将介绍一个新的状态变量 customer,它将在 CustomerForm 接收到 onSave 回调时设置。之后,我们将在我们的工作流程中进行最后的转换,从 addAppointment 返回到 dayView。
按照以下步骤操作:
-
这次,我们将检查新的客户 ID 是否传递给了
AppointmentFormLoader。记得在上一节中我们如何给saveCustomer提供一个客户参数?我们将在本测试中使用它:it("passes the customer to the AppointmentForm", async () => { const customer = { id: 123 }; render(<App />); beginAddingCustomerAndAppointment(); saveCustomer(customer); expect(AppointmentFormLoader).toBeRenderedWithProps( expect.objectContaining({ original: expect.objectContaining({ customer: customer.id, }), }) ); }); -
为了实现这一点,我们需要为客户添加一个状态变量。在
App组件的顶部添加以下内容:const [customer, setCustomer] = useState(); -
当我们在 第六章 中构建
CustomerForm的onSave属性时,探索测试替身,我们传递了更新后的客户对象。更新transitiontoAddAppointment处理程序,使其接受此参数值并使用setCustomer设置器保存它:const transitionToAddAppointment = useCallback( (customer) => { setCustomer(customer); setView("addAppointment") }, []); -
通过创建一个新的
original对象值,将客户 ID 合并到blankAppointment中,将其传递给AppointmentFormLoader:case "addAppointment": return ( <AppointmentFormLoader original={{ ...blankAppointment, customer: customer.id, }} /> ); -
是时候对这个组件进行最后的测试了。我们通过断言一旦预约保存,视图就会更新回
dayView来完成用户工作流程:const saveAppointment = () => act(() => propsOf(AppointmentFormLoader).onSave()); it("renders AppointmentDayViewLoader after AppointmentForm is submitted", async () => { render(<App />); beginAddingCustomerAndAppointment(); saveCustomer(); saveAppointment(); expect(AppointmentsDayViewLoader).toBeRendered(); }); -
定义一个新的函数来将状态重置回
dayView:const transitionToDayView = useCallback( () => setView("dayView"), [] ); -
将此函数传递给
AppointmentsFormLoader以确保在预约保存时调用它。在此之后,你的测试应该完成并通过:case "addAppointment": return ( <AppointmentFormLoader original={{ ...blankAppointment, customer: customer.id, }} onSave={transitionToDayView} /> );
我们完成了!
现在,剩下的就是更新 src/index.js 以渲染 App 组件。然后,你可以手动测试以检查你的成果:
import React from "react";
import ReactDOM from "react-dom";
import { App } from "./App";
ReactDOM
.createRoot(document.getElementById("root"))
.render(<App />);
要运行应用程序,请使用npm run serve命令。有关更多信息,请参阅第六章**,探索测试替身部分中的技术要求部分,或查阅存储库中的README.md文件。
摘要
本章介绍了你将要学习的最终 TDD 技术——模拟组件回调属性。你学习了如何使用propsOf扩展获取组件回调的引用,以及如何使用状态变量来管理工作流程不同部分之间的转换。
你会注意到App中的所有子组件都被模拟了。这种情况通常发生在顶级组件中,其中每个子组件都是一个相对复杂、自包含的单元。
在本书的下一部分,我们将把所学的一切应用到更复杂的场景中。我们将首先将字段验证引入到我们的CustomerForm组件中。
练习
以下是一些供你尝试的练习:
-
更新你的
CustomerForm和AppointmentForm测试,以使用你创建的新构建器。 -
向
AppointmentForm添加一个测试,确保在表单提交时提交客户 ID。
第二部分 – 构建应用程序功能
这一部分基于你在第一部分中学到的基本技术,通过将它们应用于你在工作中会遇到的真实世界问题来应用它们,并介绍了许多 React 开发者使用的库:React Router、Redux 和 Relay(GraphQL)。目标是向你展示 TDD 工作流程甚至可以用于大型应用程序。
本部分包括以下章节:
-
第九章,表单验证
-
第十章,过滤和搜索数据
-
第十一章,测试驱动 React Router
-
第十二章,测试驱动 Redux
-
第十三章,测试驱动 GraphQL