精通-React-测试驱动开发第二版-四-

43 阅读42分钟

精通 React 测试驱动开发第二版(四)

原文:zh.annas-archive.org/md5/5e6d20182dc7eee4198d982cf82680c0

译者:飞龙

协议:CC BY-NC-SA 4.0

第九章:表单验证

对于许多程序员来说,TDD 在涉及他们在培训环境中学习的玩具程序时是有意义的。但当面对现实世界程序的复杂性时,他们发现很难将这些点连接起来。本书的这一部分的目的就是让你将学到的技术应用到现实世界的应用中。

本章将带我们进行一次关于表单验证的自我放纵之旅。通常,使用 React,你会寻找一个现成的表单库来处理验证。但在这个章节中,我们将亲手制作自己的验证逻辑,作为一个例子,说明如何用 TDD 克服现实世界的复杂性。

当处理像 React 这样的框架时,你将发现一个重要的架构原则:抓住每一个机会将逻辑从框架控制的组件中移出,放入普通的 JavaScript 对象中。

在本章中,我们将涵盖以下主题:

  • 执行客户端验证

  • 处理服务器错误

  • 指示表单提交状态

到本章结束时,你将看到如何使用测试将验证引入你的 React 表单中。

技术要求

本章的代码文件可以在这里找到:

github.com/PacktPublishing/Mastering-React-Test-Driven-Development-Second-Edition/tree/main/Chapter09

执行客户端验证

在本节中,我们将更新CustomerFormAppointmentForm组件,以便它们向用户提醒他们输入文本中可能存在的问题。例如,如果他们在电话号码字段中输入非数字字符,应用程序将显示错误。

我们将监听每个字段的 DOM 的blur事件,以获取当前字段值并对其运行验证规则。

任何验证错误都将存储为字符串,例如First name is required,在validationErrors状态变量中。每个字段在这个对象中都有一个键。未定义的值(或值的缺失)表示没有验证错误,而字符串值表示一个错误。以下是一个示例:

{
  firstName: "First name is required",
  lastName: undefined,
  phoneNumber: "Phone number must contain only numbers, spaces, and any of the following: + - ( ) ."
}

这个错误在浏览器中显示如下:

图 9.1 – 显示给用户的验证错误

图 9.1 – 显示给用户的验证错误

为了支持操作键盘焦点的测试,我们需要一个新函数来模拟当用户完成字段值时引发的focusblur事件。我们将把这个函数命名为withFocus。它将测试提供的操作(例如更改字段值)与focus/blur事件包装起来。

本节将首先检查CustomerForm的姓名字段是否提供。然后,我们将使这个验证通用化,使其适用于表单中的所有三个字段。之后,我们将确保在按下提交按钮时也运行验证。最后,我们将把构建的所有逻辑提取到一个单独的模块中。

验证必填字段

我们页面上的三个字段——firstNamelastNamephoneNumber——都是必填字段。如果任何字段没有提供值,用户应该看到一个消息告诉他们这一点。为此,每个字段都将有一个警告消息区域,实现为一个具有 ARIA 角色 alertspan

让我们先添加 firstName 字段的警告,然后通过在用户移除焦点时验证字段来使其生效:

  1. 将以下新测试添加到 CustomerForm 测试套件的底部。它应该在一个名为 validation 的新嵌套 describe 块中。此测试检查是否已渲染一个警告空间。注意 CSS 选择器:这有点像是一个技巧。我们主要感兴趣的是找到一个匹配 [role=alert] 的元素。然而,我们还在 firstNameError ID 上进行了限定,因为我们最终会有多个警告空间——每个字段一个:

    describe("validation", () => {
      it("renders an alert space for first name validation errors", () => {
        render(<CustomerForm original={blankCustomer} />);
        expect(
          element("#firstNameError[role=alert]")
        ).not.toBeNull();
      });
    });
    
  2. 要使那个通过,请转到 src/CustomerForm.js 并在 firstName 输入字段下方添加以下 span 定义:

    <input
      type="text"
      name="firstName"
      id="firstName"
      value={customer.firstName}
      onChange={handleChange}
    />
    <span id="firstNameError" role="alert" />
    
  3. 接下来,我们想要检查该字段是否有一个指向错误警告的 aria-describedby 字段。这有助于屏幕阅读器理解页面内容。在测试套件的底部添加以下新测试:

    it("sets alert as the accessible description for the first name field", async () => {
      render(<CustomerForm original={blankCustomer} />);
      expect(
        field(
          "firstName"
        ).getAttribute("aria-describedby")
      ).toEqual("firstNameError");
    });
    
  4. 要使这个通过,请将 aria-describedby 属性添加到 firstName 字段定义中:

    <input
      type="text"
      name="firstName"
      id="firstName"
      value={customer.firstName}
      onChange={handleChange}
      aria-describedby="firstNameError"
    />
    
  5. 我们将要编写的下一个测试将使用失焦 DOM 事件来触发验证。为此测试,我们将首先构建一个新的测试扩展,withFocus,它调用 focus 事件以确保目标元素有焦点,然后运行一个动作——例如在聚焦的字段中输入文本——最后调用 blur 事件。在 test/reactTestExtensions.js 中,为 withFocus 函数添加以下定义:

    export const withFocus = (target, fn) =>
      act(() => {
        target.focus();
        fn();
        target.blur();
      });
    

聚焦和失焦序列

初始调用 focus 是必要的,因为如果元素没有聚焦,JSDOM 会认为 blur 没有关系。

  1. test/CustomerForm.test.js 中,导入新的 withFocus 函数:

    import {
      ...,
      withFocus,
    } from "./reactTestExtensions";
    
  2. 在测试套件的底部(仍然在 validation 嵌套 describe 块中)添加以下新测试。它检查如果用户输入一个空白名称值,他们会看到一个消息告诉他们需要一个值:

    it("displays error after blur when first name field is blank", () => {
      render(<CustomerForm original={blankCustomer} />);
      withFocus(field("firstName"), () =>
        change(field("firstName"), " ");
      )
      expect(
        element("#firstNameError[role=alert]")
      ).toContainText("First name is required");
    });
    
  3. 要使这个通过,我们需要硬编码消息:

    <span id="firstNameError" role="alert">
      First name is required
    </span>
    
  4. 让我们通过替换硬编码来定位。以下测试断言警告消息最初是空的。注意使用 toEqual 而不是 not.toContainText:这是前瞻性规划。当我们来到下一节中泛化此函数时,警告文本可以是任何内容:

    it("initially has no text in the first name field alert space", async () => {
      render(<CustomerForm original={blankCustomer} />);
      expect(
        element("#firstNameError[role=alert]").textContent
      ).toEqual("");
    });
    

空文本内容的匹配器

虽然这本书没有涵盖,但这将是构建一个新的匹配器,如 toHaveNoTextnot.toContainAnyText 的好机会。

  1. 为了使这个测试通过,我们将在 CustomerForm 中添加运行验证规则的支持。首先,在 src/CustomerForm.js 的顶部添加以下内联函数定义,位于导入语句下方但 CustomerForm 组件定义上方。这是我们第一个验证规则,required,如果提供的值是空的,则返回一个错误字符串,否则返回 undefined

    const required = value =>
      !value || value.trim() === ""
        ? "First name is required"
        : undefined;
    
  2. CustomerForm 组件中,定义一个 validationErrors 状态变量,初始设置为空对象:

    const [
      validationErrors, setValidationErrors
    ] = useState({});
    
  3. CustomerForm 中创建一个处理函数,当用户从名字字段切换焦点时可以使用。它运行我们在第一步中定义的 required 验证,然后将响应保存到 validationErrors 状态对象中:

    const handleBlur = ({ target }) => {
      const result = required(target.value);
      setValidationErrors({
        ...validationErrors,
        firstName: result
      });
    };
    
  4. 接下来,定义一个函数,JSX 将使用它来选择要显示的消息,命名为 hasFirstNameError

    const hasFirstNameError = () =>
      validationErrors.firstName !== undefined;
    
  5. 剩下的只是修改我们的 JSX,使其调用验证逻辑,然后显示验证错误。使用以下代码设置现有输入字段 firstNameonBlur 处理程序,并在其后渲染错误文本。在此更改后,你的测试应该通过:

    <input
      type="text"
      name="firstName"
      ...
      onBlur={handleBlur}
    />
    <span id="firstNameError" role="alert">
      {hasFirstNameError()
        ? validationErrors["firstName"]
        : ""}
    </span>
    

现在你已经完成了一个用于验证名字字段的完整、可工作的系统。

为多个字段泛化验证

接下来,我们将添加对姓氏和电话号码字段的必要验证。

由于我们现在处于绿色状态,我们可以在编写下一个测试之前重构现有的代码。我们将更新 JSX 以及 hasFirstNameErrorhandleBlur 函数,以便它们适用于表单上的所有字段。

这将是一个系统重构的练习:将重构分解成小步骤。在每个步骤之后,我们都在努力使测试保持绿色:

  1. 首先,我们将提取一个包含 JSX 片段的函数,用于渲染错误。在 CustomerForm 中的 JSX 返回值上方添加一个名为 renderFirstNameError 的新函数,内容如下:

    const renderFirstNameError = () => (
      <span id="firstNameError" role="alert">
        {hasFirstNameError()
          ? validationErrors["firstName"]
          : ""}
      <span>
    );
    
  2. 现在,你可以在 JSX 中使用它来替换 span 警报。你的测试在每个步骤中都应该通过:

    <input
      type="text"
      name="firstName"
      ...
    />
    {renderFirstNameError()}
    
  3. 接下来,我们将向此函数引入一个参数,该参数将引用我们显示错误字段的 ID。调整你刚刚添加的行以引入新参数:

    <input
      type="text"
      name="firstName"
      ...
    />
    {renderFirstNameError("firstName")}
    

总是保持绿色测试 – JavaScript 与 TypeScript

这一节是以一种方式编写的,你的测试应该在每一步都通过。在上一个步骤中,我们向 renderFirstNameError 函数传递了一个函数无法接受的参数。在 JavaScript 中,这是完全正常的。在 TypeScript 中,当你尝试构建源代码时,你会得到一个类型错误。

  1. 将该参数引入 renderFirstNameError 函数中,如下所示,用 fieldName 变量替换 firstName 字符串。在此更改后,你的测试应该仍然通过:

    const renderFirstNameError = (fieldName) => (
      <span id={`${fieldName}Error`} role="alert">
        {hasFirstNameError()
          ? validationErrors[fieldName]
          : ""}
      <span>
    );
    
  2. 通过添加参数值重复相同的步骤为 hasFirstNameError 函数:

    const renderFirstNameError = (fieldName) => (
      <span id={`${fieldName}Error`} role="alert">
        {hasFirstNameError(fieldName)
          ? validationErrors[fieldName]
          : ""}
      <span>
    );
    
  3. fieldName参数添加到hasFirstNameError中,并修改函数体,使其使用参数代替firstName错误属性:

    const hasFirstNameError = fieldName =>
      validationErrors[fieldName] !== undefined;
    
  4. 现在,将renderFirstNameError重命名为renderError

hasFirstNameError重命名为hasError

在你的 IDE 中的重构支持

你的 IDE 可能内置了重命名支持。如果有的话,你应该使用它。自动重构工具可以减少人为错误的风险。

  1. 让我们处理handleBlur。我们已经在传递target参数,我们可以使用target.name来键入一个映射,然后告诉我们为每个字段运行哪个验证器:

    const handleBlur = ({ target }) => {
      const validators = {
        firstName: required
      };
      const result = 
        validatorstarget.name;
      setValidationErrors({
        ...validationErrors,
        [target.name]: result
      });
    };
    

如你所见,函数的前半部分(validators的定义)现在是静态数据,它定义了firstName的验证应该如何发生。这个对象将在以后扩展,包括lastNamephoneNumber字段。后半部分是通用的,将适用于任何传入的输入字段,只要存在该字段的验证器。

  1. required验证器硬编码了第一个名字的描述。让我们将整个消息作为一个变量提取出来。我们可以创建一个高阶函数,它返回一个使用此消息的验证函数。修改required,使其看起来如下:

    const required = description => value =>
      !value || value.trim() === ""
        ? description
        : undefined;
    
  2. 最后,更新验证器,使其调用这个新的必需函数:

    const validators = {
      firstName: required("First name is required")
    };
    

到目前为止,你的测试应该通过,你应该有一个完全通用的解决方案。现在,让我们通过将我们的四个验证测试转换为测试生成器函数来通用化测试:

  1. validations嵌套的describe块顶部定义一个新的errorFor辅助函数。这将在测试生成器中使用:

    const errorFor = (fieldName) =>
      element(`#${fieldName}Error[role=alert]`);
    
  2. 在本节中找到你编写的第一个测试(渲染一个警告空间...)。按照这里所示,通过将其包裹在一个函数定义中来修改它,该函数定义接受一个fieldName参数。在测试描述和期望中使用该参数,替换firstName的使用,并利用新的errorFor辅助函数来查找适当的字段:

    const itRendersAlertForFieldValidation = (fieldName) => {
      it(`renders an alert space for ${fieldName} validation errors`, async () => {
        render(<CustomerForm original={blankCustomer} />);
        expect(errorFor(fieldName)).not.toBeNull();
      });
    };
    
  3. 由于你现在丢失了第一个名字的测试,使用新的测试生成器将其添加回来,就在它下面:

    itRendersAlertForFieldValidation("firstName");
    
  4. 对第二个测试重复相同的步骤:将其包裹在一个函数定义中,引入一个fieldName参数,并在测试描述和期望中将firstName替换为fieldName

    const itSetsAlertAsAccessibleDescriptionForField = (
      fieldName
    ) => {
      it(`sets alert as the accessible description for the ${fieldName} field`, async () => {
        render(<CustomerForm original={blankCustomer} />);
        expect(
          field(fieldName).getAttribute(
            "aria-describedby"
          )
        ).toEqual(`${fieldName}Error`);
      });
    };
    
  5. 然后,重新引入firstName字段的测试用例:

    itSetsAlertAsAccessibleDescriptionForField(
      "firstName"
    );
    
  6. 接下来,是时候处理最复杂的测试——在失去焦点后显示错误...测试。前两个测试生成器只使用了一个参数,fieldName。这个需要一个额外的两个参数,valuedescription,分别在行为阶段和断言阶段使用:

    const itInvalidatesFieldWithValue = (
      fieldName,
      value,
      description
    ) => {
      it(`displays error after blur when ${fieldName} field is '${value}'`, () => {
        render(<CustomerForm original={blankCustomer} />);
        withFocus(field(fieldName), () =>
          change(field(fieldName), value)
        );
        expect(
          errorFor(fieldName)
        ).toContainText(description);
      });
    };
    
  7. 在那个测试生成器定义下方,重新引入first name字段的测试用例:

    itInvalidatesFieldWithValue(
      "firstName",
      " ",
      "First name is required"
    );
    
  8. 最后,对第四个测试重复相同的步骤:

    const itInitiallyHasNoTextInTheAlertSpace = (fieldName) => {
      it(`initially has no text in the ${fieldName} field alert space`, async () => {
        render(<CustomerForm original={blankCustomer} />);
        expect(
          errorFor(fieldName).textContent
        ).toEqual("");
      });
    };
    
  9. 然后,重新引入firstName测试用例:

    itInitiallyHasNoTextInTheAlertSpace("firstName");
    
  10. 在所有这些努力之后,现在是时候使用新的测试生成器来构建lastName字段的验证了。在你的测试套件底部添加以下单行:

    itRendersAlertForFieldValidation("lastName");
    
  11. 为了使其通过,只需将代码添加到CustomerForm JSX 中,在lastName字段下方渲染另一个警告:

    <label htmlFor="lastName">Last name</label>
    <input
      type="text"
      name="lastName"
      id="lastName"
      value={customer.lastName}
      onChange={handleChange}
    />
    {renderError("lastName")}
    
  12. 接下来,我们必须创建aria-describedby属性的测试。

    itSetsAlertAsAccessibleDescriptionForField(
      "lastName"
    );
    
  13. 为了使其通过,将此属性添加到lastName输入元素中:

    <input
      type="text"
      name="lastName"
      ...
      aria-describedby="lastNameError"
    />
    
  14. 接下来,添加对必需验证规则的测试。

    itInvalidatesFieldWithValue(
      "lastName",
      " ",
      "Last name is required"
    );
    
  15. 在我们已经做了这么多艰苦工作的基础上,使这个测试通过现在变得非常简单。将lastName条目添加到validators对象中,如下所示:

    const validators = {
      firstName: required("First name is required"),
      lastName: required("Last name is required"),
    };
    
  16. 为了完整性,我们需要为lastName字段添加第四个和最后一个测试。由于我们依赖于我们刚刚泛化的机制,这个测试已经通过了。然而,鉴于它是一行代码,即使不是必需的,也值得指定:

    itInitiallyHasNoTextInTheAlertSpace("lastName");
    
  17. 重复步骤 1016以对phone number字段进行测试。

谁需要测试生成器函数?

测试生成器函数可能看起来很复杂。你可能更喜欢在测试中保留重复,或者找到从测试中提取公共功能的其他方法。

测试生成器方法有一个缺点:你将无法在单个测试上使用it.onlyit.skip

这样,我们就涵盖了所需的字段验证。现在,让我们为phoneNumber字段添加不同类型的验证。我们希望确保电话号码只包含数字和一些特殊字符:括号、破折号、空格和加号。

为了做到这一点,我们将引入一个可以进行所需电话号码匹配的match验证器和一个组合验证的list验证器。

让我们添加第二个验证。

  1. 添加以下新测试:

    itInvalidatesFieldWithValue(
      "phoneNumber",
      "invalid",
      "Only numbers, spaces and these symbols are allowed: ( ) + -"
    );
    
  2. src/CustomerForm.js的顶部添加以下定义。这期望一个正则表达式re,然后可以将其与以下内容匹配:

    const match = (re, description) => value =>
      !value.match(re) ? description : undefined;
    

学习正则表达式

正则表达式是匹配字符串格式的灵活机制。如果你对它们感兴趣,并想了解更多关于它们以及如何测试驱动它们的信息,请查看reacttdd.com/testing-regular-expressions

  1. 现在,让我们来处理list验证函数。这是一段相当密集的代码,它返回一个短路验证器。它会运行它所提供的每个验证器,直到找到一个返回字符串的验证器,然后返回那个字符串。将此代码添加到match定义的下方:

    const list = (...validators) => value =>
      validators.reduce(
        (result, validator) => result || validator(value),
        undefined
      );
    
  2. 用以下验证替换handleBlur函数中现有的phoneNumber验证,该验证使用了所有三个验证器函数:

    const validators = {
      ...
      phoneNumber: list(
        required("Phone number is required"),
        match(
          /^[0-9+()\- ]*$/,
          "Only numbers, spaces and these symbols are allowed: ( ) + -"
        )
      )
    };
    
  3. 你的测试现在应该通过了。然而,如果你回顾我们刚才编写的测试,它并没有提到允许的字符集:它只是说invalid不是一个有效的电话号码。为了证明使用真实正则表达式,我们需要一个反向测试来检查任何字符组合都有效。你可以添加这个测试;它应该已经通过了:

    it("accepts standard phone number characters when validating", () => {
      render(<CustomerForm original={blankCustomer} />);
      withFocus(field("phoneNumber"), () =>
        change(field("phoneNumber"), "0123456789+()- ")
      );
      expect(errorFor("phoneNumber")).not.toContainText(
        "Only numbers" 
      );
    });
    

这是一个有效的测试吗?

这个测试通过而不需要任何修改。这违反了我们只编写失败的测试的规则。

我们陷入这种局面是因为我们在之前的测试中做得太多:我们只需要证明invalid字符串不是一个有效的电话号码。但相反,我们提前实现了完整的正则表达式。

如果我们“正确”地进行了三角测量,从一个虚拟的正则表达式开始,我们最终会到达现在的地方,但我们做了一大堆额外的中间工作,这些工作最终都被删除了。

在某些场景中,例如处理正则表达式时,我发现短路过程是可以接受的,因为它可以节省我一些工作。

通过这样,你已经学会了如何使用 TDD 来泛化验证。

提交表单

当我们提交表单时会发生什么?对于我们的应用程序,如果用户在表单完成之前点击提交按钮,提交过程应该被取消,并且所有字段应该一次性显示它们的验证错误。

我们可以通过两个测试来完成这个任务:一个测试用来检查在存在错误时表单不会被提交,另一个测试用来检查所有字段都显示错误。

在我们这样做之前,我们需要更新我们现有的提交表单的测试,因为它们都假设表单已经被正确填写。首先,我们需要确保我们传递有效的客户数据,这些数据可以在每个测试中被覆盖。

让我们开始编写CustomerForm测试套件:

  1. 我们需要一个新构建器来帮助表示validCustomer记录。我们将更新我们现有的许多测试以使用这个新值。在test/builders/customer.js中,定义以下对象:

    export const validCustomer = {
      firstName: "first",
      lastName: "last",
      phoneNumber: "123456789"
    };
    
  2. test/CustomerForm.test.js中,更新包含blankCustomer的导入,同时引入新的validCustomer

    import {
      blankCustomer,
      validCustomer,
    } from "./builders/customer";
    
  3. 从顶部开始,修改每个模拟提交事件的测试。每个都应该使用这个新的validCustomer对象进行挂载。在继续之前,在做出这些更改后运行你的测试,并确保它们仍然通过:

    render(<CustomerForm original={validCustomer} />);
    
  4. 添加一个新的表单提交测试。这个测试可以和其它提交测试放在一起,而不是在验证块中:

    it("does not submit the form when there are validation errors", async () => {
      render(<CustomerForm original={blankCustomer} />);
      await clickAndWait(submitButton());
      expect(global.fetch).not.toBeCalled();
    });
    
  5. 为了使这个测试通过,首先,在CustomerForm组件内部定义以下validateMany函数。它的任务是同时验证多个字段。它接受一个参数,fields,这是我们关心的字段值的对象:

    const validateMany = fields =>
      Object.entries(fields).reduce(
        (result, [name, value]) => ({
          ...result,
          [name]: validatorsname
        }),
        {}
      );
    
  6. validateMany函数引用了validators常量,但这个常量目前是在handleBlur函数中定义的。将这个定义拉上来,使其存在于组件作用域的顶部,现在handleBlurvalidateMany都可以访问它。

  7. 我们需要一个新函数来检查所有字段的错误。这就是anyErrors;现在添加它,如下所示。如果存在任何错误,它返回true,否则返回false

    const anyErrors = errors =>
      Object.values(errors).some(error => (
        error !== undefined
      )
    );
    
  8. 现在,我们可以在 handleSubmit 函数中使用 validateManyanyErrors,如下所示。我们将用条件包装大多数现有函数。添加此代码后,你的测试应该通过:

    const handleSubmit = async e {
      e.preventDefault();
      const validationResult = validateMany(customer);
      if (!anyErrors(validationResult)) {
        ... existing code ...
      }
    }
    
  9. 让我们继续下一个测试。我们需要几个新的导入,textOfelements,这样我们就可以在所有三个警报空间中编写一个期望。现在添加这些:

    import {
      ...,
      textOf,
      elements,
    } from "./reactTestExtensions";
    
  10. 接下来,在测试套件的底部添加以下测试。我们想要检查屏幕上是否出现任何错误:

    it("renders validation errors after submission fails", async () => {
      render(<CustomerForm original={blankCustomer} />);
      await clickAndWait(submitButton());
      expect(
        textOf(elements("[role=alert]"))
      ).not.toEqual("");
    });
    

在多个元素上使用警报角色

本章使用多个警报空间,每个表单字段一个。然而,当多个警报角色同时显示警报时,屏幕阅读器表现不佳——例如,如果点击提交按钮导致我们的三个字段都出现验证错误。

另一种方法是对 UI 进行重构,使其在检测到任何错误时具有一个额外的元素来承担警报角色;之后,它应该从各个字段错误描述中移除警报角色。

  1. 这个很容易通过;我们只需要在 anyErrors 返回 false 时,用 validationResult 调用 setValidationErrors 即可:

    if (!anyErrors(validationResult)) {
      ...
    } else {
      setValidationErrors(validationResult);
    }
    

你现在已经看到了如何在表单提交时运行所有字段验证。

将非 React 功能提取到新模块中

一个有用的设计指南是尽快走出“框架领域”。你希望处理的是纯 JavaScript 对象。这对于 React 组件来说尤其如此:尽可能多地提取逻辑到独立的模块中。

这有几个不同的原因。首先,测试组件比测试纯对象更难。其次,React 框架的变化比 JavaScript 语言本身更频繁。如果我们的代码库首先是一个 React 代码库,那么保持我们的代码库与最新的 React 趋势保持一致是一项大规模的任务。如果我们能保持 React 在一边,从长远来看,我们的生活将会更简单。因此,当有选择时,我们总是更喜欢编写纯 JavaScript。

我们的验证代码是这方面的绝佳例子。我们有一些完全不考虑 React 的函数:

  • 验证器:requiredmatchlist

  • hasErroranyErrors

  • validateMany

  • handleBlur 中的部分代码,它类似于 validateMany 的单入口等效

让我们将所有这些内容提取到一个名为 formValidation 的单独命名空间中:

  1. 创建一个名为 src/formValidation.js 的新文件。

  2. requiredmatchlist 的函数定义从 CustomerForm 的顶部移动过来。确保你删除了旧的定义!

  3. 在新模块的每个定义前添加单词 export

  4. CustomerForm 的顶部添加以下导入,然后检查你的测试是否仍然通过:

    import {
      required,
      match,
      list,
    } from "./formValidation";
    
  5. src/CustomerForm.js 中,修改 renderError 以使其将 state 中的错误传递到 hasError

    const renderError = fieldName => {
      if (hasError(validationErrors, fieldName)) {
        ...
     }
    }
    
  6. 更新hasError,使其包含新的validationErrors参数,并使用该参数而不是state

    const hasError = (validationErrors, fieldName) =>
      validationErrors[fieldName] !== undefined;
    
  7. 更新validateMany,使其将验证器列表作为其第一个参数传递,而不是使用state

    const validateMany = (validators, fields) =>
      Object.entries(fields).reduce(
       (result, [name, value]) => ({
        ...result,
        [name]: validatorsname
      }),
      {}
    );
    
  8. 更新handleBlur,使其使用validateMany

    const handleBlur = ({ target }) => {
      const result = validateMany(validators, {
        [target.name] : target.value
      });
      setValidationErrors({
        ...validationErrors,
        ...result
      });
    }
    
  9. 更新handleSubmit,使其将validators传递给validateMany

    const validationResult = validateMany(
      validators,
      customer
    );
    
  10. hasErrorvalidateManyanyErrors移动到src/formValidation.js中,确保您从CustomerForm组件中删除这些函数。

  11. 在每个定义前添加单词export

  12. 更新导入,以便引入这些函数:

    import {
      required,
      match,
      list,
      hasError,
      validateMany,
      anyErrors,
    } from "./formValidation";
    

虽然这足以将代码从 React 领域提取出来,但我们才刚刚开始。这个 API 还有很多改进的空间。这里有几个不同的方法可以采取。本章的练习包含了一些关于如何做到这一点的建议。

使用测试替身进行验证函数

你可能会想,这些函数现在需要它们自己的单元测试吗?我应该更新CustomerForm中的测试,以便使用测试替身代替这些函数吗?

在这种情况下,我可能会为formValidation编写几个测试,以便清楚地说明每个函数应该如何使用。这并不是测试驱动开发,因为你已经有了代码,但你仍然可以通过像平时一样编写测试来模拟这种体验。

当从像这样的组件中提取功能时,通常有更新原始组件以简化并可能移动测试的必要。在这种情况下,我不会费心去做。测试足够高级,无论代码内部如何组织,都是有意义的。

本节介绍了如何编写表单的验证逻辑。你现在应该对如何使用 TDD 来实现诸如字段验证等复杂要求有很好的了解。接下来,我们将把服务器端错误集成到相同的流程中。

处理服务器错误

如果客户数据验证失败,/customers端点可能会返回422 Unprocessable Entity错误。例如,如果电话号码已经在系统中存在,这可能会发生。如果发生这种情况,我们不想调用onSave回调,而是向用户显示错误,并给他们机会进行更正。

响应体将包含与为验证框架构建的数据非常相似的错误数据。以下是一个将接收到的 JSON 示例:

{
  "errors": {
    "phoneNumber": "Phone number already exists in the system"
  }
}

我们将更新我们的代码,以显示这些错误,就像我们的客户端错误出现时一样。由于我们已处理CustomerForm的错误,因此我们还需要调整我们的测试,以及现有的CustomerForm代码。

到目前为止,我们的代码使用了从global.fetch返回的ok属性。该属性在 HTTP 状态码为200时返回true,否则返回false。现在,我们需要更具体一些。对于状态码为422的情况,我们想要显示新的错误,而对于其他任何情况(如500错误),我们想要回退到现有的行为。

让我们添加对这些附加状态码的支持:

  1. 如下更新test/builders/fetch.js中的fetchResponseError方法:

    const fetchResponseError = (
      status = 500,
      body = {}
    ) => ({
      ok: false,
      status,
      json: () => Promise.resolve(body),
    });
    
  2. test/CustomerForm.test.js中为422错误编写一个测试。我已经将这个测试放在文件顶部,靠近其他操作 HTTP 响应的测试:

    it("renders field validation errors from server", async () => {
      const errors = {
        phoneNumber: "Phone number already exists in the system"
      };
      global.fetch.mockResolvedValue(
        fetchResponseError(422, { errors })
      );
      render(<CustomerForm original={validCustomer} />);
      await clickAndWait(submitButton());
      expect(errorFor("phoneNumber")).toContainText(
        errors.phoneNumber
      );
    });
    
  3. 要实现这个跳转,请向handleSubmit中的嵌套条件语句添加一个新的分支,该分支处理 fetch 请求的响应:

    if (result.ok) {
      setError(false);
      const customerWithId = await result.json();
      onSave(customerWithId);
    } else if (result.status === 422) {
      const response = await result.json();
      setValidationErrors(response.errors);
    } else {
      setError(true);
    }
    

你的测试现在应该通过了。

本节向您展示了如何将服务器端错误集成到您已经拥有的相同的客户端验证逻辑中。为了完成,我们将添加一些装饰。

指示表单提交状态

如果我们能向用户指示他们的表单数据正在发送到我们的应用程序服务器,那就太好了。这本书的 GitHub 仓库包含了一个旋转器图形和一些我们可以使用的 CSS。我们的 React 组件需要做的只是显示一个具有submittingIndicator类名的span元素。

在我们编写测试之前,让我们看看生产代码将如何工作。我们将引入一个新的布尔状态变量submitting,用于在状态之间切换。在我们执行 fetch 请求之前,它将被切换为true,一旦请求完成,它将被切换为false。以下是我们将如何修改handleSubmit

...
if (!anyErrors(validationResult)) {
  setSubmitting(true);
  const result = await global.fetch(...);
  setSubmitting(false);
  ...
}
...

如果提交被设置为true,那么我们将渲染旋转器图形。否则,我们将不渲染任何内容。

在 promise 完成前测试状态

测试 React 组件最棘手的一个方面是测试任务期间发生的事情。这正是我们现在需要做的:我们想要检查在表单提交期间显示提交指示器。然而,指示器一旦 promise 完成就会消失,这意味着我们不能使用我们迄今为止使用的标准clickAndWait函数,因为它会在指示器消失之后的点返回!

记住clickAndWait使用的是act测试辅助函数的异步形式。这是问题的关键。为了解决这个问题,我们需要一个同步形式的我们的函数click,以便在任务队列完成之前返回——换句话说,在global.fetch调用返回任何结果之前。

然而,为了停止 React 的警告警报响起,我们仍然需要在我们的测试中包含异步的act形式。React 知道提交处理程序返回一个 promise,并且它期望我们通过调用act等待其执行。我们需要在检查提交的切换值之后做这件事,而不是之前。

现在我们来构建这个测试:

  1. act 作为导入添加到 test/CustomerForm.test.js

    import { act } from "react-dom/test-utils";
    
  2. 重新添加 click 函数导入:

    import {
      ...,
      click,
      clickAndWait,
    } from "./reactTestExtensions";
    
  3. CustomerForm 测试套件的底部创建一个新的嵌套 describe 块,位于现有的表单提交测试下方。这个测试在同步的 click 中提交调用本身,如前所述。然后,我们必须将期望包裹在一个抑制 React 任何警告或错误的异步 act 调用中:

    describe("submitting indicator", () => {
      it("displays when form is submitting", async () => {
        render(
          <CustomerForm
            original={validCustomer}
            onSave={() => {}}
          />
        );
        click(submitButton());
        await act(async () => {
          expect(
            element("span.submittingIndicator")
          ).not.toBeNull();
        });
      });
    });
    
  4. 为了使这个通过,我们只需要在 JSX 中显示那个 span。将其放置在提交按钮之后,如下所示:

    return (
      <form id="customer" onSubmit={handleSubmit}>
        ...
        <input type="submit" value="Add" />
        <span className="submittingIndicator" />
      </form>
    );
    
  5. 现在,我们需要进行三角测量,以确保指示器仅在表单提交后才显示,而不是在提交之前:

    it("initially does not display the submitting indicator", () => {
      render(<CustomerForm original={validCustomer} />);
      expect(element(".submittingIndicator")).toBeNull();
    });
    
  6. 我们可以通过使用一个名为 submitting 的标志来使这个通过。当指示器禁用时,它应该设置为 false,当它启用时,设置为 true。将以下状态变量添加到 CustomerForm 组件的顶部:

    const [submitting, setSubmitting] = useState(false);
    
  7. 将提交的 span 指示器更改为以下内容:

    {submitting ? (
      <span className="submittingIndicator" />
    ) : null}
    
  8. 新的测试现在将通过,但原始测试将失败。我们不得不在调用 fetch 之前将 submittingIndicator 切换到 true。在 handleSubmit 中,在调用 fetch 之前添加此行。添加此代码后,你的测试应该通过:

    if (!anyErrors(validationResult)) {
      setSubmitting(true);
      const result = await global.fetch(/* ... */);
      ...
    }
    
  9. 添加这个最后的测试,该测试检查指示器在收到响应后消失。这个测试与我们的第一个提交指示器测试非常相似:

    it("hides after submission", async () => {
      render(
        <CustomerForm
          original={validCustomer}
          onSave={() => {}}
        />
      );
      await clickAndWait(submitButton());
      expect(element(".submittingIndicator")).toBeNull();
    });
    
  10. 这次,我们需要在 fetch 之后添加一个 setSubmitting 调用:

    if (!anyErrors(validationResult)) {
      setSubmitting(true);
      const result = await global.fetch(/* ... */);
      setSubmitting(false);
      ...
    }
    

那就是全部了;你的所有测试都应该通过。

重构长方法

在此之后,我们的 handleSubmit 函数变得很长——在我的实现中我数了 23 行。这对我来说太长了!

handleSubmit 重构为更小的方法是一个留给你的练习;请参阅 练习 部分以获取更多详细信息。但这里有一些关于如何系统地进行的提示:

  • 将代码块提取到方法中;在这种情况下,这意味着 if 语句的内容。例如,如果没有验证错误,你可以调用 doSave 方法进行提交。

  • 在 fetch 调用之前寻找 true,然后是调用之后的 false。这可以有不同的实现方式。

现在,让我们总结这一章。

概述

本章向你展示了如何将 TDD 应用于不仅仅是玩具示例之外。虽然你可能永远不会想自己实现表单验证,但你可以看到如何使用你在本书第一部分学到的相同方法来驱动复杂的代码。

首先,你学习了如何在适当的时候验证字段值:当字段失去焦点和表单提交时。你还看到了如何将服务器端错误集成到其中,以及如何显示指示器来告知用户数据正在保存过程中。

本章还介绍了如何将逻辑从你的 React 组件移动到它们自己的模块中。

在下一章中,我们将向我们的系统添加一个新功能:一个时尚的搜索界面。

练习

以下是一些供您完成的练习:

  1. 添加一个功能,当用户更正错误时清除任何验证错误。使用 onChange 处理器来完成此操作,而不是 onBlur,因为我们希望让用户在更正错误后立即知道。

  2. 添加一个功能,一旦表单提交,就禁用提交按钮。

  3. formValidation 模块中的每个函数编写测试。

  4. handleSubmit 函数很长。提取一个 doSave 函数,用于提取 if 语句的主体部分。

进一步阅读

要了解更多关于本章所涉及的主题,请查看以下资源:

  • 通过示例解释的正则表达式指南

reacttdd.com/testing-regular-expressions

  • 关于 aria-describedby 等 ARIA 注解的更多信息

developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Annotations

第十章:过滤和搜索数据

在本章中,我们将继续将我们已学到的技术应用到另一个更复杂的使用案例中。

在我们学习本章内容的过程中,我们将学习如何通过测试调整组件的设计,以显示设计中的不足。测试驱动开发在测试变得复杂时,真正有助于突出设计问题。幸运的是,我们已编写的测试给了我们信心改变方向并完全重新设计。每次更改时,我们只需运行npm test,并在几秒钟内验证我们的新实现。

在当前的流程中,用户首先添加一个新客户,然后立即为该客户预订一个预约。现在,我们将在此基础上扩展,允许他们在添加预约之前选择一个现有客户。

我们希望用户能够快速搜索客户。这个沙龙可能有数百甚至数千名注册客户。因此,我们将构建一个CustomerSearch搜索组件,允许我们的用户通过姓名搜索客户并浏览返回的结果。

在本章中,你将了解以下主题:

  • 显示从端点获取的表格数据

  • 在大型数据集中分页

  • 数据过滤

  • 使用渲染属性执行操作

以下截图显示了新组件的外观:

图 10.1 – 新的 CustomerSearch 组件

图 10.1 – 新的 CustomerSearch 组件

到本章结束时,你将使用到目前为止所学到的所有技术构建一个相对复杂的组件。

技术要求

本章的代码文件可以在以下位置找到:

github.com/PacktPublishing/Mastering-React-Test-Driven-Development-Second-Edition/tree/main/Chapter10

显示从端点获取的表格数据

在本节中,我们将设置表格的基本形式,并在组件挂载时从服务器检索初始数据集。

服务器对/customersGET请求。有一个searchTerm参数,它接受用户正在搜索的字符串。还有一个after参数,用于检索下一页的结果。响应是一个客户数组,如下所示:

[{ id: 123, firstName: "Ashley"}, ... ]

发送不带参数的请求到/customers将返回我们客户的头 10 个,按姓氏字母顺序排列。

这为我们提供了一个良好的起点。当组件挂载时,我们将执行这个基本搜索并在表格中显示结果。

跳过起点

如果你正在使用 GitHub 仓库进行跟随,请注意,本章从已经实现且已连接到App组件的裸骨CustomerSearch组件开始。该组件通过点击顶部菜单中的搜索预约按钮显示。

让我们从对新的CustomerSearch组件的第一个测试开始。按照以下步骤操作:

  1. 打开test/CustomerSearch.test.js并添加第一个测试。它检查是否渲染了我们想要看到的四个标题的表格。代码如下所示:

    it("renders a table with four headings", async () => {
      await renderAndWait(<CustomerSearch />);
      const headings = elements("table th");
      expect(textOf(headings)).toEqual([
        "First name",
        "Last name",
        "Phone number",
        "Actions",
      ]);
    });
    
  2. 该测试应该很容易通过,以下是在src/CustomerSearch.js中对CustomerSearch的以下定义:

    export const CustomerSearch = () => (
      <table>
        <thead>
          <tr>
            <th>First name</th>
            <th>Last name</th>
            <th>Phone number</th>
            <th>Actions</th>
          </tr>
        </thead>
      </table>
     );
    
  3. 为了显示数据,组件需要执行一个GET请求。编写出这个下一个测试,它指定了该行为:

    it("fetches all customer data when component mounts", async () => {
      await renderAndWait(<CustomerSearch />);
      expect(global.fetch).toBeCalledWith("/customers", {
        method: "GET",
        credentials: "same-origin",
        headers: { "Content-Type": "application/json" },
      });
    });
    
  4. 为了使这一步通过,向组件添加一个执行搜索的useEffect钩子。我们需要使用之前看到的相同的useEffect仪式,使用内联函数以确保我们不返回值,并将空数组传递给依赖项列表,这确保了效果仅在组件首次挂载时运行。代码如下所示:

    export const CustomerSearch = () => {
      useEffect(() => {
        const fetchData = async () =>
          await global.fetch("/customers", {
            method: "GET",
            credentials: "same-origin",
            headers: {
              "Content-Type": "application/json"
            },
          });
        fetchData();
      }, []);
      return (
        ...
      )
    };
    
  5. 现在,是时候编写根据返回的数据发生的事情的代码了。我们将从确定单行数据的显示开始。在文件顶部,在describe块上方添加oneCustomer的定义,如下所示:

    const oneCustomer = [
      {
        id: 1,
        firstName: "A",
        lastName: "B",
        phoneNumber: "1"
      },
    ];
    
  6. 在下一个测试中,使用该定义,如下所示,该测试验证组件显示单个客户行的所有客户数据:

    it("renders all customer data in a table row", async () => {
      global.fetch.mockResolvedValue(
        fetchResponseOk(oneCustomer)
      );
      await renderAndWait(<CustomerSearch />);
      const columns = elements("table > tbody > tr > td");
      expect(columns[0]).toContainText("A");
      expect(columns[1]).toContainText("B");
      expect(columns[2]).toContainText("1");
    });
    
  7. 为了使这一步通过,我们需要使用组件状态将数据从useEffect钩子传递到下一个渲染周期。创建一个新的状态变量customers,其初始值为空数组([]),如下所示:

    const [customers, setCustomers] = useState([]);
    
  8. 将搜索结果保存到customers中,通过修改useEffect的定义,如下所示:

    const fetchData = async () => {
    const result = await global.fetch(...);
      setCustomers(await result.json());
    };
    
  9. 我们准备好显示数据了。我们将使用一个新的CustomerRow组件来显示单个客户信息的一行。在CustomerSearch定义上方添加其实现。注意这里最后一列是空的;它将包含执行特定客户记录上各种操作的按钮。我们将在稍后的单独测试中填充该功能:

    const CustomerRow = ({ customer }) => (
      <tr>
        <td>{customer.firstName}</td>
        <td>{customer.lastName}</td>
        <td>{customer.phoneNumber}</td>
        <td />
      </tr>
    );
    
  10. 剩下的就是在这个CustomerSearch中利用这个新组件。添加以下tbody元素,如果存在,则渲染第一个客户的CustomerRow。添加此代码后,你的测试现在应该通过了:

    return (
      <table>
        <thead>
          ...
        </thead>
        <tbody>
          {customers[0] ? (
            <CustomerRow customer={customers[0]} />
          ) : null}
        </tbody>
      </table>
    );
    
  11. 对于本节最后的测试,让我们添加一个测试来显示这适用于多个客户。为此,我们需要一个新的结果集:twoCustomers。这可以放在文件顶部,在oneCustomer之后,如下所示:

    const twoCustomers = [
      {
        id: 1,
        firstName: "A",
        lastName: "B",
        phoneNumber: "1"
      },
      {
        id: 2,
        firstName: "C",
        lastName: "D",
        phoneNumber: "2"
      }
    ];
    
  12. 然后,添加一个测试,利用这个功能并检查是否渲染了两行,如下所示:

    it("renders multiple customer rows", async () => {
      global.fetch.mockResolvedValue(
        fetchResponseOk(twoCustomers)
      );
      await renderAndWait(<CustomerSearch />);
      const rows = elements("table tbody tr");
      expect(rows[1].childNodes[0]).toContainText("C");
    });
    
  13. 使这一步通过只需要一行代码;将 JSX 更改为映射每个客户,而不是仅提取第一个客户:

    <tbody>
      {customers.map(customer => (
         <CustomerRow
           customer={customer}
           key={customer.id}
         />
        )
      )}
    </tbody>
    

这为我们构建本章剩余功能提供了一个很好的基础。

在下一节中,我们将介绍在多个搜索结果页面之间切换的能力。

在大型数据集中分页

默认情况下,我们的端点返回 10 条记录。为了获取下一组 10 条记录,我们可以通过使用表示已看到最后一个客户标识符的after参数来分页结果集。服务器将跳过结果,直到找到该 ID,然后从下一个客户开始返回结果。

我们将在下一个搜索请求中添加after参数。

为了支持每次用户点击Previous时可以弹出的after ID。

添加一个按钮以跳转到下一页

让我们从buttonWithLabel辅助函数开始,该函数将匹配具有该标签的按钮。按照以下步骤操作:

  1. test/reactTestExtensions.js文件底部添加以下新的辅助函数:

    export const buttonWithLabel = (label) =>
      elements("button").find(
        ({ textContent }) => textContent === label
     );
    
  2. test/CustomerSearch.test.js中,更新导入语句以包括此新辅助函数,如下所示:

    import {
      ...,
      buttonWithLabel,
    } from "./reactTestExtensions";
    
  3. 编写以下测试,这将使我们能够在页面上获得一个Next按钮:

    it("has a next button", async () => {
      await renderAndWait(<CustomerSearch />);
      expect(buttonWithLabel("Next")).not.toBeNull();
    });
    
  4. 创建一个SearchButtons组件,渲染menu元素,就像我们在App中做的那样。我们将在后续测试中扩展此菜单栏,添加更多按钮。代码如下所示:

    const SearchButtons = () => (
      <menu>
        <li>
          <button>Next</button>
        </li>
      </menu>
    );
    
  5. 现在,在CustomerSearch中表格上方渲染它,如下所示:

    return (
      <>
        <SearchButtons />
        <table>
          ...
        </table>
      </>
    );
    
  6. 当按钮被点击时,我们希望获取已显示的最后客户 ID 并将其发送回服务器。为了使我们的测试中这个选择明显,我们将使用一个新的返回值tenCustomers,该值模仿从服务器 API 返回的默认记录数。将此tenCustomers定义放置在文件顶部,靠近你的其他客户定义,如下所示:

    const tenCustomers =
      Array.from("0123456789", id => ({ id })
    );
    

充分利用 Array.from

此定义使用了一个“巧妙”版本的Array.from函数,它将字符串的每个字符作为输入创建一个对象。我们最终得到 10 个对象,每个对象都有一个从09的范围的id属性。

  1. 下一个测试检查当带有最后看到客户 ID 的GET请求。根据我们之前的tenCustomers定义,这是 ID 为9的客户。注意以下代码片段中toHaveBeenLastCalledWith的必要性,因为这将是对global.fetch的第二次调用:

    it("requests next page of data when next button is clicked", async () => {
      global.fetch.mockResolvedValue(
        fetchResponseOk(tenCustomers)
      );
      await renderAndWait(<CustomerSearch />);
      await clickAndWait(buttonWithLabel("Next"));
      expect(global.fetch).toHaveBeenLastCalledWith(
        "/customers?after=9",
        expect.anything()
      );
    });
    

避免不必要的字段以突出重要含义

tenCustomers值只是每个客户的部分定义:只包含id属性。这不是懒加载:这是故意的。因为获取最后一个 ID 的逻辑不明显,所以突出id属性作为此流程的关键特性很重要。我们不会担心其他字段,因为我们的先前测试检查了它们的正确使用。

  1. 为了使这个通过,定义一个处理fetch请求的处理程序。它通过以下代码片段中所示的方式计算after请求参数,即从customers状态变量中获取最后一个客户:

    const handleNext = useCallback(() => {
      const after = customers[customers.length - 1].id;
      const url = `/customers?after=${after}`;
      global.fetch(url, {
        method: "GET",
        credentials: "same-origin",
        headers: { "Content-Type": "application/json" }
      });
    }, [customers]);
    
  2. SearchButtons一个handleNext属性,并将其设置为按钮上的onClick处理程序,如下所示:

    const SearchButtons = ({ handleNext }) => (
      <menu>
        <li>
          <button onClick={handleNext}>Next</button>
        </li>
      </menu>
    );
    
  3. 将处理程序连接到SearchButtons,如下所示。此更改后,你的测试应该可以通过:

    <SearchButtons handleNext={handleNext} />
    
  4. 继续添加以下测试。它使用一系列mockResolvedValueOnce后跟mockResolvedValue来设置两个fetch响应。第二个响应只包含一条记录。测试断言在按下下一步按钮后显示此记录:

    it("displays next page of data when next button is clicked", async () => {
      const nextCustomer = [{ id: "next", firstName: "Next" }];
      global.fetch
        .mockResolvedValueOnce(
          fetchResponseOk(tenCustomers)
        )
        .mockResolvedValue(fetchResponseOk(nextCustomer));
      await renderAndWait(<CustomerSearch />);
      await clickAndWait(buttonWithLabel("Next"));
      expect(elements("tbody tr")).toHaveLength(1);
      expect(elements("td")[0]).toContainText("Next");
    });
    
  5. 为了使这个通过,修改handleNext以将其响应保存到customers状态变量中,如下所示:

    const handleNext = useCallback(async () => {
      ...
    const result = await global.fetch(...);
      setCustomers(await result.json());
    }, [customers]);
    

对于我们的下一步按钮来说,这就结束了。在我们继续到上一页按钮之前,我们需要纠正一个设计问题。

调整设计

看这里handleNextfetchData函数之间的相似之处。它们几乎相同;它们唯一的不同之处在于fetch调用的第一个参数。handleNext函数有一个after参数;fetchData没有参数:

const handleNext = useCallback(async () => {
  const after = customers[customers.length - 1].id;
  const url = `/customers?after=${after}`;
const result = await global.fetch(url, ...);
  setCustomers(await result.json());
}, [customers]);
const fetchData = async () => {
const result = await global.fetch(`/customers`, ...);
  setCustomers(await result.json());
};

我们将添加useEffect钩子的能力,使其在状态变化时重新运行。

我们将引入一个新的状态变量queryStringhandleNext将更新它,useEffect将监听它。

现在就来做这件事。按照以下步骤进行:

  1. 现在将这个新变量添加到CustomerSearch组件的顶部,如下代码片段所示。它的初始值是空字符串,这很重要:

    const [queryString, setQueryString] = useState("");
    
  2. handleNext替换为以下函数:

    const handleNext = useCallback(() => {
      const after = customers[customers.length - 1].id;
      const newQueryString = `?after=${after}`;
      setQueryString(newQueryString);
    }, [customers]);
    
  3. 使用以下定义更新useEffect,将queryString附加到统一资源定位符URL)。此时,你的测试应该仍然通过:

    useEffect(() => {
      const fetchData = async () => {
        const result = await global.fetch(
          `/customers${queryString}`,
          ...
        );
        setCustomers(await result.json());
      };
      fetchData();
    }, [queryString]);
    

对于下一步按钮来说,你已经看到了如何为复杂的 API 编排逻辑编写优雅的测试,而且我们也已经重构了我们的生产代码以使其变得优雅。

添加一个按钮以跳转到上一页

让我们继续到上一页按钮:

  1. 编写以下测试:

    it("has a previous button", async () => {
      await renderAndWait(<CustomerSearch />);
      expect(buttonWithLabel("Previous")).not.toBeNull();
    });
    
  2. 通过修改SearchButtons以包括以下按钮,在下一步按钮之前,使其通过:

    <menu>
      <li>
        <button>Previous</button>
      </li>
      ...
    </menu>
    
  3. 下一个测试挂载组件,点击下一步,然后点击上一页。它期望对端点的另一个调用已被执行,但这次与初始页面相同——换句话说,没有查询字符串。代码如下所示:

    it("moves back to first page when previous button is clicked", async () => {
      global.fetch.mockResolvedValue(
        fetchResponseOk(tenCustomers)
      );
      await renderAndWait(<CustomerSearch />);
      await clickAndWait(buttonWithLabel("Next"));
      await clickAndWait(buttonWithLabel("Previous"));
      expect(global.fetch).toHaveBeenLastCalledWith(
        "/customers",
        expect.anything()
      );
    });
    
  4. 为了使这个通过,首先定义一个handlePrevious函数,如下所示:

    const handlePrevious = useCallback(
      () => setQueryString(""),
      []
    );
    
  5. 修改SearchButtons以接受一个新的handlePrevious属性,并将该属性设置为新按钮的onClick处理程序,如下所示:

    const SearchButtons = (
      { handleNext, handlePrevious }
    ) => (
      <menu>
        <li>
          <button
            onClick={handlePrevious}
          >
            Previous
          </button>
        </li>
        ...
      </menu>
    );
    
  6. 将处理程序连接到SearchButtons,如下所示。在此之后,你的测试应该通过:

    <SearchButtons
      handleNext={handleNext}
      handlePrevious={handlePrevious}
    />
    
  7. 下一个测试需要我们进行一些思考。它模拟在tenCustomers定义之后立即点击anotherTenCustomers,如下所示:

    const anotherTenCustomers =
      Array.from("ABCDEFGHIJ", id => ({ id }));
    
  8. 现在,添加下一个测试,该测试检查在导航到另外两个页面后,上一页按钮仍然有效:

    it("moves back one page when clicking previous after multiple clicks of the next button", async () => {
      global.fetch
        .mockResolvedValueOnce(
          fetchResponseOk(tenCustomers)
        )
        .mockResolvedValue(
          fetchResponseOk(anotherTenCustomers)
        );
      await renderAndWait(<CustomerSearch />);
      await clickAndWait(buttonWithLabel("Next"));
      await clickAndWait(buttonWithLabel("Next"));
      await clickAndWait(buttonWithLabel("Previous"));
      expect(global.fetch).toHaveBeenLastCalledWith(
        "/customers?after=9",
        expect.anything()
      );
    });
    
  9. 我们将通过维护传递给端点的查询字符串的记录来使这个通过。对于这个特定的测试,我们只需要知道上一个查询字符串是什么。添加一个新的状态变量来记录它,如下所示:

    const [
      previousQueryString, setPreviousQueryString
    ] = useState("");
    

强制设计问题

你可能认为这是一个过于复杂的设计。现在我们先这样进行:我们将在另一个测试中再次简化它。

  1. handleNext 修改为保存之前的查询字符串,确保在调用 setQueryString 之前完成。将 queryString 包含在传递给 useCallback 第二个参数的数组中,以便每次 queryString 的值改变时,这个回调都会被重新生成。代码如下所示:

    const handleNext = useCallback(queryString => {
      ...
      setPreviousQueryString(queryString);
      setQueryString(newQueryString);
    }, [customers, queryString]);
    
  2. 现在,handlePrevious 可以使用这个值作为传递给 fetchData 的查询字符串,如下所示。此时,你的测试应该已经通过:

    const handlePrevious = useCallback(async () =>
      setQueryString(previousQueryString)
    , [previousQueryString]);
    

这就是基本的 上一页 按钮实现。然而,当我们想要后退两页或更多页时会发生什么?我们当前的设计只有两个额外的页面深度。如果我们想支持任意数量的页面怎么办?

使用测试强制设计更改

我们可以使用测试来强制设计问题。TDD 的过程帮助我们确保我们总是花时间思考最简单的解决方案,以解决所有测试。因此,如果我们添加一个强调当前设计局限性的测试,那么这个测试就成为了我们停止、思考和重新实现的触发器。

在这种情况下,我们可以使用之前查询字符串的堆栈来记住页面的历史。我们将用单个状态变量 queryStrings 替换我们的两个状态变量 queryStringpreviousQueryStringqueryStrings 是所有之前查询字符串的堆栈。

让我们开始测试。按照以下步骤进行:

  1. 添加以下测试,它断言 上一页 按钮可以多次点击:

    it("moves back multiple pages", async () => {
      global.fetch
        .mockResolvedValue(fetchResponseOk(tenCustomers));
      await renderAndWait(<CustomerSearch />);
      await clickAndWait(buttonWithLabel("Next"));
      await clickAndWait(buttonWithLabel("Next"));
      await clickAndWait(buttonWithLabel("Previous"));
      await clickAndWait(buttonWithLabel("Previous"));
      expect(global.fetch).toHaveBeenLastCalledWith(
        "/customers",
        expect.anything()
      );
    });
    
  2. 为了通过这个测试,首先添加一个新的 queryStrings 状态变量,删除 queryStringpreviousQueryStrings,如下所示:

    const [queryStrings, setQueryStrings] = useState([]);
    
  3. 按照以下方式修改 fetchData。如果 queryStrings 数组中有条目,它将 queryString 设置为最后一个条目,然后该值传递给 fetch 调用。如果没有条目在数组中,那么 queryString 将是一个空字符串:

    useEffect(() => {
      const fetchData = async () => {
        const queryString =
          queryStrings[queryStrings.length - 1] || "";
        const result = await global.fetch(
          `/customers${queryString}`,
          ...
        );
        setCustomers(await result.json());
      };
      fetchData();
    }, [queryStrings]);
    
  4. 按照以下方式修改 handleNext。现在它将当前的查询字符串 附加 到之前的查询字符串堆栈上:

    const handleNext = useCallback(() => {
      const after = customers[customers.length - 1].id;
      const newQueryString = `?after=${after}`;
      setQueryStrings([...queryStrings, newQueryString]);
    }, [customers, queryStrings]);
    
  5. 按照以下方式修改 handlePrevious。最后一个值是从查询字符串堆栈中 弹出 的:

    const handlePrevious = useCallback(() => {
      setQueryStrings(queryStrings.slice(0, -1));
    } [queryStrings]);
    

现在,你已经有了相对完整的 下一页上一页 按钮的实现。你也看到了测试如何帮助你遇到问题时改变你的设计。

接下来,我们将继续构建与 /customers HTTP 端点的 searchTerm 参数的集成。

数据过滤

在本节中,我们将添加一个文本框,用户可以使用它来过滤名称。用户在搜索字段中输入的每个字符都会导致向服务器发出一个新的 fetch 请求。该请求将包含由搜索框提供的新的搜索术语。

/customers 端点支持一个名为 searchTerm 的参数,它使用这些术语过滤搜索结果,如下面的代码片段所示:

GET /customers?searchTerm=Dan
[
  {
    firstName: "Daniel",
    ...
  }
  ...
]

让我们先添加一个文本字段,用户可以在其中输入搜索词,如下所示:

  1. 将以下测试添加到CustomerSearch测试套件中,在最后一个测试下方。它只是检查一个新的字段:

    it("renders a text field for a search term", async () => {
      await renderAndWait(<CustomerSearch />);
      expect(element("input")).not.toBeNull();
    });
    
  2. CustomerSearch中更新您的 JSX,将输入元素添加到组件的顶部,如下所示:

    return (
      <>
        <input />
        ...
      </>
    );
    
  3. 接下来,我们想检查该字段的placeholder属性是否已设置。我们可以通过运行以下代码来完成此操作:

    it("sets the placeholder text on the search term field", async () => {
      await renderAndWait(<CustomerSearch />);
      expect(
        element("input").getAttribute("placeholder")
      ).toEqual("Enter filter text");
    });
    
  4. 为了使其通过,将占位符添加到您的 JSX 中的输入元素,如下所示:

    <input placeholder="Enter filter text" />
    
  5. 我们想将其连接到 DOM 更改事件:每次值更改时,我们将进行一个async的 fetch 请求。为此,我们需要一个新的辅助函数。在test/reactTestExtensions.js中,在change定义下方添加以下changeAndWait定义。这允许我们在 DOM 更改事件发生时运行效果:

    export const changeAndWait = async (target, value) =>
      act(async () => change(target, value));
    
  6. test/CustomerSearch.test.js的顶部导入新的辅助函数,如下所示:

    import {
      ...,
      changeAndWait,
    } from "./reactTestExtensions";
    
  7. 每当在搜索框中输入新字符时,我们应该使用文本框中输入的任何文本执行新的搜索。添加以下测试:

    it("performs search when search term is changed", async () => {
      await renderAndWait(<CustomerSearch />);
      await changeAndWait(element("input"), "name");
      expect(global.fetch).toHaveBeenLastCalledWith(
        "/customers?searchTerm=name",
        expect.anything()
      );
    });
    
  8. 定义一个新的searchTerm变量,如下所示:

    const [searchTerm, setSearchTerm] = useState("");
    
  9. 添加一个新的处理程序,handleSearchTextChanged,如下所示。它将搜索词存储在状态中,因为我们将在在不同页面之间移动时需要将其拉回:

    const handleSearchTextChanged = (
      { target: { value } }
    ) => setSearchTerm(value);
    
  10. 将其连接到输入元素,如下所示:

    <input
      value={searchTerm}
      onChange={handleSearchTextChanged}
      placeholder="Enter filter text"
    />
    
  11. 现在,我们可以在fetchData中使用searchTerm变量从服务器获取更新后的客户集,如下所示:

    const fetchData = async () => {
      let queryString = "";
      if (searchTerm !== "") {
        queryString = `?searchTerm=${searchTerm}`;
      } else if (queryStrings.length > 0) {
        queryString =
         queryStrings[queryStrings.length - 1];
      }
      ...
    };
    
  12. 最后,我们需要通过将searchTerm添加到依赖列表中来修改useEffect,如下所示。之后,测试应该通过:

    useEffect(() => {
     ...
    }, [queryStrings, searchTerm]);
    
  13. 我们需要确保点击下一个按钮将保持我们的搜索词。目前,它不会。我们可以使用以下测试来修复这个问题:

    it("includes search term when moving to next page", async () => {
      global.fetch.mockResolvedValue(
        fetchResponseOk(tenCustomers)
      );
      await renderAndWait(<CustomerSearch />);
      await changeAndWait(element("input"), "name");
      await clickAndWait(buttonWithLabel("Next"));
      expect(global.fetch).toHaveBeenLastCalledWith(
        "/customers?after=9&searchTerm=name",
        expect.anything()
      );
    });
    
  14. 为了使其通过,让我们通过向if语句添加一个附加项将行为强制进入fetchData,如下所示:

    const fetchData = async () => {
      let queryString;
      if (queryStrings.length > 0 && searchTerm !== "") {
        queryString =
          queryStrings[queryStrings.length - 1]
          + `&searchTerm=${searchTerm}`;
      } else if (searchTerm !== '') {
        queryString = `?searchTerm=${searchTerm}`;
      } else if (queryStrings.length > 0) {
        queryString =
          queryStrings[queryStrings.length - 1];
      }
      ...
    };
    

我们已经通过了这个测试...但这太乱了!任何包含这么多可变部分的if语句(变量、运算符、条件等)都是一个信号,表明设计并不像它本可以做到的那样好。让我们来修复它。

简化组件设计的重构

问题在于queryString数据结构和它的历史对应物,queryStrings状态变量。构建是复杂的。

我们是否只存储原始数据呢——最后表格行中的客户 ID?然后,我们可以在实际中立即构建queryString数据结构,因为实际上queryString只是fetch请求的输入。保留原始数据似乎会更简单。

让我们规划一下我们的重构。在以下每个阶段,我们的测试都应该通过,这让我们有信心我们仍然走在正确的道路上:

  1. 首先,将查询字符串构建逻辑从handleNext移动到fetchData中,在这个过程中将存储在queryStrings中的值从查询字符串更改为客户 ID。

  2. 然后,使用您的编辑器的搜索和替换功能更改那些变量的名称。

  3. 最后,简化fetchData中的逻辑。

这听起来不难,对吧?让我们开始,如下所示:

  1. 在组件顶部,将queryStrings变量替换为这个新变量:

    const [lastRowIds, setLastRowIds] = useState([]);
    
  2. 使用你编辑器的搜索和替换功能将所有queryStrings的出现更改为lastRowIds

  3. 同样,将调用setQueryStrings改为调用setLastRowIds。在这个阶段,你的测试应该仍然通过。

  4. handleNext中删除以下行:

    const newQueryString = `?after=${after}`;
    
  5. 在下一行中,将调用fetchData改为传入after而不是现在已删除的newQueryString,如下所示:

    const handleNext = useCallback(() => {
      const after = customers[customers.length - 1].id;
      setLastRowIds([...lastRowIds, after]);
    }, [customers, lastRowIds]);
    
  6. 在同一个函数中,将after重命名为currentLastRowId。在这个阶段,你的测试应该仍然通过。

  7. 现在是时候简化fetchData内部的逻辑了。创建一个searchParams函数,它将根据aftersearchTerm的值为我们生成搜索参数。这可以在组件外部定义。代码如下所示:

    const searchParams = (after, searchTerm) => {
      let pairs = [];
      if (after) {
        pairs.push(`after=${after}`);
      }
      if (searchTerm) {
        pairs.push(`searchTerm=${searchTerm}`);
      }
      if (pairs.length > 0) {
        return `?${pairs.join("&")}`;
      }
      return "";
    };
    
  8. 最后,更新fetchData以使用这个新函数替换现有的查询字符串逻辑,如下所示。在这个阶段,你的测试应该通过,实现大大简化且易于理解:

    const fetchData = async () => {
      const after = lastRowIds[lastRowIds.length - 1];
      const queryString = searchParams(after, searchTerm);
      const response = await global.fetch(...);
    };
    

你现在已经构建了一个功能性的搜索组件。你引入了一个新的辅助函数changeAndWait,并提取了一个searchParams函数,该函数可以在其他地方重用。

接下来,我们将向CustomerSearch组件添加一个最终机制。

使用渲染属性执行操作

表的每一行将包含一个AppointmentForm组件,为该客户创建一个预约。

我们将通过使用CustomerSearch来显示这些操作。父组件——在我们的例子中是App——使用这个来将其自己的渲染逻辑插入到子组件中。App将传递一个函数,该函数在App本身中显示一个按钮,导致视图转换。

如果子组件应该不知道它正在操作的环境,例如App提供的流程,那么渲染属性是有用的。

不必要的复杂代码警告!

你即将看到的实现可能比必要的更复杂。还有其他解决这个问题的方法:你可以简单地让CustomerSearch直接渲染AppointmentFormLoader,或者你可以允许CustomerSearch渲染按钮,然后调用一个回调,例如onSelect(customer)

渲染属性可能对库作者更有用,而不是对任何应用程序作者,因为库组件无法考虑到它们运行的上下文。

我们需要的渲染属性测试技术比我们迄今为止看到的任何技术都要复杂,你可以把这当作“更好的”解决方案的另一个迹象。

首先,我们将向CustomerSearch添加renderCustomerActions属性,并在新的表格单元格中渲染它。按照以下步骤操作:

  1. test/CustomerSearch.test.js中编写以下测试:

    it("displays provided action buttons for each customer", async () => {
      const actionSpy = jest.fn(() => "actions");
      global.fetch.mockResolvedValue(
       fetchResponseOk(oneCustomer)
      );
      await renderAndWait(
        <CustomerSearch
          renderCustomerActions={actionSpy}
        />
      );
      const rows = elements("table tbody td");
      expect(rows[rows.length - 1])
        .toContainText("actions");
    });
    
  2. 设置一个默认的renderCustomerActionsprop,这样我们的现有测试在开始使用新 prop 时不会开始失败,如下所示。这应该在src/CustomerSearch.js的底部进行:

    CustomerSearch.defaultProps = {
      renderCustomerActions: () => {}
    };
    
  3. CustomerSearch组件的顶部行解构那个 prop,如下所示:

    export const CustomerSearch = (
      { renderCustomerActions }
    ) => {
      ...
    };
    
  4. 将它传递给CustomerRow,如下所示:

    <CustomerRow
      customer={customer}
      key={customer.id}
      renderCustomerActions={renderCustomerActions}
    />
    
  5. CustomerRow中,更新第四个td单元格以调用这个新 prop,如下所示:

    const CustomerRow = (
      { customer, renderCustomerActions }
    ) => (
      <tr>
        <td>{customer.firstName}</td>
        <td>{customer.lastName}</td>
        <td>{customer.phoneNumber}</td>
        <td>{renderCustomerActions()}</td>
      </tr>
    );
    
  6. 对于下一个测试,我们想要检查这个渲染 prop 是否接收了适用于该行的特定客户记录。我们可以这样做到:

    it("passes customer to the renderCustomerActions prop", async () => {
      const actionSpy = jest.fn(() => "actions");
      global.fetch.mockResolvedValue(
        fetchResponseOk(oneCustomer)
      );
      await renderAndWait(
        <CustomerSearch
          renderCustomerActions={actionSpy}
        />
      );
      expect(actionSpy).toBeCalledWith(oneCustomer[0]);
    });
    
  7. 要使这个通过,你所要做的就是更新你刚才写的 JSX 调用,包括客户作为参数,如下所示:

    <td>{renderCustomerActions(customer)}</td>
    

这就是调用CustomerSearch组件内的渲染 prop 的全部内容。难点在于测试驱动App组件中渲染 prop 的实现本身。

在额外的渲染上下文中测试渲染 prop

回想一下,App组件有一个view状态变量,它决定了用户当前在屏幕上查看哪个组件。如果他们在搜索客户,那么view将被设置为searchCustomers

按压CustomerSearch组件应该将view设置为addAppointment,导致用户的屏幕隐藏CustomerSearch组件并显示AppointmentForm组件。

我们还需要将App组件的customer状态变量设置为用户在CustomerSearch组件中刚刚选择的客户。

所有这些都将由App传递给customer的渲染 prop 来完成。

最大的问题是:我们如何测试驱动这个渲染 prop 的实现?

我们可以采取几种不同的方法来做这件事:

  • 你可以在App组件内部渲染实际的CustomerSearch组件,导航到客户,并点击App。如果你有一个模块级别的CustomerSearch模拟,你需要为这些测试创建一个新的测试套件,这增加了维护开销。

  • 你可以修改CustomerSearch模拟以具有触发渲染 prop 的机制。这涉及到使模拟定义比标准形式更复杂。这对我来说是一个直接的红旗,原因如第七章中所述,测试 useEffect 和模拟组件。这个解决方案被放在了后面。

  • 你可以从CustomerSearch组件中提取渲染 prop,渲染它,然后找到创建预约按钮并点击它。这是我们将继续采用的方法。

如果我们使用我们的renderrenderAndWait函数来渲染这个额外的 prop,它将替换已渲染的App组件。然后我们点击按钮,我们会观察到没有任何事情发生,因为App已经消失了。

我们需要的是一个第二级 React 根,可以用来仅渲染那额外的 DOM 片段。我们的测试可以简单地假装CustomerSearch组件。

要做到这一点,我们需要一个新的渲染组件,我们将称之为renderAdditional。现在让我们添加它,然后编写以下测试:

  1. test/reactTestExtensions.js中,在renderAndWait定义下方添加以下函数定义:

    export const renderAdditional = (component) => {
      const container = document.createElement("div");
      act(() =>
        ReactDOM.createRoot(container).render(component)
      );
      return container;
    };
    
  2. test/App.test.js中,更新import语句以引入这个新扩展,如下所示:

    import {
      ...,
      renderAdditional,
    } from "./reactTestExtensions";
    
  3. 定位到search customers嵌套的describe块,并添加一个searchFor辅助函数,该函数调用提供的客户的渲染属性,如下所示:

    const searchFor = (customer) =>
      propsOf(CustomerSearch)
        .renderCustomerActions(customer);
    
  4. 现在,添加测试。这会渲染属性并检查是否已渲染了一个按钮,如下面的代码片段所示:

    it("passes a button to the CustomerSearch named Create appointment", async () => {
      render(<App />);
      navigateToSearchCustomers();
      const buttonContainer = 
        renderAdditional(searchFor());
      expect(
        buttonContainer.firstChild
      ).toBeElementWithTag("button");
      expect(
        buttonContainer.firstChild
      ).toContainText("Create appointment");
    });
    
  5. src/App.js中,在返回的 JSX 上方添加以下函数:

    const searchActions = () => (
      <button>Create appointment</button>
    );
    
  6. CustomerSearch上设置属性,如下所示。在此更改后,你的测试应该通过:

    case "searchCustomers":
      return (
        <CustomerSearch 
          renderCustomerActions={searchActions}
        />
      );
    
  7. test/CustomerSearch.test.js中,添加以下测试。这使用相同的辅助函数,但这次点击按钮并验证是否显示了带有正确客户 ID 的AppointmentFormLoader

    it("clicking appointment button shows the appointment form for that customer", async () => {
      const customer = { id: 123 };
      render(<App />);
      navigateToSearchCustomers();
      const buttonContainer = renderAdditional(
        searchFor(customer)
      );
      click(buttonContainer.firstChild);
      expect(
       element("#AppointmentFormLoader")
      ).not.toBeNull();
      expect(
        propsOf(AppointmentFormLoader).original
      ).toMatchObject({ customer: 123 });
    });
    
  8. 为了使它通过,更新src/App.js中的searchActions以使用CustomerSearch传递给它的客户参数,如下所示:

    const searchActions = (customer) => (
      <button
        onClick={
          () => transitionToAddAppointment(customer)
        }>
        Create appointment
    </button>
    );
    

就这么多了:你现在已经使用了renderAdditional来触发你的渲染属性并检查它是否按预期工作。

当与期望你传递渲染属性的三方库一起工作时,这个技术非常有用。

这样就完成了这个功能;如果你想要看到它全部的效果,请手动测试。

摘要

本章探讨了构建一个组件,其中用户界面和 API 之间存在一些复杂的用户交互。你已经创建了一个新的表格组件,并将其集成到现有的应用程序工作流程中。

你看到了如何通过使用测试作为安全机制来对组件的实现进行重大更改。

你还看到了如何使用额外的渲染根来测试渲染属性——我希望你不需要经常使用这个技术!

在下一章中,我们将使用测试将 React Router 集成到我们的应用程序中。我们将继续使用CustomerSearch组件,通过添加使用浏览器地址栏指定搜索条件的能力。这将为我们引入 Redux 和 GraphQL 打下良好的基础。

练习

  1. 如果用户在第一页上,禁用上一页按钮;如果当前列表显示的记录少于 10 条,禁用下一页按钮。

  2. searchParams函数提取到一个单独的模块中,该模块可以处理任意数量的参数,并使用encodeURIComponent JavaScript 函数来确保值被正确编码。

  3. /customers端点支持一个limit参数,允许你指定返回的最大记录数。为用户提供一个机制,以便在每个页面上更改限制。

第十一章:测试驱动 React Router

React Router 是一个流行的组件库,它与浏览器的自身导航系统集成。它操作浏览器的地址栏,使得你的 UI 变化看起来像是页面转换。对于用户来说,他们似乎在导航到不同的页面。实际上,他们仍然停留在同一个页面上,避免了昂贵的页面重新加载。

在本章中,我们将重构我们的示例预约系统以使用 React Router。与本书的其余部分不同,本章不是一个逐步指南。这是因为重构过程相当长且费力。相反,我们将依次查看每个主要更改。

本章涵盖了以下内容:

  • 从测试优先的角度设计 React Router 应用程序

  • 在路由器内测试组件

  • 测试路由链接

  • 测试编程式导航

到本章结束时,你将学会所有测试驱动 React Router 集成所必需的技术。

技术要求

本章的代码文件可以在以下位置找到:

github.com/PacktPublishing/Mastering-React-Test-Driven-Development-Second-Edition/tree/main/Chapter11

从测试优先的角度设计 React Router 应用程序

本节是对 React Router 生态系统所有主要部分的概述,以防你不熟悉它。它还包含了如何测试依赖于 React Router 的系统的指导。

所有 React Router 组件的列表

你将使用 React Router 库中的以下内容:

  • 一个 路由器 组件。你通常会有一个这样的组件,并且有很多不同的类型。基本的是 BrowserRouter,但如果你需要在外部路由器中操作历史记录,你无疑会升级到 HistoryRouter,因为你正在编写测试。在 第十二章 测试驱动 Redux 中,你也会看到这是在 Redux 动作中引起页面转换所必需的。

  • 一个 路由 组件。这类似于我们现有 App 组件中的 switch 语句。它有一个 路由 子组件列表,并且一次只会选择其中一个子组件来显示。

  • 一组带有 路由 父组件的 路由 组件。每个 路由 都有一个路径属性,例如 /addCustomer路由器 组件会使用它来与窗口的当前位置进行比较。匹配的路由就是显示的那个。

  • 一个或多个 链接 组件。这些显示得像正常的 HTML 超链接,但它们的行为不同;React Router 会阻止浏览器接收这些导航事件,并将它们发送回 路由 组件,这意味着不会发生页面转换。

  • useNavigate 钩子。这用于在 React 侧效应或事件处理程序中执行页面转换。

  • useLocationuseSearchParams钩子。这些用于在组件中获取当前窗口位置的某些部分。

当窗口位置改变时拆分测试

您可以从这个列表中看到,React Router 的核心功能是操作窗口位置并根据该位置修改您应用程序的行为。

关于这一点的一种思考方式是,我们将利用窗口位置作为应用程序状态的一种形式,这种状态对所有我们的组件都是可访问的。重要的是,这种状态在 Web 请求之间持续存在,因为用户可以保存或收藏链接以供以后使用。

这的结果是我们现在必须将一些单元测试分开。以之前用于切换页面上显示的主要组件的创建预约按钮为例。在 React Router 到位后,这个按钮将变成一个链接。之前,我们有一个名为以下内容的单个单元测试:

displays the AppointmentFormLoader after the CustomerForm is submitted

但现在,我们将它拆分为两个测试:

navigates to /addAppointment after the CustomerForm is submitted
renders AppointmentFormRoute at /addAppointment

您可以看到,第一个测试在窗口位置改变时停止。第二个测试在浏览器导航到相同位置时开始。

进行这项更改很重要,因为 React Router 不仅仅是重构,它还在添加一个新功能:URL 现在可以作为您应用程序的入口点。

也就是说,在本质上,这是在将 React Router 引入您的项目之前您需要知道的最重要的事情。

为我们新的路由进行前期设计

在开始重构之前,让我们看看我们将要引入的路由:

  • 默认路由/将保持为我们的AppointmentsDayViewLoader,以及导航按钮。这被提取出来作为一个名为MainScreen的新组件。

  • 一个用于添加新客户的路由,位于/addCustomer

  • 一个用于为特定客户添加新预约的路由,位于/addAppointment?customer=<id>

  • 一个用于在/searchCustomers搜索客户的路由。它可以接收一组查询字符串值:searchTermlimitpreviousRowIds。例如,查询字符串可能如下所示:

    ?searchTerm=An&limit=20&previousRowIds=123,456
    

接下来,我们将查看如何测试Router组件及其Route子组件。

在路由器内测试组件

在本节中,我们将探讨如何使用主要的RouterRoutesRoute组件。

本章没有演练内容

如章节介绍中所述,本章不遵循通常的演练方法。这里显示的示例是从我们 Appointments 代码库的完成重构中提取的,您可以在 GitHub 仓库的Chapter11/Complete目录中找到。

路由器组件及其测试等效组件

这是一个顶级组件,它连接到您浏览器的位置机制。我们通常不进行测试驱动,因为 JSDOM 不处理页面转换,也没有对window.location API 的完全支持。

相反,我们将其放在src/index.js文件中:

import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import { App } from "./App";
ReactDOM.createRoot(
  document.getElementById("root")
).render(
  <BrowserRouter>
    <App />
  </BrowserRouter>
);

这是因为如果你尝试在Router组件的子组件之外使用任何其他 React Router 组件,它将会崩溃。对于我们的测试也是如此:我们的组件需要在路由器内部渲染。因此,我们引入了一个新的渲染辅助函数,称为renderWithRouter

这个定义在test/reactTestExtensions.js中:

import { createMemoryHistory } from "history";
import {
  unstable_HistoryRouter as HistoryRouter
} from "react-router-dom";
export let history;
export const renderWithRouter = (
  component,
  { location } = { location: "" }
) => {
  history = createMemoryHistory({
    initialEntries: [location]
  });
  act(() =>
    reactRoot.render(
      <HistoryRouter history={history}>
        {component}
      </HistoryRouter>
    )
  );
};

MemoryRouter 与 HistoryRouter

React Router 文档建议你使用MemoryRouter,这通常足够好。使用HistoryRouter允许你控制传入的历史实例,这意味着你可以在测试中操作它。

更多信息,请参阅reacttdd.com/memory-router-vs-history-router

如果你想在测试中操作窗口位置,那么导出history变量本身是很重要的。这种情况的一个特例是,如果你想在挂载组件之前设置窗口位置;在这种情况下,你可以简单地将一个location属性传递给renderWithRouter函数。你将在下一节中看到它是如何工作的。

使用Routes组件替换 switch 语句

现在,让我们看看如何使用Routes组件根据窗口位置切换组件。这个组件通常位于应用程序组件层次结构的顶部,在我们的例子中,它确实是App中的第一个组件。

Routes组件与原始应用程序中存在的switch语句类似。switch语句使用状态变量来确定应该显示哪个组件。Routes组件依赖于父Router来提供窗口位置作为上下文。

这是App组件中原始的switch语句的样子:

const [view, setView] = useState("dayView");
...
switch (view) {
  case "addCustomer":
    return (
      <CustomerForm ... />
    );
  case "searchCustomers":
    return (
      <CustomerSearch ... />
    );
  case "addAppointment":
    return (
      <AppointmentFormLoader ... />
    );
  default:
    return ...
}

它的Router替代品看起来像这样:

<Routes>
  <Route
    path="/addCustomer"
    element={<CustomerForm ... />}
  />
  <Route
    path="/addAppointment"
    element={<AppointmentFormRoute ... />}
  />
  <Route
    path="/searchCustomers"
    element={<CustomerSearchRoute ... />}
  />
  <Route path="/" element={<MainScreen />} />
</Routes>

view状态变量不再需要。注意我们有几个带有Route后缀的新组件。这些组件是小的包装器,在将它们传递给原始组件之前,从窗口位置中提取客户 ID 和其他参数。我们很快就会看到它们。

但首先,这些新路由的测试看起来是怎样的?

对于默认路由,测试很简单,是对之前存在的测试的更新:

it("initially shows the AppointmentDayViewLoader", () => {
  renderWithRouter(<App />);
  expect(AppointmentsDayViewLoader).toBeRendered();
});
it("has a menu bar", () => {
  renderWithRouter(<App />);
  expect(element("menu")).not.toBeNull();
});

唯一的区别是我们使用renderWithRouter辅助函数,而不是render

其他路由类似,只是它们使用location属性设置初始窗口位置,并且它们的断言基于模拟组件:

it("renders CustomerForm at the /addCustomer endpoint", () => {
  renderWithRouter(<App />, {
    location: "/addCustomer"
  });
  expect(CustomerForm).toBeRendered();
});
it("renders AppointmentFormRoute at /addAppointment", () => {
  renderWithRouter(<App />, {
    location: "/addAppointment?customer=123",
  });
  expect(AppointmentFormRoute).toBeRendered();
});
it("renders CustomerSearchRoute at /searchCustomers", () => {
  renderWithRouter(<App />, {
    location: "/searchCustomers"
  });
  expect(CustomerSearchRoute).toBeRendered();
});

使用中间组件来转换 URL 状态

让我们更仔细地看看AppointmentFormRouteCustomerSearchRoute。这些组件在做什么?

这是AppointmentFormRoute的定义:

import React from "react";
import { useSearchParams } from "react-router-dom";
import {
  AppointmentFormLoader
} from "./AppointmentFormLoader";
const blankAppointment = {
  service: "",
  stylist: "",
  startsAt: null,
};
export const AppointmentFormRoute = (props) => {
  const [params, _] = useSearchParams();
  return (
    <AppointmentFormLoader
      {...props}
      original={{
        ...blankAppointment,
        customer: params.get("customer"),
      }}
    />
  );
};

这个组件是一个中间组件,位于/addAppointmentRoute组件实例和AppointmentFormLoader组件实例之间。

本可以直接在AppointmentFormLoader内部引用useSearchParams函数,但通过使用这个中间类,我们可以避免修改该组件,并保持两个职责的分离。

每个组件只负责一个任务有助于理解。这也意味着,如果我们以后希望移除 React Router,AppointmentFormLoader就不需要被修改。

对于这个组件有一些有趣的测试。第一个是检查解析customer搜索参数:

it("adds the customer id into the original appointment object", () => {
  renderWithRouter(<AppointmentFormRoute />, {
    location: "?customer=123",
  });
  expect(AppointmentFormLoader).toBeRenderedWithProps({
    original: expect.objectContaining({
      customer: "123",
    }),
  });
});

发送到renderWithRouterlocation属性只是一个标准的查询字符串:?customer=123。我们本可以在这里输入一个完整的 URL,但通过仅关注 URL 的查询字符串部分,测试会更清晰。

第二个测试是对剩余的 props:

it("passes all other props through to AppointmentForm", () => {
  const props = { a: "123", b: "456" };
  renderWithRouter(<AppointmentFormRoute {...props} />);
  expect(AppointmentFormLoader).toBeRenderedWithProps(
    expect.objectContaining({
      a: "123",
      b: "456",
    })
  );
});

这个测试很重要,因为Route元素传递了一个onSave属性,它是为AppointmentFormLoader准备的:

<Route
  path="/addAppointment"
  element={
    <AppointmentFormRoute onSave={transitionToDayView} />
  }
/>

我们将在稍后的测试导航部分看看transitionToDayView函数做了什么。

现在让我们看看CustomerSearchRoute。这稍微复杂一些,因为它使用名为convertParams的函数解析了一些查询字符串参数:

const convertParams = () => {
  const [params] = useSearchParams();
  const obj = {};
  if (params.has("searchTerm")) {
    obj.searchTerm = params.get("searchTerm");
  }
  if (params.has("limit")) {
    obj.limit = parseInt(params.get("limit"), 10);
  }
  if (params.has("lastRowIds")) {
    obj.lastRowIds = params
      .get("lastRowIds")
      .split(",")
      .filter((id) => id !== "");
  }
  return obj;
};

这个函数替换了现有CustomerSearch组件中使用的三个状态变量。由于所有查询字符串参数都是字符串,每个值都需要解析成正确的格式。然后这些值作为 props 传递给CustomerSearch

import React from "react";
import {
  useNavigate,
  useSearchParams,
} from "react-router-dom";
import {
  CustomerSearch
} from "./CustomerSearch/CustomerSearch";
const convertParams = ...; // as above
export const CustomerSearchRoute = (props) => (
  <CustomerSearch
    {...props}
    navigate={useNavigate()}
    {...convertParams()}
  />
);

这个参数解析功能原本可以放入CustomerSearch中,但将这个逻辑放在一个单独的组件中有助于提高可读性。

这个例子还展示了useNavigate的使用,它被传递给CustomerSearch。将这个钩子函数的返回值作为 prop 传递意味着我们可以使用标准的 Jest spy 函数测试navigate的值,从而避免在路由中渲染测试组件。

这个组件的测试很简单。让我们看看一个例子:

it("parses lastRowIds from query string", () => {
  const location =
    "?lastRowIds=" + encodeURIComponent("1,2,3");
  renderWithRouter(<CustomerSearchRoute />, { location });
  expect(CustomerSearch).toBeRenderedWithProps(
    expect.objectContaining({
      lastRowIds: ["1", "2", "3"],
    })
  );
});

你现在已经了解了如何与三个组件一起工作:RouterRoutesRoute。接下来是Link组件。

测试路由链接

在本节中,你将学习如何使用和测试Link组件。这个组件是 React Router 对谦逊的 HTML 锚点(或a)标签的版本。

我们使用了两种形式的Link组件。第一种使用to属性作为字符串,例如,/addCustomer

<Link to="/addCustomer" role="button">
  Add customer and appointment
</Link>

第二个设置to属性为一个具有search属性的object

<Link
    to={{
      search: objectToQueryString(queryParams),
    }}
>
  {children}
</Link>

这个对象形式也接受一个pathname属性,但我们可以避免设置它,因为对于我们的用例,路径保持不变。

我们将探讨两种不同的测试链接的方法:标准方法(通过检查超链接),以及稍微痛苦一些的模拟方法。

检查页面中的超链接

这是src/App.js中的MainScreen组件,它显示了导航链接和预约日视图:

export const MainScreen = () => (
  <>
    <menu>
      <li>
        <Link to="/addCustomer" role="button">
          Add customer and appointment
        </Link>
      </li>
      <li>
        <Link to="/searchCustomers" role="button">
          Search customers
        </Link>
      </li>
    </menu>
    <AppointmentsDayViewLoader />
  </>
);

提取组件

MainScreen组件已被从App中提取出来。相同的代码之前位于switch语句的默认情况下。

Link组件生成一个标准的 HTML 锚标签。这意味着我们创建了一个辅助工具,通过查找具有匹配href属性的锚标签来找到特定的链接。这位于test/reactTestExtensions.js中:

export const linkFor = (href) =>
  elements("a").find(
    (el) => el.getAttribute("href") === href
  );

然后,你可以用来测试链接的存在及其标题:

it("renders a link to the /addCustomer route", async () => {
  renderWithRouter(<App />);
  expect(linkFor("/addCustomer")).toBeDefined();
});
it("captions the /addCustomer link as 'Add customer and appointment'", async () => {
  renderWithRouter(<App />);
  expect(linkFor("/addCustomer")).toContainText(
    "Add customer and appointment"
  );
});

测试这个问题的另一种方法是通过点击链接并检查其是否正常工作,如下面的测试所示。然而,正如本章开头提到的,这个测试是不必要的,因为你已经测试了测试的两个“部分”:链接是否显示,以及导航到 URL 是否渲染了正确的组件:

it("displays the CustomerSearch when link is clicked", async () => {
  renderWithRouter(<App />);
  click(linkFor("/searchCustomers"));
  expect(CustomerSearchRoute).toBeRendered();
});

这涵盖了测试Link组件的主要方法。另一种测试链接的方法是模拟Link组件,我们将在下一节中介绍。

模拟Link组件

这种方法比简单地测试 HTML 超链接要复杂一些。然而,这意味着你可以避免在Router组件内渲染你的测试组件。

src/CustomerSearch/RouterButton.js文件包含这个组件:

import React from "react";
import {
  objectToQueryString
} from "../objectToQueryString";
import { Link } from "react-router-dom";
export const RouterButton = ({
  queryParams,
  children,
  disabled,
}) => (
  <Link
    className={disabled ? "disabled" : ""}
    role="button"
    to={{
      search: objectToQueryString(queryParams),
    }}
  >
    {children}
  </Link>
);

要使用普通的render测试而不是renderWithRouter,我们需要模拟Link组件。以下是test/CustomerSearch/RouterButton.test.js中的样子:

import { Link } from "react-router-dom";
import {
  RouterButton
} from "../../src/CustomerSearch/RouterButton";
jest.mock("react-router-dom", () => ({
  Link: jest.fn(({ children }) => (
    <div id="Link">{children}</div>
  )),
}));

现在,你可以在测试中愉快地使用这个模拟:

it("renders a Link", () => {
  render(<RouterButton queryParams={queryParams} />);
  expect(Link).toBeRenderedWithProps({
    className: "",
    role: "button",
    to: {
      search: "?a=123&b=234",
    },
  });
});

有一个最后的要点需要考虑。有时候,你有一个单个的模拟组件在同一页面上有多个渲染实例,这种情况在Link实例中经常发生。

在我们的案例中,这是SearchButtons组件,它包含一个RouterButtonToggleRouterButton组件的列表:

<menu>
  ...
  <li>
    <RouterButton
      id="previous-page"
      queryParams={previousPageParams()}
      disabled={!hasPrevious}
    >
      Previous
    </RouterButton>
  </li>
  <li>
    <RouterButton
      id="next-page"
      queryParams={nextPageParams()}
      disabled={!hasNext}
    >
      Next
    </RouterButton>
  </li>
</menu>

当涉及到测试这些链接时,最简单的方法是使用renderWithRouter来渲染SearchButtons组件,然后检查渲染的 HTML 超链接。

然而,如果你已经决定模拟,那么你需要一种方法来轻松地找到你渲染的元素。

首先,你需要指定模拟包括一个id属性:

jest.mock("../../src/CustomerSearch/RouterButton", () => ({
  RouterButton: jest.fn(({ id, children }) => (
    <div id={id}>{children}</div>
  )),
}));

然后,你可以使用一个新的测试扩展propsMatching来找到特定的实例。以下是来自test/reactTestExtensions.js的定义:

export const propsMatching = (mockComponent, matching) => {
  const [k, v] = Object.entries(matching)[0];
  const call = mockComponent.mock.calls.find(
    ([props]) => props[k] === v
  );
  return call?.[0];
};

然后,你可以编写测试来利用这一点,如下面的代码所示。但请记住,可能更容易不模拟这个组件,而直接使用renderWithRouter,然后直接检查 HTML 超链接:

const previousPageButtonProps = () =>
  propsMatching(RouterButton, { id: "previous-page" });
it("renders", () => {
  render(<SearchButtons {...testProps} />);
  expect(previousPageButtonProps()).toMatchObject({
    disabled: false,
  });
  expect(element("#previous-page")).toContainText(
    "Previous"
  );
});

这就是测试Link组件的所有内容。在下一节中,我们将探讨测试 React Router 的最后一个方面:程序化导航。

测试程序化导航

有时候,你可能想要程序化地触发位置变化——换句话说,不等待用户点击链接。

有两种方法可以做到这一点:一种使用useNavigate钩子,另一种使用传递给顶级路由器的history实例。

组件内外的导航

在本章中,我们将仅探讨第一种方法,即使用钩子。稍后,在第十二章“测试驱动 Redux”中,我们将使用第二种方法来更改 Redux 动作中的位置。

当你能够在 React 组件内部进行导航时,useNavigate钩子是适当的方法。

在预约应用程序中,这发生在两个地方。第一个是在客户被添加后,我们希望将用户移动到/addAppointment路由。第二个是在表单填写完成并且预约被创建后——然后我们希望将他们移回默认路由。

由于这些非常相似,我们只需查看第一个。

这是/addCustomer路由定义在src/App.js中的样子:

<Route
  path="/addCustomer"
  element={
    <CustomerForm
      original={blankCustomer}
      onSave={transitionToAddAppointment}
    />
  }
/>

注意到onSave属性;这是在客户表单提交完成后被调用的回调。以下是该回调定义,以及与useNavigate钩子相关的部分:

import {
  ...,
  useNavigate,
} from "react-router-dom";
export const App = () => {
  const navigate = useNavigate();
  const transitionToAddAppointment = (customer) =>
    navigate(`/addAppointment?customer=${customer.id}`);
  ...
};

当涉及到测试这一点时,显然,我们不能仅仅依赖于Link组件的存在,因为并没有。相反,我们必须调用onSave回调:

import {
  ...,
  history,
} from "./reactTestExtensions";
...
it("navigates to /addAppointment after the CustomerForm is submitted", () => {
  renderWithRouter(<App />);
  click(linkFor("/addCustomer"));
  const onSave = propsOf(CustomerForm).onSave;
  act(() => onSave(customer));
  expect(history.location.pathname).toEqual(
    "/addAppointment"
  );
});

预期是要测试历史记录是否正确更新。这个历史记录是来自test/reactTestExtensions.js的导出常量,它在我们在在路由器中测试组件部分定义的renderWithRouter函数中被设置。

这有一个变体。你不仅可以使用history导入,还可以简单地使用window.location实例:

expect(
  window.location.pathname
).toEqual("/addAppointment");

你现在已经学会了如何测试程序化的 React Router 导航。

在下一章“测试驱动 Redux”中,我们将看到如何使用这个相同的历史实例从 Redux sagas 中。

摘要

本章向您展示了如何以可测试的方式使用 React Router。你已经学会了如何测试驱动RouterRoutesRouteLink组件。你已经看到了如何使用 React Router 的useSearchParamsuseNavigate钩子。

最重要的是,你已经看到,由于路由为你的应用程序提供了额外的入口级别,你必须将现有的导航测试分成两部分:一部分测试链接是否存在(或被跟随),另一部分检查如果你访问该 URL,是否显示正确的组件。

现在我们已经成功集成了一个库,下一个库应该不会太棘手,对吧?在下一章中,我们将把本章学到的所有技能应用到另一个库的集成中:Redux。

练习

在本章中,没有进行详细说明,因为重构过程相当复杂,会占用相当的时间和空间。

利用这个机会尝试自我重构。使用系统化重构方法将 React Router 的更改分解成许多小步骤。在每一步中,你仍然应该有可工作的软件。

你可以在reacttdd.com/refactoring-to-react-router找到如何处理此类重构的指南。

进一步阅读

官方 React Router 文档可以在以下链接找到:

reacttraining.com/react-router/