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

28 阅读43分钟

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

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

译者:飞龙

协议:CC BY-NC-SA 4.0

第四章:测试驱动数据输入

在本章中,你将探索 React 表单和受控组件。

表单是构建 Web 应用程序的重要组成部分,是用户输入数据的主要方式。如果我们想确保我们的应用程序正常工作,那么不可避免地,这意味着我们需要为我们的表单编写自动化测试。更重要的是,在 React 中使表单工作需要大量的配置,这使得它们得到良好的测试变得尤为重要。

表单的自动化测试全部关于用户的行为:输入文本、点击按钮,以及表单完成时提交。

我们将构建一个新的组件,CustomerForm,当添加或修改客户时我们将使用它。它将包含三个文本字段:名字、姓氏和电话号码。

在构建这个表单的过程中,你将更深入地了解测试复杂的 DOM 元素树。你将学习如何使用参数化测试重复一组测试而不重复代码。

本章将涵盖以下主题:

  • 添加表单元素

  • 接受文本输入

  • 提交表单

  • 为多个表单字段复制测试

到本章结束时,你将能够理解使用 React 进行 HTML 表单的测试驱动开发。

技术要求

本章的代码文件可以在以下位置找到:github.com/PacktPublishing/Mastering-React-Test-Driven-Development-Second-Edition/tree/main/Chapter04

添加表单元素

HTML 表单是一系列字段,被包裹在 form 元素中。尽管我们主要对字段感兴趣,但我们仍需要从 form 元素本身开始。这就是本节我们将要构建的内容。

让我们按照以下步骤创建我们的第一个表单:

  1. 创建一个名为 test/CustomerForm.test.js 的新文件,并添加以下脚手架。它包含你在前几章中看到的所有常用导入和组件测试初始化:

    import React from "react";
    import {
      initializeReactContainer,
      render,
      element,
    } from "./reactTestExtensions";
    import { CustomerForm } from "../src/CustomerForm";
    describe("CustomerForm", () => {
      beforeEach(() => {
        initializeReactContainer();
      });
    });
    
  2. 现在你已经准备好创建你的第一个测试了。将以下测试添加到 describe 块中:

    it("renders a form", () => {
      render(<CustomerForm />);
      expect(element("form")).not.toBeNull();
    });
    
  3. 我们有一个完整的测试,所以让我们运行它看看会发生什么:

    FAIL test/CustomerForm.test.js
      ● Test suite failed to run
        Cannot find module '../src/CustomerForm' from 'CustomerForm.test.js'
    

失败告诉我们它找不到该模块。那是因为我们还没有创建它。

  1. 因此,创建一个名为 src/CustomerForm.js 的空白文件。再次运行你的测试应该会给出以下输出:

    FAIL test/CustomerForm.test.jsCustomerForm › renders a form
       Element type is invalid: expected a string (for built-in components) or a class/function (for composite components) but got: undefined. You likely forgot to export your component from the file it's defined in, or you might have mixed up default and named imports.
           8 |
           9 | export const render = (component) =>
        > 10 |   act(() => 
          11 |     ReactDOM.createRoot(...).render(...)
             |     ^
          12 |   );
          11 |
          12 | export const click = (element) =>
          13 |   act(() => element.click());
    

测试辅助代码的堆栈跟踪

Jest 的堆栈跟踪指向我们扩展代码中的失败,而不是测试本身。如果我们的代码在一个 npm 模块中,Jest 会跳过测试输出中的那些测试行。幸运的是,错误信息足够有帮助。

  1. 为了修复这个问题,我们需要添加一个与我们在测试文件顶部编写的导入匹配的导出。将以下行添加到 src/CustomerForm.js

    export const CustomerForm = () => null;
    
  2. 运行一些测试给出了实际的期望失败:

    CustomerForm › renders a form
      expect(received).not.toBeNull()
      Received: null
    

这可以通过让组件返回一些内容来修复:

import React from "react";
export const CustomerForm = () => <form />;

在继续之前,让我们提取一个用于查找 form 元素的辅助函数。正如前一章所述,这可能是过早的,因为我们现在只有一个测试使用这段代码。然而,当我们编写表单提交测试时,我们会感激有这个辅助函数。

  1. 打开 test/reactTestExtensions.js 并添加以下函数:

    export const form = (id) => element("form");
    
  2. 通过添加以下 import 修改你的测试文件。你可以保留 element 的导入,因为我们将在下一节中使用它:

    import {
      initializeReactContainer,
      render,
      element,
      form,
    } from "./reactTestExtensions";
    
  3. 最后,更新你的测试以使用辅助函数,如下所示。之后,你的测试应该仍然通过:

    it("renders a form", () => {
      render(<CustomerForm />);
      expect(form()).not.toBeNull();
    });
    

这就是创建基本 form 元素的全部内容。有了这个包装器,我们现在可以添加我们的第一个字段元素:一个文本框。

接受文本输入

在本节中,我们将添加一个文本框,以便添加或编辑客户的第一个名字。

添加一个文本字段比添加 form 元素更复杂。首先,有元素本身,它有一个需要测试的 type 属性。然后,我们需要用初始值初始化元素。最后,我们需要添加一个标签,以便清楚地表示字段的意义。

让我们从在页面上渲染一个 HTML 文本输入字段开始:

  1. 将以下测试添加到 test/CustomerForm.test.js 中。它包含三个期望(本章末尾有一个练习,你可以按照它来提取这些期望作为一个单独的匹配器):

    it("renders the first name field as a text box", () => {
      render(<CustomerForm />);
      const field = form().elements.firstName;
      expect(field).not.toBeNull();
      expect(field.tagName).toEqual("INPUT");
      expect(field.type).toEqual("text");
    });
    

依赖于 DOM 的表单 API

这个测试使用了表单 API:任何表单元素都允许你使用 elements 索引器访问其所有输入元素。你给出元素的 name 属性(在这个例子中,是 firstName),然后返回该元素。

这意味着我们必须检查返回的元素的标签。我们想确保它是一个 <input> 元素。如果我们没有使用表单 API,一个替代方案将是使用 elements("input")[0],它返回页面上第一个输入元素。这将使对元素 tagName 属性的期望变得不必要。

  1. 让我们加快速度。我们将一次使所有期望通过。更新 CustomerForm 以包括一个单独的输入字段,如下所示:

    export const CustomerForm = () => (
      <form
        <input type="text" name="firstName" />
      </form>
    );
    
  2. 由于这个表单将在修改现有客户以及添加新客户时使用,我们需要设计一种方法将现有客户数据放入组件中。我们将通过设置包含表单数据的 original 属性来实现这一点。添加以下测试:

    it("includes the existing value for the first name", () => {
      const customer = { firstName: "Ashley" };
      render(<CustomerForm original={customer} />);
      const field = form().elements.firstName;
      expect(field.value).toEqual("Ashley");
    });
    
  3. 要使这个测试通过,将组件定义更改为以下内容。我们将使用一个属性来传递之前的 firstName 值:

    export const CustomerForm = ({ original }) => (
      <form
        <input
          type="text"
          name="firstName"
    value={original.firstName} />
      </form>
    );
    
  4. 再次运行测试后,你会发现尽管这个测试现在通过了,但前两个测试失败了,因为它们没有指定 original 属性。更重要的是,我们有一个警告:

    Warning: You provided a `value` prop to a form field without an `onChange` handler. This will render a read-only field. If the field should be mutable use `defaultValue`. Otherwise, set either `onChange` or `readOnly`.
    
  5. 为了修复初始测试,创建一个新的常量 blankCustomer,它将作为我们的“基础”客户。对于不关心特定字段值的测试来说,这完全足够,比如我们的前两个测试。将此定义添加到 beforeEach 块之上:

    const blankCustomer = {
      firstName: "",
    };
    

对于指定一个空对象作为原始属性,有什么看法吗?

在这个对象定义中,我们将 firstName 的值设置为空字符串。你可能认为 undefinednull 是更好的候选值。这样,我们可以避免定义这样的对象,只需传递一个空对象 {}。不幸的是,当你尝试将受控组件的初始值设置为 undefined 时,React 会警告你,这是我们想要避免的。这不是什么大问题,而且除了这个之外,空字符串对于文本框来说是一个更现实的默认值。

  1. 更新前两个测试,使它们以设置 original 属性的方式渲染,如下所示。在这个更改到位后,你应该有三个通过测试,但警告仍然存在:

    it("renders a form", () => {
      render(<CustomerForm original={blankCustomer} />);
      expect(form()).not.toBeNull();
    });
    it("renders the first name field as a text box", () => {
      render(<CustomerForm original={blankCustomer} />);
      const field = form().elements.firstName;
      expect(field).not.toBeNull();
      expect(field.tagName).toEqual("INPUT");
      expect(field.type).toEqual("text");
    });
    
  2. 为了消除警告,将单词 readOnly 添加到输入标签中。你可能认为我们当然不希望有一个只读字段?你说得对,但我们需要一个进一步的测试,用于修改输入值,然后我们才能避免使用 readOnly 关键字。我们将在稍后添加那个测试:

    <input
      type="text"
      name="firstName"
      value={original.firstName}
      readOnly
    />
    

小贴士

总是认为 React 警告是测试失败。在没有先修复任何警告的情况下不要继续进行。

  1. 最后两个测试包括以下行,它进入表单以提取 firstName 字段:

    const field = form().elements.firstName;
    

让我们将这个功能提升到 test/reactTestExtensions.js 文件中。打开该文件,在 form 定义之后添加以下定义:

export const field = (fieldName) =>
  form().elements[fieldName];
  1. 然后,将其导入到 test/CustomerForm.js

    import {
      initializeReactContainer,
      render,
      element,
      form,
      field,
    } from "./reactTestExtensions";
    
  2. 更改你写的最后一个测试,使其使用新的辅助函数:

    it("includes the existing value for the first name", () => {
      const customer = { firstName: "Ashley" };
      render(<CustomerForm original={customer} />);
      expect(field("firstName").value).toEqual("Ashley");
    });
    
  3. 以相同的方式更新第一个测试:

    it("renders the first name field as a text box", () => {
      render(<CustomerForm original={blankCustomer} />);
      expect(field("firstName")).not.toBeNull();
      expect(field("firstName")).toEqual("INPUT");
      expect(field("firstName")).toEqual("text");
    });
    
  4. 接下来,我们将为该字段添加一个标签。添加以下测试,它使用 element 辅助函数:

    it("renders a label for the first name field", () => {
      render(<CustomerForm original={blankCustomer} />);
      const label = element("label[for=firstName]");
      expect(label).not.toBeNull();
    });
    
  5. 通过将新元素插入到 CustomerForm 的 JSX 中来使这个测试通过:

    <form
      <label htmlFor="firstName" />
      ...
    </form>
    

htmlFor 属性

JSX 的 htmlFor 属性设置了 HTML 的 for 属性。for 在 JSX 中不能使用,因为它是一个保留的 JavaScript 关键字。该属性用于表示标签与具有给定 ID 的表单元素相匹配——在这种情况下,firstName

  1. 让我们在那个标签中添加一些文本内容:

    it("renders 'First name' as the first name label content", () => {
      render(<CustomerForm original={blankCustomer} />);
      const label = element("label[for=firstName]");
      expect(label).toContainText("First name");
    });
    
  2. 更新 label 元素以使测试通过:

    <form
      <label htmlFor="firstName">First name</label>
      ...
    </form>
    
  3. 最后,我们需要确保我们的输入有一个与标签的 htmlFor 值匹配的 ID,以便它们可以匹配。添加以下测试:

    it("assigns an id that matches the label id to the first name field", () => {
      render(<CustomerForm original={blankCustomer} />);
      expect(field("firstName").id).toEqual("firstName");
    });
    
  4. 使其通过就像添加新的属性一样简单:

    <form>
      <label htmlFor="firstName">First name</label>
      <input
        type="text"
        name="firstName"
        id="firstName"
        value={firstName}
        readOnly
      />
    </form>
    

我们现在几乎已经为这个字段创建了所有需要的东西:输入字段本身、它的初始值和它的标签。但我们没有处理值更改的行为——这就是为什么我们有 readOnly 标志的原因。

仅在提交表单并更新数据的情况下更改行为才有意义:如果你无法提交表单,更改字段值就没有意义。这就是我们将在下一节中讨论的内容。

提交表单

对于本章,我们将定义“提交表单”为“调用当前customer对象的onSubmit回调函数”。onSubmit回调函数是我们将要传递的属性。

本节将介绍一种测试表单提交的方法。在第六章 探索测试替身 中,我们将更新这个调用为global.fetch,将我们的客户数据发送到应用程序的后端 API。

我们需要几个不同的测试来指定这种行为,每个测试都是逐步构建我们需要的功能。首先,我们将有一个测试来确保表单有一个提交按钮。然后,我们将编写一个测试来点击该按钮而不对表单进行任何更改。我们还需要另一个测试来检查提交表单不会导致页面导航发生。最后,在文本框的值更新后,我们将结束一个测试提交。

无更改提交

让我们从在表单中创建一个按钮开始。点击它将导致表单提交:

  1. 首先,添加一个测试来检查页面上是否存在提交按钮:

    it("renders a submit button", () => {
      render(<CustomerForm original={blankCustomer} />);
      const button = element("input[type=submit]");
      expect(button).not.toBeNull();
    });
    
  2. 为了使它通过,在表单的 JSX 底部添加以下单行:

    <form>
      ...
      <input type="submit" value="Add" />
    </form>
    
  3. 以下测试引入了一个新概念,所以我们将将其分解为其组成部分。首先,创建一个新的测试,starting,如下所示:

    it("saves existing first name when submitted", () => {
      expect.hasAssertions();
    });
    

hasAssertions期望告诉 Jest 它应该期望至少发生一个断言。它告诉 Jest 至少有一个断言必须在测试的作用域内运行;否则,测试就失败了。你将在下一步中看到为什么这很重要。

  1. 将以下测试部分添加到大纲中,在hasAssertions调用下方:

    const customer = { firstName: "Ashley" };
    render(
      <CustomerForm
        original={customer}
        onSubmit={({ firstName }) =>
          expect(firstName).toEqual("Ashley")
        }
      />
    );
    

这个函数调用是render调用本身和onSubmit处理程序的混合。这是我们希望 React 在表单提交时调用的处理程序。

  1. 通过在render调用下方添加以下行来完成测试。这是我们的测试的执行阶段,在这个测试中是测试的最后一个阶段:

    const button = element("input[type=submit]");
    click(button);
    

使用hasAssertions避免假阳性

你现在可以明白为什么我们需要hasAssertions。测试是按照顺序编写的,断言定义在onSubmit处理程序中。如果我们没有使用hasAssertions,这个测试现在就会通过,因为我们从未调用onSubmit

我不建议编写这样的测试。在第六章 探索测试替身 中,我们将发现hasAssertions。我们在这里使用的方法是有效的 TDD 实践;它只是有点混乱,所以你最终会想要重构它。

  1. 现在,你需要导入click

    import {
      initializeReactContainer,
      render,
      element,
      form,
      field,
      click,
    } from "./reactTestExtensions";
    
  2. 尽管测试设置很复杂,但使这个测试通过是直接的。更改组件定义,使其如下所示:

    export const CustomerForm = ({
      original,
      onSubmit
    }) => (
      <form onSubmit={() => onSubmit(original)}>
        ...
      </form>
    );
    
  3. 现在,使用npm test运行测试。你会发现测试通过了,但我们有一个新的警告,如下所示:

    console.error
    Error: Not implemented: HTMLFormElement.prototype.submit
        at module.exports (.../node_modules/jsdom/lib/jsdom/browser/not-implemented.js:9:17)
    

有些地方不太对劲。这个警告强调了我们需要注意的非常重要的事情。让我们停下来,仔细看看。

阻止默认提交操作

这个未实现控制台错误来自 JSDOM 包。HTML 表单在提交时有一个默认行为:它们会导航到另一个页面,这个页面由form元素的action属性指定。JSDOM 没有实现页面导航,这就是为什么我们会得到一个未实现错误。

在我们正在构建的典型 React 应用程序中,我们不想让浏览器导航。我们希望停留在同一页面上,并允许 React 使用提交操作的结果更新页面。

要做到这一点,我们需要从onSubmit属性中获取event参数,并在其上调用preventDefault

event.preventDefault();

由于这是生产代码,我们需要一个测试来验证这种行为。我们可以通过检查事件的defaultPrevented属性来完成:

expect(event.defaultPrevented).toBe(true);

现在,问题变成了,我们如何在测试中获取这个Event

我们需要自己创建event对象,并直接使用表单元素的dispatchEvent DOM 函数将其派发。这个事件需要标记为cancelable,这将允许我们在其上调用preventDefault

为什么点击提交按钮不起作用

在最后几项测试中,我们故意构建了一个可以点击以提交表单的提交按钮。虽然这对我们其他所有测试都有效,但对于这个特定的测试,它并不有效。这是因为 JSDOM 会将一个click事件内部转换为submit事件。如果我们无法访问 JSDOM 创建的submit事件对象,我们就无法获取它。因此,我们需要直接触发submit事件。

这不是一个问题。记住,在我们的测试套件中,我们努力模拟真实浏览器的行为——通过点击提交按钮来提交表单——但有一个测试工作方式不同并不是世界末日。

让我们把所有这些放在一起并修复警告:

  1. 打开test/reactTestExtensions.js,在click定义下方添加以下内容。我们将在下一个测试中使用它:

    export const submit = (formElement) => {
      const event = new Event("submit", {
        bubbles: true,
        cancelable: true,
      });
      act(() => formElement.dispatchEvent(event));
      return event;
    };
    

为什么我们需要 bubbles 属性?

如果这一切还不够复杂,我们还需要确保事件冒泡;否则,它不会到达我们的事件处理器。

当 JSDOM(或浏览器)派发一个事件时,它会遍历元素层次结构,寻找处理该事件的处理器,从事件派发的元素开始,通过父链接向上到根节点。这被称为冒泡。

为什么我们需要确保这个事件冒泡?因为 React 有自己的事件处理系统,它由事件到达 React 根元素触发。在 React 处理之前,submit事件必须冒泡到我们的container元素。

  1. 将新的辅助函数导入到test/CustomerForm.test.js

    import {
      ...,
      submit,
    } from "./reactTestExtensions";
    
  2. 将以下测试添加到CustomerForm测试套件的底部。它指定在表单提交时应调用preventDefault

    it("prevents the default action when submitting the form", () => {
      render(
        <CustomerForm
          original={blankCustomer}
          onSubmit={() => {}}
        />
      );
      const event = submit(form());
      expect(event.defaultPrevented).toBe(true);
    });
    
  3. 为了让这个通过,首先,更新CustomerForm使其具有显式的返回值:

    export const CustomerForm = ({
      original,
      onSubmit
    }) => {
      return (
        <form onSubmit={() => onSubmit(original)}>
          ...
        </form>
      );
    };
    
  4. 在返回之上添加一个新函数handleSubmit,并更新表单使其调用该函数:

    export const CustomerForm = ({
      original,
      onSubmit
    }) => {
      const handleSubmit = (event) => {
        event.preventDefault();
        onSubmit(original);
      };
      return (
        <form onSubmit={handleSubmit}>
        </form>
      );
    };
    
  5. 运行你的测试并确保它们都通过。

提交更改的值

现在是时候在我们的组件中引入一些状态了。我们将指定当使用文本字段更新客户的姓氏时应发生什么。

我们即将要做的事情中最复杂的部分是派发 DOM change事件。在浏览器中,这个事件在每次按键后都会派发,通知 JavaScript 应用程序文本字段的内容已更改。接收此事件的处理器可以查询target元素的value属性以找出当前值。

关键的是,我们在派发change事件之前负责设置value属性。我们通过调用value属性的 setter 来实现这一点。

对于我们这些测试人员来说,不幸的是,React 有一个为浏览器环境设计的更改跟踪行为,而不是 Node 测试环境。在我们的测试中,这种更改跟踪逻辑抑制了像我们测试将要派发的那样的事件。我们需要绕过这种逻辑,我们可以使用一个名为originalValueProperty的助手函数来实现,如下所示:

const originalValueProperty = (reactElement) => {
  const prototype =
    Object.getPrototypeOf(reactElement);
  return Object.getOwnPropertyDescriptor(
    prototype,
    "value"
  );
};

正如你将在下一节中看到的,我们将使用这个函数来绕过 React 的更改跟踪,并让它像浏览器一样处理我们的事件。

仅模拟最终更改

我们不会为每次按键创建一个change事件,而是只制造最终的实例。由于事件处理器始终可以访问元素的完整值,它可以忽略所有中间事件,只处理接收到的最后一个事件。

让我们从一点重构开始:

  1. 我们将使用提交按钮来提交表单。我们已经在之前的测试中找到了访问该按钮的方法:

    const button = element("input[type=submit]");
    

让我们将这个定义移动到test/reactTestExtensions.js,这样我们就可以在未来的测试中使用它。现在打开那个文件,并将此定义添加到末尾:

export const submitButton = () =>
  element("input[type=submit]");
  1. 返回到test/CustomerForm.test.js,并将新助手添加到导入中:

    import {
      ...,
      submitButton,
    } from "./reactTestExtensions";
    
  2. 更新渲染提交按钮测试,使其使用那个新助手,如下所示:

    it("renders a submit button", () => {
      render(<CustomerForm original={blankCustomer} />);
      expect(submitButton()).not.toBeNull();
    });
    

助手提取舞蹈

为什么我们只写一个变量(例如const button = ...)在测试中(如我们刚才对submitButton所做的),然后稍后将其提取为函数呢?

按照这种方法是一种系统地构建助手函数库的方法,这意味着你不必太过于考虑“正确”的设计。首先,从一个变量开始。如果你发现你会在第二次或第三次使用那个变量,那么将其提取为一个函数。没什么大不了的。

  1. 是时候编写下一个测试了。这与上一个测试非常相似,但现在,我们需要使用一个新的change辅助函数。我们将在下一步定义它:

    it("saves new first name when submitted", () => {
      expect.hasAssertions();
      render(
        <CustomerForm
          original={blankCustomer}
          onSubmit={({ firstName }) =>
            expect(firstName).toEqual("Jamie")
          }
        />
      );
      change(field("firstName"), "Jamie");
      click(submitButton());
    });
    
  2. 此函数使用本节开头讨论的新change辅助函数。将以下定义添加到test/reactTestExtensions.js中:

    const originalValueProperty = (reactElement) => {
      const prototype = 
        Object.getPrototypeOf(reactElement);
      return Object.getOwnPropertyDescriptor(
        prototype,
        "value"
      );
    };
    export const change = (target, value) => {
      originalValueProperty(target).set.call(
        target,
        value
      );
      const event = new Event("change", {
        target,
        bubbles: true,
      });
      act(() => target.dispatchEvent(event));
    };
    

确定 React 和 JSDOM 之间的交互

这里展示的change函数的实现并不明显。正如我们之前在bubbles属性中看到的,React 在 DOM 的常规事件系统之上做了一些相当巧妙的事情。

对 React 的工作原理有一个高级的认识是有帮助的。我还发现使用 Node 调试器逐步通过 JSDOM 和 React 源代码来找出流程中断的地方很有帮助。

  1. 要使这个通过,转到src/CustomerForm.js并将useState导入模块,通过修改现有的 React 导入:

    import React, { useState } from "react";
    
  2. 将客户常量定义改为通过调用useState来分配。默认状态是customer的原始值:

    const [ customer, setCustomer ] = useState(original);
    
  3. 创建一个新的箭头函数,它将充当我们的事件处理程序。你可以在上一步添加的useState行之后放置这个函数:

    const handleChangeFirstName = ({ target }) =>
      setCustomer((customer) => ({
        ...customer,
        firstName: target.value
      }));
    
  4. 在返回的 JSX 中,修改input元素,如下所示。我们将readOnly属性替换为onChange属性,并将其连接到我们刚刚创建的处理程序。现在,value属性也需要更新,以便它可以使用 React 组件状态而不是组件属性:

    <input
      type="text"
      name="firstName"
      id="firstName"
      value={customer.firstName}
      onChange={handleChangeFirstName}
    />
    
  5. 好吧,运行测试;现在它应该通过了。

通过这样,你已经学会了如何测试驱动changeDOM 事件,以及如何将其与 React 组件状态连接起来以保存用户的输入。接下来,是时候重复这个过程来处理另外两个文本框了。

为多个表单字段复制测试

到目前为止,我们已经编写了一套测试,完全定义了firstName文本字段。现在,我们想要添加两个更多字段,这些字段本质上与firstName字段相同,但具有不同的id值和标签。

在你伸手去复制粘贴之前,停下来想想你即将添加到你的测试和生产代码中的重复内容。我们有六个测试定义了名字。这意味着我们将最终得到 18 个测试来定义三个字段。那将是很多没有任何分组或抽象的测试。

因此,让我们同时做这两件事——也就是说,将我们的测试分组并抽象出一个为我们生成测试的函数。

嵌套describe

我们可以嵌套describe块,将类似的测试拆分为逻辑上下文。我们可以制定一个命名这些describe块的约定。顶级块以表单本身命名,而第二级describe块则以表单字段命名。

这是我们希望它们最终的样子:

describe("CustomerForm", () => {
  describe("first name field", () => {
    // ... tests ...
  };
  describe("last name field", () => {
    // ... tests ...
  };
  describe("phone number field", () => {
    // ... tests ...
  };
});

在此结构已建立的情况下,您可以通过删除字段的名称来简化it描述性文本。例如,"renders the first name field as a text box"变为"renders as a text box",因为它已经被"first name field" describe块所限定。由于 Jest 在测试输出中在测试名称之前显示describe块名称的方式,这些内容仍然读起来像一句普通的英语句子,但没有冗余的词汇。在刚才给出的例子中,Jest 将显示CustomerForm first name field renders as a text box

现在让我们为第一个字段(姓名字段)做这个操作。将六个现有的测试包裹在一个describe块中,然后重命名测试,如下所示:

describe("first name field", () => {
  it("renders as a text box" ... );
  it("includes the existing value" ... );
  it("renders a label" ... );
  it("assigns an id that matches the label id" ... );
  it("saves existing value when submitted" ... );
  it("saves new value when submitted" ... );
});

注意不要将preventsDefault测试包含在内,因为它不是字段特定的。您可能需要调整测试文件中测试的位置。

这就涵盖了测试分组。现在,让我们看看如何使用测试生成函数来减少重复。

生成参数化测试

一些编程语言,如 Java 和 C#,需要特殊的框架支持来构建参数化测试。但在 JavaScript 中,我们可以非常容易地自己实现参数化,因为我们的测试定义只是函数调用。我们可以利用这一点,将现有的六个测试作为接受参数值的函数提取出来。

这种类型的更改需要一些勤奋的重构。我们将前两个测试一起做,然后您可以重复这些步骤来完成剩下的五个测试,或者跳到 GitHub 仓库中的下一个标签:

  1. renders as a text box开始,将整个it调用包裹在一个箭头函数中,然后直接调用该函数,如下所示:

    const itRendersAsATextBox = () =>
      it("renders as a text box", () => {
        render(<CustomerForm original={blankCustomer} />);
        expect(field("firstName")).not.toBeNull();
        expect(field("firstName").tagName).toEqual(
          "INPUT"
        );
        expect(field("firstName").type).toEqual("text");
      });
    itRendersAsATextBox();
    
  2. 验证所有测试是否通过。

  3. 通过将firstName字符串提升为函数参数来参数化此函数。然后,您需要将firstName字符串传递给函数调用本身,如下所示:

    const itRendersAsATextBox = (fieldName) =>
      it("renders as a text box", () => {
        render(<CustomerForm original={blankCustomer} />);
        expect(field(fieldName)).not.toBeNull();
        expect(field(fieldName).tagName).toEqual("INPUT");
        expect(field(fieldName).type).toEqual("text");
      });
    itRendersAsATextBox("firstName");
    
  4. 再次,验证您的测试是否通过。

  5. itRendersAsATextBox函数提升一级,进入父describe作用域。这将允许您在后续的describe块中使用它。

  6. 对于下一个测试,includes the existing value,使用相同的程序:

    const itIncludesTheExistingValue = (
      fieldName,
      existing
    ) =>
      it("includes the existing value", () => {
        const customer = { [fieldName]: existing };
        render(<CustomerForm original={customer} />);
        expect(field(fieldName).value).toEqual(existing);
      });
    itIncludesTheExistingValue("firstName", "Ashley");
    
  7. 验证测试通过,然后将itIncludesTheExistingValue提升一级,进入父describe作用域。

  8. 对于标签测试,也可以在一个函数中包含,第二个测试可以在其测试定义中使用一个参数,如下所示:

    const itRendersALabel = (fieldName, text) => {
      it("renders a label for the text box", () => {
        render(<CustomerForm original={blankCustomer} />);
        const label = element(`label[for=${fieldName}]`);
        expect(label).not.toBeNull();
      });
      it(`renders '${text}' as the label content`, () => {
        render(<CustomerForm original={blankCustomer} />);
        const label = element(`label[for=${fieldName}]`);
        expect(label).toContainText(text);
      });
    };
    
  9. 对剩下的三个测试重复相同的步骤:

    const itAssignsAnIdThatMatchesTheLabelId = (
      fieldName
    ) => 
       ...
    const itSubmitsExistingValue = (fieldName, value) =>
       ...
    const itSubmitsNewValue = (fieldName, value) =>
       ...
    

重要提示

检查完整的解决方案,这可以在Chapter04/Complete目录中找到。

  1. 所做的一切完成后,您的describe块将简洁地描述姓名字段的功能:

    describe("first name field", () => {
      itRendersAsATextBox("firstName");
      itIncludesTheExistingValue("firstName", "Ashley");
      itRendersALabel("firstName", "First name");
      itAssignsAnIdThatMatchesTheLabelId("firstName");
      itSubmitsExistingValue("firstName", "Ashley");
      itSubmitsNewValue("firstName", "Jamie");
    });
    

退后一步,看看describe块的新形式。现在,理解这个字段应该如何工作的规范变得非常快。

解决一批测试

现在,我们想要复制最后名字字段的那六个测试。但我们如何着手呢?我们一个一个地测试,就像我们处理第一个名字字段时一样。然而,这次我们应该更快,因为我们的测试是一行代码,而生产代码是复制粘贴的工作。

因此,例如,第一个测试将是这样的:

describe("last name field", () => {
  itRendersAsATextBox("lastName");
});

你需要更新blankCustomer,使其包含新字段:

const blankCustomer = {
  firstName: "",
  lastName: "",
};

通过在firstName输入字段下方添加以下行,可以使该测试通过:

<input type="text" name="lastName" />

这只是输入字段的开始;你需要在添加接下来的几个测试时完成它。

继续添加剩余的五个测试,以及它们的实现。然后,为电话号码字段重复这个过程。在添加电话号码的提交测试时,确保提供一个由数字组成的字符串值,例如"012345"。在本书的后面部分,我们将添加验证,如果现在不使用正确的值,这些验证将失败。

跳过

你可能会想一次性解决所有 12 个新测试。如果你很有信心,那就试试吧!

如果你想要查看文件中所有测试的列表,你必须使用单个文件调用 Jest。运行npm test test/CustomerForm.test.js命令以查看其外观。或者,你可以运行npx jest --verbose来运行所有测试,并带有完整的测试列表:

PASS test/CustomerForm.test.js
  CustomerForm
    ✓ renders a form (28ms)
    first name field
      ✓ renders as a text box (4ms)
      ✓ includes the existing value (3ms)
      ✓ renders a label (2ms)
      ✓ saves existing value when submitted (4ms)
      ✓ saves new value when submitted (5ms)
    last name field
      ✓ renders as a text box (3ms)
      ✓ includes the existing value (2ms)
      ✓ renders a label (6ms)
      ✓ saves existing value when submitted (2ms)
      ✓ saves new value when submitted (3ms)
    phone number field
      ✓ renders as a text box (2ms)
      ✓ includes the existing value (2ms)
      ✓ renders a label (2ms)
      ✓ saves existing value when submitted (3ms)
      ✓ saves new value when submitted (2ms)

修改handleChange使其与多个字段一起工作

是时候进行小重构了。添加所有三个字段后,你将拥有三个非常相似的onChange事件处理程序:

const handleChangeFirstName = ({ target }) =>
  setCustomer((customer) => ({
    ...customer,
    firstName: target.value
  }));
const handleChangeLastName = ({ target }) =>
  setCustomer((customer) => ({
    ...customer,
    lastName: target.value
  }));
const handleChangePhoneNumber = ({ target }) =>
  setCustomer((customer) => ({
    ...customer,
    phoneNumber: target.value
  }));

你可以通过使用target上的name属性来简化这些函数,该属性与字段 ID 相匹配:

const handleChange = ({ target }) =>
  setCustomer(customer => ({
    ...customer,
   [target.name]: target.value
  }));

测试它

到这一阶段,你的AppointmentsDayView实例已经完成。现在是一个真正尝试它的好时机。

更新src/index.js中的入口点,使其渲染一个新的CustomerForm实例,而不是AppointmentsDayView。通过这样做,你应该准备好手动测试:

图 4.1 – 完成的 CustomerForm

图 4.1 – 完成的 CustomerForm

有了这个,你已经学会了一种快速在多个表单字段间复制规范的方法:因为describeit是普通的函数,你可以像对待任何其他函数一样对待它们,并在它们周围构建自己的结构。

摘要

在本章中,你学习了如何创建带有文本框的 HTML 表单。你为form元素和input元素(类型为textsubmit)编写了测试。

尽管文本框可能是最基础的输入元素,但我们利用这个机会深入研究了测试驱动的 React。我们发现通过 JSDOM 引发submitchange事件的复杂性,例如确保在事件上调用event.preventDefault()以避免浏览器页面转换。

我们在 Jest 上也做得更深入。我们将常见的测试逻辑提取到模块中,使用了嵌套的 describe 块,并使用 DOM 的表单 API 构建断言。

在下一章中,我们将测试一个更复杂的表单示例:一个包含下拉框和单选按钮的表单。

练习

以下是一些供你尝试的练习:

  1. labelFor 辅助函数提取到 test/reactTestExtensions.js 中。它应该这样使用:

    expect(labelFor(fieldName)).not.toBeNull();
    
  2. 添加一个 toBeInputFieldOfType 匹配器,以替换 itRendersAsATextBox 函数中的三个期望。它应该这样使用:

    expect(field(fieldName)).toBeInputFieldOfType("text");
    

第五章:添加复杂表单交互

是时候将你所学应用到更复杂的 HTML 设置中了。在本章中,我们将测试一个新的组件:AppointmentForm。它包含一个下拉框,用于选择所需的服务,以及一组单选按钮,形成一个用于选择预约时间的日历视图。

结合布局和表单输入,本章中的代码展示了 TDD 如何为你提供一个工作结构,使复杂场景变得简单易懂:你将使用测试来扩展组件成为组件层次结构,随着组件开始增长,将功能从主组件中分离出来。

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

  • 从下拉框中选择一个值

  • 构建日历视图

  • 测试单选按钮组

  • 减少构建组件时的努力

到本章结束时,你将学会如何将测试驱动开发应用于复杂用户输入场景。这些技术对所有类型的表单组件都很有用,而不仅仅是下拉框和单选按钮。

技术要求

本章的代码文件可以在以下位置找到:github.com/PacktPublishing/Mastering-React-Test-Driven-Development-Second-Edition/tree/main/Chapter05

从下拉框中选择一个值

让我们从创建一个名为AppointmentForm的组件来预订新预约开始。

第一字段是一个下拉框,用于选择客户所需的服务:剪发、染色、吹干等。让我们现在创建它:

  1. 创建一个新文件,test/AppointmentForm.test.js,包含以下测试和设置:

    import React from "react";
    import {
      initializeReactContainer,
      render,
      field,
      form,
    } from "./reactTestExtensions";
    import { AppointmentForm } from "../src/AppointmentForm";
    describe("AppointmentForm", () => {
      beforeEach(() => {
        initializeReactContainer();
      });
      it("renders a form", () => {
        render(<AppointmentForm />);
        expect(form()).not.toBeNull();
      });
    });
    
  2. 通过实现并创建一个新文件,src/AppointmentForm.js,如下所示,使这个测试通过:

    import React from "react";
    export const AppointmentForm = () => <form />;
    
  3. 为服务字段创建一个嵌套的describe块。我们将立即跳到这一点,因为我们知道这个表单将包含多个字段:

    describe("service field", () => {
    });
    
  4. 将以下测试添加到describe块中:

    it("renders as a select box", () => {
      render(<AppointmentForm />);
      expect(field("service").not.toBeNull();
      expect(field("service").tagName).toEqual("SELECT");
    });
    
  5. 要使这个测试通过,修改AppointmentForm组件,如下所示:

    export const AppointmentForm = () => (
      <form
        <select name="service" />
      </form>
    );
    
  6. 运行测试并确保它们全部通过。

通过这样,我们已经为新下拉框字段完成了基本的脚手架,使其准备好填充option元素。

提供下拉框选项

我们的沙龙提供一系列沙龙服务。我们应该确保它们都在应用程序中列出。我们可以从定义我们的期望开始测试,如下所示:

it("lists all salon services", () => {
  const selectableServices = [
    "Cut",
    "Blow-dry",
    "Cut & color",
    "Beard trim",
    "Cut & beard trim",
    "Extensions"
  ];
  ...
});

如果我们这样做,我们最终会在测试代码和生产代码中重复相同的数组服务。我们可以通过将单元测试集中在下拉框的行为上而不是填充它的静态数据来避免这种重复:下拉框应该做什么

结果表明,我们只需要在数组中指定两个项目就可以指定我们的选择框的功能。保持数组简短还有另一个很好的原因,那就是这有助于我们集中测试的重点:行为,而不是数据。

这就留下了一个问题,当我们需要六个项目用于生产代码时,我们如何在测试中只使用两个项目?

我们将通过向AppointmentForm引入一个新的属性selectableServices来实现这一点。我们的测试可以选择指定一个值,如果需要的话。在我们的生产代码中,我们可以为组件的defaultProps指定一个值。

defaultProps是 React 提供的一种巧妙机制,用于设置当所需的属性未明确提供时将使用的默认属性值。

对于那些不关心选择框值的测试,我们可以避免传递属性并在测试中完全忽略它。对于那些确实关心的测试,我们可以为测试提供一个简短的两个项目数组。

我们如何验证实际的选择框值?

测试静态数据确实会发生,但不是在我们的单元测试中。这种测试可以在验收测试中进行,我们将在第四部分使用 Cucumber 的行为驱动开发中探讨。

我们将从确保第一个值是一个空白条目开始测试。这是当用户创建新的预约时最初选择的值:没有选择任何选项。让我们现在编写这个测试:

  1. AppointmentForm测试套件的末尾添加以下测试。它指定选择框中的第一个项目是空白,这意味着用户不会自动从我们的服务列表中分配一个选择:

    it("has a blank value as the first value", () => {
      render(<AppointmentForm />);
      const firstOption = field("service").childNodes[0];
      expect(firstOption.value).toEqual("");
    });
    
  2. 通过向现有的select元素添加一个空白的option元素来使这个测试通过:

    export const AppointmentForm = () => (
      <form
        <select name="service">
          <option />
        </select>
      </form>
    );
    
  3. 在你的测试中,在beforeEach块之后添加这个新的辅助函数。我们将在下一个测试中使用它来构建选择框选项的所有标签的数组:

    const labelsOfAllOptions = (element) =>
      Array.from(
        element.childNodes,
        (node) => node.textContent
      );
    
  4. 添加以下测试。这使用了新的属性selectableServices,它简单地是可用选项的数组:

    it("lists all salon services", () => {
      const services = ["Cut", "Blow-dry"];
    
      render(
        <AppointmentForm selectableServices={services} />
      );
    
      expect(
        labelsOfAllOptions(field("service"))
      ).toEqual(expect.arrayContaining(services));
    });
    

选择测试数据

我已经为预期的服务使用了“真实”数据:CutBlow-dry。使用非真实名称,如Service AService B也是可以的。通常,这可以提供更详细的描述。这两种方法都是有效的。

  1. 让我们使这个测试通过。更改组件定义,如下所示:

    export const AppointmentForm = ({
      selectableServices
    }) => (
      <form>
        <select name="service">
          <option />
          {selectableServices.map(s => (
            <option key={s}>{s}</option>
          ))}
        </select>
      </form>
    );
    
  2. 检查最新的测试现在是否通过。然而,你会看到我们之前的测试因为引入了新的属性而失败了。

  3. 我们可以使用defaultProps使这些测试再次通过。在src/AppointmentForm.jsAppointmentForm函数定义下方,添加以下内容:

    AppointmentForm.defaultProps = {
      selectableServices: [
        "Cut",
        "Blow-dry",
        "Cut & color",
        "Beard trim",
        "Cut & beard trim",
        "Extensions",
      ]
    };
    
  4. 运行你的测试并验证它们是否通过。

这就是全部内容。通过这样,我们学习了如何使用简短的两个项目数组来定义我们组件的行为,并将真实数据保存为defaultProps

预选一个值

让我们确保如果我们在编辑现有的预约,我们的组件会预选已经保存的值:

  1. describe 块的顶部定义一个 findOption 箭头函数。这个函数在 DOM 树中搜索特定的文本节点:

    const findOption = (selectBox, textContent) => {
      const options = Array.from(selectBox.childNodes);
      return options.find(
        option => option.textContent === textContent
      );
    };
    
  2. 在我们的下一个测试中,我们可以找到这个节点,然后检查它是否被选中:

    it("pre-selects the existing value", () => {
      const services = ["Cut", "Blow-dry"];
      const appointment = { service: "Blow-dry" };
      render(
        <AppointmentForm
          selectableServices={services}
          original={appointment}
        />
      );
      const option = findOption(
        field("service"),
        "Blow-dry"
      );
      expect(option.selected).toBe(true);
    });
    
  3. 要使这个通过,请设置根 select 标签的值属性:

    <select
      name="service"
      value={original.service}
      readOnly>
    

可访问的富互联网应用程序(ARIA)标签

如果你有过构建 React 应用程序的经验,你可能期望在 select 元素上设置 aria-label 属性。然而,本章的练习之一是为这个 select 框添加一个标签元素,这将确保浏览器隐式设置 ARIA 标签。

  1. 你需要更改你的组件属性,使其包括新的 service 属性:

    export const AppointmentForm = ({
      original,
      selectableServices
    }) =>
    
  2. 运行你的测试。尽管这个测试现在通过了,但你将发现之前的测试失败了,因为原始属性尚未设置。要修复它们,首先,定义一个新的常量 blankAppointment,就在你的 beforeEach 块之上。我们将在每个失败的测试中使用它:

    const blankAppointment = {
      service: "",
    };
    
  3. 更新你的先前测试,以便它们使用这个新常量作为 original 属性的值。例如,AppointmentForm 的第一个测试将如下所示:

    it("renders a form", () => {
      render(
        <AppointmentForm original={blankAppointment} />
      );
      expect(form()).not.toBeNull();
    });
    
  4. 再次使用 npm test 运行测试;所有测试应该都通过。(如果它们没有通过,请返回并检查每个测试是否都有一个 original 属性值。)

  5. 让我们以一小部分重构来结束。你的最后两个测试都有相同的服务定义。将其从每个测试中提取出来,放在 blankAppointment 定义之上。确保你从两个测试中删除该行:

    describe("AppointmentForm", () => {
      const blankAppointment = {
        service: "",
      };
    const services = ["Cut", "Blow-dry"]; 
      ...
    });
    

这完成了这个测试,但如果我们要有一个完全功能的下拉框,还需要添加更多功能。完成这些测试被留作本章末尾的练习之一。它们的工作方式与 CustomerForm 中的文本框测试相同。

如果你比较我们的下拉框测试和文本框测试,你会看到它们是相似的,但有一些额外的技术:我们使用了 defaultProps 来分离生产数据定义和测试行为,并定义了几个本地化助手方法,labelsOfAllOptionsfindOption,以帮助缩短我们的测试。

让我们继续到我们表单的下一个项目:预约的时间。

构建日历视图

在本节中,我们将学习如何使用我们现有的助手,例如 elementelements,结合 CSS 选择器,来选择我们 HTML 布局中感兴趣的具体元素。

但首先,让我们做一些规划。

我们希望 AppointmentForm 能够以网格的形式显示未来 7 天的可用时间段,列代表天数,行代表 30 分钟的时间段,就像标准的日历视图一样。用户将能够快速找到一个适合他们的时间段,然后在提交表单之前选择正确的单选按钮:

图 5.1 – 我们日历视图的视觉设计

图 5.1 – 我们日历视图的视觉设计

这是我们试图构建的 HTML 结构示例。我们可以将其用作我们编写 React 组件时的指南:

<table id="time-slots">
  <thead>
    <tr>
      <th></th>
      <th>Oct 11</th>
      <th>Oct 12</th>
      <th>Oct 13</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th>9:00</th>
      <td>
        <input type="option" name="timeSlot" value="..." />
      </td>
    </tr>
    <!-- ... two more cells ... -->
  </tbody>
</table>

在接下来的几节中,我们将对table元素本身进行测试驱动,然后为一天中的时间添加一个标题列,然后为一周中的日子添加一个标题。

添加表格

让我们从构建table本身开始:

  1. test/AppointmentForm.test.js的底部创建一个嵌套的describe块,并添加一个新的测试:

    describe("time slot table", () => {
      it("renders a table for time slots with an id", () => {
        render(
          <AppointmentForm original={blankAppointment} />
        );
        expect(
          element("table#time-slots")
        ).not.toBeNull();
      });
    });
    
  2. 你需要将element辅助函数拉入你的导入中:

    import {
      initializeReactContainer,
      render,
      field,
      form,
      element,
    } from "./reactTestExtensions";
    
  3. 为了使这个测试通过,转到src/AppointmentForm.js并定义一个新的TimeSlotTable组件,在AppointmentForm定义之上。我们不需要将其标记为导出,因为它只将被AppointmentForm引用:

    const TimeSlotTable = () => <table id="time-slots" />;
    

为什么添加一个 ID?

ID 很重要,因为这是应用程序的 CSS 用来查找table元素的方式。尽管这在本章中没有涉及,如果你使用 CSS 并且它基于元素 ID 定义选择器,那么你应该将这些 ID 视为一种技术规范,你的代码必须满足。这就是为什么我们为它们编写单元测试的原因。

  1. 将此组件添加到你的AppointmentForm JSX 中,正好在select标签下方:

    <form>
      ...
      <TimeSlotTable />
    </form>;
    

运行测试并验证它们是否全部通过。

这就是table元素的全部内容。现在,让我们将一些数据放入第一列。

添加标题列

对于下一个测试,我们将测试显示时间列表的左侧标题列。我们将引入两个新的属性salonOpensAtsalonClosesAt,它们会通知组件每天显示哪个时间。按照以下步骤操作:

  1. 添加以下测试:

    it("renders a time slot for every half an hour between open and close times", () => {
      render(
        <AppointmentForm
          original={blankAppointment}
          salonOpensAt={9}
          salonClosesAt={11}
        />
      );
      const timesOfDayHeadings = elements("tbody >* th");
      expect(timesOfDayHeadings[0]).toContainText(
        "09:00"
      );
      expect(timesOfDayHeadings[1]).toContainText(
        "09:30"
      );
      expect(timesOfDayHeadings[3]).toContainText(
        "10:30"
      );
    });
    

断言数组模式

在这个例子中,我们正在检查数组中的三个条目的textContent,尽管数组中有四个条目。

对于所有数组条目都相同的属性,只需要在其中一个条目上测试。对于每个条目都不同的属性,例如textContent,需要根据需要测试的模式数量在两个或三个条目上测试。

对于这个测试,我想测试它是否在正确的时间开始和结束,并且每个时间槽增加 30 分钟。我可以通过对数组条目 0、1 和 3 的断言来实现这一点。

这个测试“违反”了我们每个测试只有一个预期的规则。然而,在这种情况下,我认为这是可以接受的。另一种方法可能是使用textOf辅助函数。

  1. 你需要将elements辅助函数拉入你的导入中:

    import {
      initializeReactContainer,
      render,
      field,
      form,
      element,
      elements,
    } from "./reactTestExtensions";
    
  2. 为了使这个测试通过,在TimeSlotTable组件之上添加以下函数。它们计算每日时间槽的列表:

    const timeIncrements = (
      numTimes,
      startTime,
      increment
    ) =>
      Array(numTimes)
        .fill([startTime])
        .reduce((acc, _, i) =>
          acc.concat([startTime + i * increment])
        );
    const dailyTimeSlots = (
      salonOpensAt,
      salonClosesAt
    ) => {
      const totalSlots =
        (salonClosesAt – salonOpensAt) * 2;
      const startTime = new Date()
        .setHours(salonOpensAt, 0, 0, 0);
      const increment = 30 * 60 * 1000;
      return timeIncrements(
        totalSlots,
        startTime,
        increment
      );
    };
    
  3. 定义toTimeValue函数,如下所示:

    const toTimeValue = timestamp =>
      new Date(timestamp).toTimeString().substring(0, 5);
    
  4. 现在,你可以使用这两个函数。更新TimeSlotTable,使其如下所示:

    const TimeSlotTable = ({
      salonOpensAt,
      salonClosesAt
    }) => {
      const timeSlots = dailyTimeSlots(
        salonOpensAt,
        salonClosesAt);
      return (
        <table id="time-slots">
          <tbody>
            {timeSlots.map(timeSlot => (
              <tr key={timeSlot}>
                <th>{toTimeValue(timeSlot)}</th>
              </tr>
            ))}
          </tbody>
        </table>
      );
    };
    
  5. AppointmentForm的 JSX 中,将salonOpensAtsalonClosesAt属性传递给TimeSlotTable

    export const AppointmentForm = ({
      original,
      selectableServices,
      service, 
      salonOpensAt,
      salonClosesAt
    }) => (
      <form>
        ...
        <TimeSlotTable
          salonOpensAt={salonOpensAt}
          salonClosesAt={salonClosesAt} />
      </form>
    );
    
  6. salonOpensAtsalonsCloseAt填充defaultProps

    AppointmentForm.defaultProps = {
      salonOpensAt: 9,
      salonClosesAt: 19,
      selectableServices: [ ... ]
    };
    
  7. 运行测试并确保一切通过。

这就是添加左侧标题列的全部内容。

添加标题行

那么,列标题怎么办?在本节中,我们将创建一个新的顶部行,包含这些单元格,并确保在左上角留出一个空单元格,因为左列包含时间标题而不是数据。按照以下步骤操作:

  1. 添加以下测试:

    it("renders an empty cell at the start of the header row", () => 
      render(
        <AppointmentForm original={blankAppointment} />
      );
      const headerRow = element("thead > tr");
      expect(headerRow.firstChild).toContainText("");
    });
    
  2. 修改表格 JSX,使其包含一个新的表格行:

    <table id="time-slots">
      <thead>
        <tr>
          <th />
        </tr>
      </thead>
      <tbody>
        ...
      </tbody>
    </table>
    
  3. 对于标题行的其余部分,我们将从今天开始显示 7 天。AppointmentForm需要接受一个新的属性today,这是表格中要显示的第一天。分配给该属性的值存储在一个名为specificDate的变量中。这个名字被选择用来强调这个选定的日期会影响渲染的日期输出,例如,"Sat 01"

    it("renders a week of available dates", () => {
      const specificDate = new Date(2018, 11, 1);
      render(
        <AppointmentForm
          original={blankAppointment}
          today={specificDate}
        />
      );
      const dates = elements(
        "thead >* th:not(:first-child)"
      );
      expect(dates).toHaveLength(7);
      expect(dates[0]).toContainText("Sat 01");
      expect(dates[1]).toContainText("Sun 02");
      expect(dates[6]).toContainText("Fri 07");
    });
    

为什么要将日期传递给组件?

当你在测试处理日期和时间的组件时,你几乎总是想要一种方式来控制组件将看到的日期和时间值,就像我们在这次测试中所做的那样。你很少想只使用现实世界的时间,因为这可能会在未来导致间歇性故障。例如,你的测试可能假设一年中至少有 30 天,这只有在 12 个月中的 11 个月是正确的。将月份固定在特定月份比在二月到来时出现意外故障要好。

关于这个话题的深入讨论,请查看reacttdd.com/controlling-time

  1. 要实现这个过渡,首先,创建一个函数来列出我们想要的 7 天,就像我们处理时间段时做的那样。你可以把这个函数放在toTimeValue函数之后:

    const weeklyDateValues = (startDate) => {
      const midnight = startDate.setHours(0, 0, 0, 0);
      const increment = 24 * 60 * 60 * 1000;
      return timeIncrements(7, midnight, increment);
    };
    
  2. 定义toShortDate函数,将我们的日期格式化为短字符串:

    const toShortDate = (timestamp) => {
      const [day, , dayOfMonth] = new Date(timestamp)
        .toDateString()
        .split(" ");
      return `${day} ${dayOfMonth}`;
    };
    
  3. 修改TimeSlotTable,使其接受新的today属性并使用这两个新函数:

    const TimeSlotTable = ({
      salonOpensAt,
      salonClosesAt,
      today
    }) => {
      const dates = weeklyDateValues(today);
      ...
      return (
        <table id="time-slots">
          <thead>
            <tr>
              <th />
              {dates.map(d => (
                <th key={d}>{toShortDate(d)}</th>
              ))}
            </tr>
          </thead>
          ...
        </table>
      )
    };
    
  4. AppointmentForm内部,将today属性从AppointmentForm传递给TimeSlotTable

    export const AppointmentForm = ({
      original,
      selectableServices,
      service,
      salonOpensAt,
      salonClosesAt,
      today
    }) => {
      ...
      return <form>
        <TimeSlotTable
          ...
          salonOpensAt={salonOpensAt}
          salonClosesAt={salonClosesAt}
          today={today}
        />
      </form>;
    };
    
  5. 最后,为today添加一个defaultProp。通过调用Date构造函数将其设置为当前日期:

    AppointmentForm.defaultProps = {
      today: new Date(),
      ...
    }
    
  6. 运行测试。它们应该都是绿色的。

这样,我们就完成了表格布局。你已经看到了如何编写指定表格结构本身的测试,并填写了标题列和标题行。在下一节中,我们将用单选按钮填充表格单元格。

测试驱动单选按钮组

现在我们已经放置了带有标题的表格,是时候给每个表格单元格添加单选按钮了。并不是所有单元格都会有单选按钮——只有代表可用时间段单元格才会有单选按钮。

这意味着我们需要向AppointmentForm传递另一个新的属性,这将帮助我们确定要显示哪些时间段。这个属性是availableTimeSlots,它是一个对象数组,列出了仍然可用的时段。按照以下步骤操作:

  1. 添加以下测试,它为availableTimeSlots属性设置一个值,然后检查是否为每个时间段渲染了单选按钮:

    it("renders radio buttons in the correct table cell positions", () => {
      const oneDayInMs = 24 * 60 * 60 * 1000;
      const today = new Date();
      const tomorrow = new Date(
        today.getTime() + oneDayInMs
      );
      const availableTimeSlots = [
        { startsAt: today.setHours(9, 0, 0, 0) },
        { startsAt: today.setHours(9, 30, 0, 0) },
        { startsAt: tomorrow.setHours(9, 30, 0, 0) },
      ];
      render(
        <AppointmentForm
          original={blankAppointment}
          availableTimeSlots={availableTimeSlots}
          today={today}
        />
      );
      expect(cellsWithRadioButtons()).toEqual([0, 7, 8]);
    });
    
  2. 注意,此测试使用了一个cellsWithRadioButtons辅助函数,我们现在需要定义它。你可以将它放在测试上方;没有必要将其移动到扩展模块中,因为它只针对这个组件:

    const cellsWithRadioButtons = () =>
      elements("input[type=radio]").map((el) =>
        elements("td").indexOf(el.parentNode)
      );
    
  3. 此测试检查今天前两个时间段内是否有单选按钮。这些按钮将位于单元格 0 和 7,因为elements按页面顺序返回匹配的元素。我们可以通过在AppointmentForm的渲染方法中添加以下内容来非常简单地使此测试通过,就在每个tr中的th下面:

    {timeSlots.map(timeSlot =>
      <tr key={timeSlot}>
        <th>{toTimeValue(timeSlot)}</th>
        {dates.map(date => (
          <td key={date}>
            <input type="radio" />
          </td>
        ))}
      </tr>
    )}
    

到目前为止,你的测试将通过。

尽管我们的测试需要它,但我们不需要在产品代码中使用availableTimeSlots!相反,我们只是在每个单元格中放了一个单选按钮!这显然是“错误的”。然而,如果你回想起我们只实现使测试通过的最简单规则,那么这就有意义了。我们现在需要另一个测试来证明相反的情况——在availableTimeSlots给定的情况下,某些单选按钮不存在

隐藏输入控件

我们如何得到正确的实现?我们可以通过测试没有可用的时间段将不会渲染任何单选按钮来实现:

  1. 添加以下测试:

    it("does not render radio buttons for unavailable time slots", () => {
      render(
        <AppointmentForm
          original={blankAppointment}
          availableTimeSlots={[]}
        />
      );
      expect(
        elements("input[type=radio]")
      ).toHaveLength(0);
    });
    
  2. 要使它通过,首先,转到src/AppointmentForm.js并在TimeSlotTable组件上方定义mergeDateAndTime函数。这个函数从列标题中获取日期,以及从行标题中获取时间,并将它们转换成我们可以用来与availableTimeSlots中的startsAt字段进行比较的时间戳:

    const mergeDateAndTime = (date, timeSlot) => {
      const time = new Date(timeSlot);
      return new Date(date).setHours(
        time.getHours(),
        time.getMinutes(),
        time.getSeconds(),
        time.getMilliseconds()
      );
    };
    
  3. 更新TimeSlotTable使其接受新的availableTimeSlots属性:

    const TimeSlotTable = ({
      salonOpensAt,
      salonClosesAt,
      today,
      availableTimeSlots
    }) => {
      ...
    };
    
  4. 用 JSX 条件替换TimeSlotTable中现有的单选按钮元素:

    {dates.map(date =>
      <td key={date}>
        {availableTimeSlots.some(availableTimeSlot =>
          availableTimeSlot.startsAt === mergeDateAndTime(date, timeSlot)
        )
    ? <input type="radio" />
         : null
        }
      </td>
    )}
    
  5. 此外,更新AppointmentForm使其接受新的属性,并将其传递给TimeSlotTable

    export const AppointmentForm = ({
      original,
      selectableServices,
      service,
      salonOpensAt,
      salonClosesAt,
      today,
      availableTimeSlots
    }) => {
      ...
      return (
        <form>
          ...
          <TimeSlotTable
            salonOpensAt={salonOpensAt}
            salonClosesAt={salonClosesAt}
            today={today}
            availableTimeSlots={availableTimeSlots} />
        </form>
      );
    };
    
  6. 虽然你的测试现在将通过,但其余的将失败:它们需要一个availableTimeSlots属性的值。为此,首先,在AppointmentForm的顶部添加以下定义:

    describe("AppointmentForm", () => {
      const today = new Date();
      const availableTimeSlots = [
        { startsAt: today.setHours(9, 0, 0, 0) },
        { startsAt: today.setHours(9, 30, 0, 0) },
      ];
    
  7. 遍历每个测试并更新每个渲染调用,以指定一个值为availableTimeSlotsavailableTimeSlots属性。例如,第一个测试应该有以下的渲染调用:

    render(
      <AppointmentForm
        original={blankAppointment}
        availableTimeSlots={availableTimeSlots}
      />
    );
    

处理属性的合理默认值

在每个测试中为新的属性添加默认值并不是什么有趣的事情。在本章的后面,你将学习如何通过引入一个testProps对象来分组合理的默认属性值,以避免在测试中出现属性爆炸。

  1. 让我们继续下一个测试。我们必须确保每个单选按钮都有正确的值。我们将使用每个单选按钮的startsAt值。单选按钮的值是字符串,但预约对象的属性startsAt是数字。我们将使用标准库函数parseInt将按钮值转换回可用的数字:

    it("sets radio button values to the startsAt value of the corresponding appointment", () => {
      render(
        <AppointmentForm
          original={blankAppointment}
          availableTimeSlots={availableTimeSlots}
          today={today}
        />
      );
      const allRadioValues = elements(
        "input[type=radio]"
      ).map(({ value }) => parseInt(value));
      const allSlotTimes = availableTimeSlots.map(
        ({ startsAt }) => startsAt
      );
      expect(allRadioValues).toEqual(allSlotTimes);
    });
    

在测试中定义常量

有时候,在测试中保留常量而不是将它们作为辅助函数提取出来更可取。在这种情况下,这些辅助函数仅由这个测试使用,并且它们所做的事情非常具体。将它们保留在行内可以帮助你理解函数在做什么,而无需在文件中搜索函数定义。

  1. 在你的生产代码中,将包含原始mergeDateAndTime调用的三元表达式提取到一个新的组件中。注意向input元素添加新的namevalue属性:

    const RadioButtonIfAvailable = ({
      availableTimeSlots,
      date,
      timeSlot,
    }) => {
      const startsAt = mergeDateAndTime(date, timeSlot);
      if (
        availableTimeSlots.some(
          (timeSlot) => timeSlot.startsAt === startsAt
        )
      ) {
        return (
          <input
            name="startsAt"
            type="radio"
            value={startsAt}
          />
        );
      }
      return null;
    };
    

名称属性

具有相同name属性的无线电按钮属于同一组。点击一个单选按钮将选中该按钮并取消选中组中的所有其他按钮。

  1. 你现在可以在TimeSlotTable中使用这个组件,用这个功能组件的实例替换现有的三元表达式。在此之后,你的测试应该通过:

    {dates.map(date =>
      <td key={date}>
        <RadioButtonIfAvailable
          availableTimeSlots={availableTimeSlots}
          date={date}
          timeSlot={timeSlot}
        />
      </td>
    )}
    

现在你已经正确显示了单选按钮,是时候给它们添加一些行为。

在一组中选择单选按钮

让我们看看如何使用输入元素上的checked属性来确保为我们的单选按钮设置正确的初始值。

为了做到这一点,我们将使用一个名为startsAtField的辅助函数,它接受一个索引并返回该位置的单选按钮。为了做到这一点,所有单选按钮都必须具有相同的名称。这意味着单选按钮被组合成一个组,这意味着一次只能选择一个。按照以下步骤操作:

  1. 首先,在时间表表的describe块顶部添加startsAtField辅助函数:

    const startsAtField = (index) =>
      elements("input[name=startsAt]")[index];
    
  2. 添加以下测试。它传递了一个现有的预约,其startsAt值设置为availableTimeSlots列表中的第二个项目。选择第二个项目而不是第一个项目并不是严格必要的(因为默认情况下,所有单选按钮都将被取消选中),但它可以帮助未来的维护者突出显示已经选择并正在检查的特定值:

    it("pre-selects the existing value", () => {
      const appointment = {
        startsAt: availableTimeSlots[1].startsAt,
      };
      render(
        <AppointmentForm
          original={appointment}
          availableTimeSlots={availableTimeSlots}
          today={today}
        />
      );
      expect(startsAtField(1).checked).toEqual(true);
    });
    
  3. 要实现这个传递,首先,向TimeSlotTable添加一个新的checkedTimeSlot属性,其值为原始的startsAt值:

    <TimeSlotTable
      salonOpensAt={salonOpensAt}
      salonClosesAt={salonClosesAt}
      today={today
      availableTimeSlots={availableTimeSlots}
      checkedTimeSlot={appointment.startsAt}
    />
    
  4. 更新TimeSlotTable,使其利用这个新属性,将其传递给RadioButtonIfAvailable

    const TimeSlotTable = ({
      ...,
      checkedTimeSlot,
    }) => {
      ...
        <RadioButtonIfAvailable
          availableTimeSlots={availableTimeSlots}
          date={date}
          timeSlot={timeSlot}
          checkedTimeSlot={checkedTimeSlot}
        />
      ...
    };
    
  5. 现在,你可以在RadioButtonIfAvailable中使用它,在输入元素上设置isChecked属性,如这里所示。在此更改之后,你的测试应该通过:

    const RadioButtonIfAvailable = ({
      ...,
      checkedTimeSlot,
    }) => {
      const startsAt = mergeDateAndTime(date, timeSlot);
      if (
        availableTimeSlots.some(
          (a) => a.startsAt === startsAt
        )
      ) {
        const isChecked = startsAt === checkedTimeSlot;
        return (
          <input
            name="startsAt"
            type="radio"
            value={startsAt}
            checked={isChecked}
          />
        );
      }
      return null;
    };
    

设置初始值的操作就到这里。接下来,我们将组件与onChange行为连接起来。

通过组件层次结构处理字段更改

在本章中,我们逐渐构建了一个组件层次结构:AppointmentForm渲染一个TimeSlotTable组件,该组件渲染了一堆RadioButtonIfAvailable组件,这些组件可能会(也可能不会)渲染单选按钮输入元素。

最后的挑战是如何从输入元素获取onChange事件并将其传递回AppointmentForm,这将控制预约对象。

本节中的代码将使用useCallback钩子。这是一种性能优化的形式:我们无法编写测试来指定这种行为。一个很好的经验法则是,如果你正在将函数作为属性传递,那么你应该考虑使用useCallback

useCallback钩子

useCallback钩子返回的TimeSlotTable会在父组件每次重新渲染时重新渲染,因为不同的引用会导致它认为需要重新渲染。

input元素上的事件处理器不需要使用useCallback,因为事件处理器属性是集中处理的;这些属性的更改不需要重新渲染。

useCallback的第二个参数是useCallback更新的依赖项集合。在这种情况下,它是[],一个空数组,因为它不依赖于任何可能会改变的属性或其他函数。函数的参数,如target不计,而setAppointment是一个保证在重新渲染中保持恒定的函数。

在本章末尾的进一步阅读部分查看有关useCallback的更多信息链接。

由于我们还没有对提交AppointmentForm进行任何工作,我们需要从这里开始。让我们为表单的提交按钮添加一个测试:

  1. 将以下测试添加到你的AppointmentForm测试套件中,该测试用于检查提交按钮的存在。这可以放在测试套件的顶部,就在renders a form测试下面:

    it("renders a submit button", () => {
      render(
        <AppointmentForm original={blankAppointment} />
      );
      expect(submitButton()).not.toBeNull();
    });
    
  2. 你还需要将submitButton辅助函数导入到你的测试中:

    import {
      initializeReactContainer,
      render,
      field,
      form,
      element,
      elements,
      submitButton,
    } from "./reactTestExtensions";
    
  3. 为了使这一步通过,请在你的AppointmentForm底部添加按钮:

    <form>
      ...
      <input type="submit" value="Add" />  
    </form>
    
  4. 对于下一个测试,让我们提交表单并检查我们是否得到了提交的原始startsAt值。我们将使用我们在上一章中看到的相同expect.hasAssertions技术。测试验证onSubmit属性是否以原始的、未更改的startsAt值被调用:

    it("saves existing value when submitted", () => {
      expect.hasAssertions();
      const appointment = {
        startsAt: availableTimeSlots[1].startsAt,
      };
      render(
        <AppointmentForm
          original={appointment}
          availableTimeSlots={availableTimeSlots}
          today={today}
          onSubmit={({ startsAt }) =>
            expect(startsAt).toEqual(
              availableTimeSlots[1].startsAt
            )
          }
        />
      );
      click(submitButton());
    });
    
  5. 由于这个测试使用了click辅助函数,你需要导入它:

    import {
      initializeReactContainer,
      render,
      field,
      form,
      element,
      elements,
      submitButton,
      click,
    } from "./reactTestExtensions";
    
  6. 对于这个测试,我们只需要将表单的onSubmit事件处理器设置好。在这个阶段,它将简单地提交没有任何注册更改的original对象。更新AppointmentForm组件,如下所示:

    export const AppointmentForm = ({
      ...,
      onSubmit,
    }) => {
      const handleSubmit = (event) => {
        event.preventDefault();
        onSubmit(original);
      };
      return (
        <form onSubmit={handleSubmit}>
          ...
        </form>
      );
    };
    
  7. 那个测试通过后,让我们添加最后的测试。这个测试使用的是click动作而不是change,我们之前用于文本框和选择框。我们将点击所需的单选按钮,就像用户一样:

    it("saves new value when submitted", () => {
      expect.hasAssertions();
      const appointment = {
        startsAt: availableTimeSlots[0].startsAt,
      };
      render(
        <AppointmentForm
          original={appointment}
          availableTimeSlots={availableTimeSlots}
          today={today}
          onSubmit={({ startsAt }) =>
            expect(startsAt).toEqual(
              availableTimeSlots[1].startsAt
            )
          }
        />
      );
      click(startsAtField(1));
      click(submitButton());
    });
    
  8. 现在,有趣的部分开始了。从上到下工作:我们首先定义一个新的appointment状态对象,然后使用一个新的事件处理器,当点击单选按钮时修改当前预约。移动到src/AppointmentForm.js并更新你的 React 导入,使其如下所示:

    import React, { useState, useCallback } from "react";
    
  9. 引入一个新的appointment状态对象,并将你的checkedTimeSlot属性更新为使用此对象,而不是使用original属性值:

    export const AppointmentForm = ({
      ...
    }) => {
    const [appointment, setAppointment] = 
        useState(original);
      ...
      return (
        <form>
          ...
          <TimeSlotTable
            ...
            checkedTimeSlot={appointment.startsAt}
         />
          ...
        </form>
      );
    };
    
  10. 更新handleSubmit函数,使其使用appointment而不是original

    const handleSubmit = (event) => {
      event.preventDefault();
      onSubmit(appointment);
    };
    

阻止默认行为的调用

我避免编写 preventDefault 的测试,因为我们之前已经讨论过。在实际应用中,我几乎肯定会再次添加这个测试。

  1. 现在,是时候为新的事件处理程序了。这是利用 useCallback 来安全地将其传递给 TimeSlotTable 及其超集的函数。在之前步骤中添加的 useState 调用下方添加以下定义。处理程序使用 parseInt 在我们的单选按钮的字符串值和我们将存储的数字时间戳值之间进行转换:

    const handleStartsAtChange = useCallback(
      ({ target: { value } }) =>
        setAppointment((appointment) => ({
          ...appointment,
          startsAt: parseInt(value),
        })),
      []
    );
    
  2. 我们需要将事件处理程序编织到 input 元素中,就像我们处理 checkedTimeSlot 一样。首先,将它传递给 TimeSlotTable

    <TimeSlotTable
      salonOpensAt={salonOpensAt}
      salonClosesAt={salonClosesAt}
      today={today}
      availableTimeSlots={availableTimeSlots}
      checkedTimeSlot={appointment.startsAt}
      handleChange={handleStartsAtChange}
    />
    
  3. 然后,更新 TimeSlotTable,将那个属性传递给 RadioButtonIfAvailable

    const TimeSlotTable = ({
      ...,
      handleChange,
    }) => {
       ...,
      <RadioButtonIfAvailable
        availableTimeSlots={availableTimeSlots}
        date={date}
        timeSlot={timeSlot}
        checkedTimeSlot={checkedTimeSlot}
        handleChange={handleChange}
      />
      ...
    };
    
  4. 最后,在 RadioButtonIfAvailable 中,从输入字段中移除 readOnly 属性,并设置 onChange 代替它:

    const RadioButtonIfAvailable = ({
      availableTimeSlots,
      date,
      timeSlot,
      checkedTimeSlot,
      handleChange
    }) => {
      ...
      return (
        <input
          name="startsAt"
          type="radio"
          value={startsAt}
          checked={isChecked}
          onChange={handleChange}
        />
      );
      ...
    };
    

到目前为止,你的测试应该通过,你的时间段表应该完全可用。

本节涵盖了大量的代码:条件渲染 input 元素,以及单选按钮元素的细节,例如为组提供 name 并使用 onChecked 属性,然后通过组件层次结构传递其 onChange 事件。

这是个手动测试你构建内容的好时机。你需要更新 src/index.js,使其加载 AppointmentForm 以及示例数据。这些更改包含在 Chapter05/Complete 目录中:

图 5.2 – 显示的 AppointmentForm

图 5.2 – 显示的 AppointmentForm

你现在已经完成了构建单选按钮表所需的工作。现在是时候进行重构了。

构建组件时的效率提升

让我们看看几种简单的方法来减少我们刚刚构建的测试套件所需的时间和代码量:首先,提取构建函数,其次,提取对象以存储我们组件属性的有意义默认值。

提取时间和日期函数的测试数据构建器

你已经看到我们可以如何将可重用的函数提取到它们自己的命名空间中,例如 renderclickelement DOM 函数。这是一个特殊情况,即 builder 函数,它构建你在测试的 安排行动 阶段将使用的对象。

这些函数的目的不仅仅是去除重复,还包括简化并帮助理解。

我们已经在测试套件中有一个候选者,如下代码所示:

const today = new Date();
today.setHours(9, 0, 0, 0);

我们将更新我们的测试套件,使其使用一个名为 todayAt 的构建函数,这将节省一些输入:

todayAt(9);

我们还将提取 today 值作为常量,因为我们也会使用它。

领域对象的构建器

通常,您会为代码库中的域对象创建构建函数。在我们的例子中,那将是customerappointment对象,甚至是具有单个startsAt字段的时段对象。我们的代码库还没有发展到需要这一点,所以我们将从我们使用的Date对象的构建函数开始。我们将在本书的后面写更多的构建函数。

让我们开始吧:

  1. 创建一个新目录,test/builders。这是我们所有builder函数将存放的地方。

  2. 创建一个新文件,test/builders/time.js。这是我们放置所有与时间相关内容的地方。

  3. 在您的新文件中添加以下常量:

    export const today = new Date();
    
  4. test/AppointmentForm.test.js中,在您的其他导入下面添加以下导入:

    import { today } from "./builders/time";
    
  5. 从测试套件中删除today常量的定义。

  6. test/builders/time.js中,添加以下todayAt的定义。请注意,这确实允许我们指定小时、分钟、秒和毫秒,如果我们选择的话,但它为每个未指定的默认值为0。我们将在一个测试中使用这种完整形式。我们还必须通过调用date构造函数来复制today常量。这确保了我们不会意外地修改任何调用此函数的today常量:

    export const todayAt = (
      hours,
      minutes = 0,
      seconds = 0,
      milliseconds = 0
    ) =>
      new Date(today).setHours(
        hours,
        minutes,
        seconds,
        milliseconds
      );
    

构建函数的不变性

如果您的命名空间使用共享的常量值,就像我们在这里使用today一样,请确保您的函数不会意外地修改它们。

  1. test/AppointmentForm.test.js中,更新您的导入,使其包括新函数:

    import { today, todayAt } from "./builders/time";
    
  2. 是时候进行搜索和替换了!找到以下所有出现:

    today.setHours(9, 0, 0, 0)
    

用以下内容替换它:

todayAt(9)
  1. 找到以下所有出现:

    today.setHours(9, 30, 0, 0)
    

用以下内容替换它:

todayAt(9, 30)
  1. 确保您的测试仍然可以通过。

  2. 将这些行从测试套件移动到test/builders/time.js中:

    const oneDayInMs = 24 * 60 * 60 * 1000;
    const tomorrow = new Date(
      today.getTime() + oneDayInMs
    );
    
  3. 而不是直接使用tomorrow常量,让我们为它编写一个tomorrowAt辅助函数:

    export const tomorrowAt = (
      hours,
      minutes = 0,
      seconds = 0,
      milliseconds = 0
    ) =>
      new Date(tomorrow).setHours(
        hours,
        minutes,
        seconds,
        milliseconds
      );
    
  4. 更新您的导入,使其包括新函数:

    import {
      today,
      todayAt,
      tomorrowAt
    } from "./builders/time";
    
  5. 从测试套件中删除oneDayInMstomorrow的定义。

  6. 找到以下表达式:

    tomorrow.setHours(9, 30, 0, 0)
    

用以下代码替换它:

tomorrowAt(9, 30)
  1. 再次运行测试;它们应该可以通过。

我们将在第七章中再次使用这些辅助工具,测试 useEffect 和模拟组件。然而,在我们完成这一章之前,我们还可以进行一次提取。

提取测试属性对象

测试属性对象是一个设置合理默认值的对象,您可以使用它来减少您的render语句的大小。例如,看看以下渲染调用:

render(
  <AppointmentForm
    original={blankAppointment}
    availableTimeSlots={availableTimeSlots}
    today={today}
  />
);

根据测试的不同,这些属性中的一些(或全部)可能对测试不相关。original属性是必要的,这样我们的渲染函数在渲染现有字段值时不会崩溃。但如果我们测试的是显示页面上的标签,我们就不关心这一点——这也是我们创建blankAppointment常量的一个原因。同样,availableTimeSlotstoday属性可能对测试不相关。

不仅如此,通常,我们的组件最终可能需要大量属性,这些属性对于测试功能是必要的。这可能导致你的测试非常冗长。

属性太多?

你即将看到的技巧是处理许多必需属性的一种方法。但是,拥有很多属性(比如说,超过四五个)可能意味着你的组件设计可以改进。这些属性能否合并成一个复杂类型?或者应该将组件拆分成两个或更多组件?

这是另一个倾听你的测试的例子。如果测试难以编写,退一步看看你的组件设计。

我们可以在describe块顶部定义一个名为testProps的对象:

const testProps = {
  original: { ... },
  availableTimeSlots: [ ... ],
  today: ...
}

这样就可以在render调用中使用它,如下所示:

render(<AppointmentForm {...testProps} />);

如果测试依赖于一个属性,比如如果其期望提到了props值的一部分,那么你不应该依赖于testProps对象中隐藏的值。这些值是合理的默认值。你的测试中的值应该突出显示,就像这个例子一样:

const appointment = {
  ...blankAppointment,
  service: "Blow-dry"
};
render(
  <AppointmentForm {...testProps} original={appointment} />
);
const option = findOption(field("service"), "Blow-dry");
expect(option.selected).toBe(true);

注意,在testProps之后,original属性仍然包含在render调用中。

有时候,你可能会明确地包含一个属性,即使其值与testProps值相同。这是为了在测试中突出其使用。我们将在本节中看到一个例子。

何时使用显式属性

作为一条经验法则,如果属性用于你的测试断言,或者如果属性值对于测试所测试的场景至关重要,那么即使其值与在testProps中定义的值相同,也应该在render调用中明确包含该属性。

让我们更新AppointmentForm测试套件,使其使用一个testProps对象:

  1. 在你的测试套件中找到servicesavailableTimeSlotsblankAppointment的定义。这些定义应该接近顶部。

  2. 在其他定义之后添加以下testProps定义:

    const testProps = {
      today,
      selectableServices: services,
      availableTimeSlots,
      original: blankAppointment,
    };
    
  3. 套件中的第一个测试看起来是这样的:

    it("renders a form", () => {
      render(
        <AppointmentForm
          original={blankAppointment}
          availableTimeSlots={availableTimeSlots}
        />
      );
      expect(form()).not.toBeNull();
    });
    

这可以更新为如下所示:

it("renders a form", () => {
  render(<AppointmentForm {...testProps} />);
  expect(form()).not.toBeNull();
});
  1. 接下来的两个测试,渲染提交按钮渲染为选择框,可以使用相同的更改。现在就去做吧。

  2. 接下来,我们有以下测试:

    it("has a blank value as the first value", () => {
      render(
        <AppointmentForm
          original={blankAppointment}
          availableTimeSlots={availableTimeSlots}
        />
      );
      const firstOption = field("service").childNodes[0];
      expect(firstOption.value).toEqual("");
    });
    

由于这个测试依赖于为service字段传递一个空白值,所以让我们保留原始属性:

it("has a blank value as the first value", () => {
  render(
    <AppointmentForm
      {...testProps}
      original={blankAppointment}
    />
  );
  const firstOption = field("service").childNodes[0];
  expect(firstOption.value).toEqual("");
});

我们有效地隐藏了availableTimeSlots属性,这在之前是噪音。

  1. 接下来,我们有一个使用selectableServices的测试:

    it("lists all salon services", () => {
      const services = ["Cut", "Blow-dry"];
      render(
        <AppointmentForm
          original={blankAppointment}
          selectableServices={services}
          availableTimeSlots={availableTimeSlots}
        />
      );
      expect(
        labelsOfAllOptions(field("service"))
      ).toEqual(expect.arrayContaining(services));
    });
    

此测试在其期望中使用services常量,因此这是一个迹象表明我们需要将其作为一个显式的 prop。将其更改为以下内容:

it("lists all salon services", () => {
  const services = ["Cut", "Blow-dry"];
  render(
    <AppointmentForm
      {...testProps}
      selectableServices={services}
    />
  );
  expect(
    labelsOfAllOptions(field("service"))
  ).toEqual(expect.arrayContaining(services));
});
  1. 在下一个测试中,我们只需要去掉availableTimeSlots,因为servicesappointments都在测试本身中定义:

    it("pre-selects the existing value", () => {
      const services = ["Cut", "Blow-dry"];
      const appointment = { service: "Blow-dry" };
      render(
        <AppointmentForm
          {...testProps}
          original={appointment}
          selectableServices={services}
        />
      );
      const option = findOption(
        field("service"),
        "Blow-dry"
      );
      expect(option.selected).toBe(true);
    });
    

此测试套件中剩余的测试位于嵌套的describe块中的时间槽表中。更新这一点留作练习。

你现在已经学习了更多清理测试套件的方法:提取测试数据构建器和提取testProps对象。记住,使用testProps对象并不总是正确的事情;可能更好的做法是重构你的组件,使其接受更少的 props。

摘要

在本章中,你学习了如何使用两种类型的 HTML 表单元素:选择框和单选按钮。

我们构建的组件具有一定的复杂性,主要由于用于显示日历视图的组件层次结构,但也因为我们需要的一些日期和时间函数来帮助显示该视图。

这就是它的复杂程度:编写 React 组件测试不应该比本章中更困难。

仔细审查我们的测试,最大的问题是使用expect.hasAssertions和异常的安排-断言-行动顺序。在第六章 探索测试替身 中,我们将发现如何简化这些测试并将它们恢复到安排-行动-断言顺序。

练习

以下是一些供你尝试的练习:

  1. renders as a select box测试中替换两个期望,添加一个toBeElementWithTag匹配器。它应该像这样使用:

    expect(field("service")).toBeElementWithTag("select");
    
  2. 完成对AppointmentForm选择框剩余测试的补充:

    • 渲染一个标签

    • 分配一个与标签 ID 匹配的 ID

    • 提交时保存现有值

    • 提交时保存新值

这些测试实际上与CustomerForm的测试相同,包括使用change辅助函数。如果你想要挑战,你可以尝试将这些表单测试辅助函数提取到一个自己的模块中,该模块在CustomerFormAppointmentForm之间共享。

  1. 更新时间槽表测试,使其使用testProps对象。

  2. 更新AppointmentsDayView组件,使其在适当的情况下使用todayAt构建器。

  3. 在选择时间槽之前添加选择风格的能力。这应该是一个基于所需服务进行筛选的选择框,因为并非所有造型师都具备提供所有服务的能力。你需要决定一个合适的数据结构来存储这些数据。修改availableTimeSlots,使其列出每个时间可用的造型师,并更新表格以反映所选造型师及其在一周内的可用性。

进一步阅读

useCallback 钩子在您通过组件层次结构传递事件处理程序时非常有用。请查看 React 文档以获取有关确保正确使用方法的提示:reactjs.org/docs/hooks-….