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

React Native测试驱动开发:Jest+Testing Library完整教程 在React Native开发中,“写完代码再测试”的传统模式常导致bug隐藏、重构困难。本文聚焦测试驱动开发(TDD)组件渲染正确性验证用户点击/输入等交互测试异步逻辑(如API调用)测试状态管理(如Redux)集成测试本文从“为什么需要测试”入手,用生活案例解释TDD、Jest、Testing Library的核心概念;通过“待办事项列表”实战项目,演示从写测试到实现功能的完整流程;最后总结常见问题和未来趋势。TDD(测试驱动开发) 阅读详情

原文: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.js
     ● CustomerForm › 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 的值设置为空字符串。你可能认为 undefined 或 null 是更好的候选值。这样,我们可以避免定义这样的对象,只需传递一个空对象 {}。不幸的是,当你尝试将受控组件的初始值设置为 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。通过这样做,你应该准备好手动测试:

https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/ms-rct-tst-dvn-dev-2e/img/Figure_4.01_B18423.jpg

图 4.1 – 完成的 CustomerForm

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

摘要

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

尽管文本框可能是最基础的输入元素,但我们利用这个机会深入研究了测试驱动的 React。我们发现通过 JSDOM 引发submit和change事件的复杂性,例如确保在事件上调用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));
    });
    

选择测试数据

我已经为预期的服务使用了“真实”数据:Cut和Blow-dry。使用非真实名称,如Service A和Service 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.js中AppointmentForm函数定义下方,添加以下内容:

    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 来分离生产数据定义和测试行为,并定义了几个本地化助手方法,labelsOfAllOptions 和 findOption,以帮助缩短我们的测试。

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

构建日历视图

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

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

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

https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/ms-rct-tst-dvn-dev-2e/img/Figure_5.01_B18423.jpg

图 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元素的全部内容。现在,让我们将一些数据放入第一列。

添加标题列

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

  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 中,将salonOpensAt和salonClosesAt属性传递给TimeSlotTable:

    export const AppointmentForm = ({
      original,
      selectableServices,
      service, 
      salonOpensAt,
      salonClosesAt
    }) => (
      <form>
        ...
        <TimeSlotTable
          salonOpensAt={salonOpensAt}
          salonClosesAt={salonClosesAt} />
      </form>
    );
    
  6. 为salonOpensAt和salonsCloseAt填充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. 遍历每个测试并更新每个渲染调用,以指定一个值为availableTimeSlots的availableTimeSlots属性。例如,第一个测试应该有以下的渲染调用:

    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元素添加新的name和value属性:

    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 目录中:

https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/ms-rct-tst-dvn-dev-2e/img/Figure_5.02_B18423.jpg

图 5.2 – 显示的 AppointmentForm

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

构建组件时的效率提升

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

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

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

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

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

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

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

todayAt(9);

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

领域对象的构建器

通常,您会为代码库中的域对象创建构建函数。在我们的例子中,那将是customer或appointment对象,甚至是具有单个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. 从测试套件中删除oneDayInMs和tomorrow的定义。

  6. 找到以下表达式:

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

用以下代码替换它:

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

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

提取测试属性对象

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

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

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

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

属性太多?

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

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

我们可以在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. 在你的测试套件中找到services、availableTimeSlots和blankAppointment的定义。这些定义应该接近顶部。

  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,因为services和appointments都在测试本身中定义:

    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辅助函数。如果你想要挑战,你可以尝试将这些表单测试辅助函数提取到一个自己的模块中,该模块在CustomerForm和AppointmentForm之间共享。

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

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

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

进一步阅读

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

南京邮电大学《数学实验》模块二(函数的迭代)参考答案 【代码】南京邮电大学《数学实验》模块二(函数的迭代)参考答案。 阅读详情

相关推荐

uni-app一键登录实战:从云函数配置到前端调用的完整指南

本文详细介绍了uni-app中一键登录功能的实现方法,从DCloud控制台配置到云函数开发,再到前端调用全流程。通过一键登录,用户无需输入账号密码即可快速使用本机手机号登录,显著提升用户体验和登录转化率。文章还提供了常见问题排查和安全合规要点,帮助开发者高效完成集成。

weixin_29291573的博客 446

对 React 组件进行单元测试(unit testing)

2019独角兽企业重金招聘Python工程师标准>>> ...

weixin_34238633的博客 657

python-调用动态链接库dll/so的方法

使用python ctyeps CDLL来调用linux下的so动态链接库或windows下的dll动态链接库

XIAOXIANG233233的博客 3662

推荐开源项目:Expect-JSX - 优雅的React组件测试工具

推荐开源项目:Expect-JSX - 优雅的React组件测试工具 在软件开发中,尤其是前端领域,测试是保证代码质量的关键步骤。今天我们要介绍的开源项目——,是一款专为React应用设计的断言库,它使得测试React组件变得更加简洁和直观。 项目简介 Expect-JSX是由Algolia团队开发的一个测试库,它扩展了流行的期望库如Jest或Chai,提供了对React组件的原生支持。该项目的目...

gitblog_00065的博客 495

用V E S C 6-M K V I 第六代调速控制器给VESC电调刷固件

文章摘要:本文介绍了一款先进的电机控制器,具备多项创新技术。核心亮点包括三相电流/电压检测、四象限无缝运行、多模式兼容等先进电机控制技术,以及智能感知与安全保护、高效能电源管理、多样化连接与扩展功能。此外,该控制器支持实时数据监测与用户交互,具备开源生态和工业级可靠性,适用于电动载具、机器人及工业领域。用户需注意散热要求和兼容性提示,可通过VESC-Tool软件进行灵活配置。产品可在常竹科技淘宝店购买,并加入技术交流群获取支持。

2403_89422757的博客 1366

高压控制单元与接触器 —— BMS电池的“高压大门”

高压接触器在BMS电池管理系统中扮演着“高压大门”的角色,负责控制电池包与整车高压母线的连接与隔离。接触器的工作原理基于线圈产生的磁力吸合接点,通过主触点接通高压电流路径,辅助触点检测状态,灭弧结构保护接点。预充电路的设计是为了避免直接连接大电容时产生的浪涌电流冲击,通过预充电阻逐步充电,确保系统安全上电。接触器控制策略包括顺序控制、辅助触点检测、温度监控、故障断电和背靠背MOS辅助控制,以确保系统的高效和安全运行。工程师在选择接触器型号、控制线圈、检测状态、软件冗余控制和预充失败保护等方面提供了具体建议。

weixin_43199439的博客 813

使用jest进行单元测试

以前,写完一段代码我也是直接调用或者实例化一下,发现过了就把测试相关部分删了。今年的不幸与坎坷使我有很长一段时间去思考人生,不想将就了,鲁棒健壮的程序,开发和测试应该是分得很开的,于是我选择jest去做单元测试这件事。 为什么要做单元测试 在开始之前,我们先思考这样一个问题,我们为什么要做单元测试? 不扯犊子直接说吧,第一点,用数据、用茫茫多的测试用例去告诉使用者,你的程序是多么鲁棒健壮;第...

岩家兴的博客 262

告别手动查文献!用Claude Haiku 4.5一键帮你检索+插入参考文献,科研效率直接翻倍(附实操演示+AI提示词)

以下是我的论文段落内容与参考文献列表。以下是我论文段落内容【粘贴论文段落】,请根据每个段落的主题自动检索近五年内的高相关学术文献(以真实DOI或可查询来源为准),要求:1)为每个段落匹配2–3条最相关的文献;另一种是你没有现成的文献列表,需要AI能够帮你检索,筛选输出合适的参考文献列表,并将其正确插入至段落中。选择完成后,先让它检索文献,我设计了一段AI提示词,让Haiku 4.5模型围绕研究主题,给我在各大数据库搜索近五年的文献,并以表格的形式输出文献的核心信息,确保DOI号能直接跳转到原文。

qq_64378896的博客 407

React单元测试策略及落地

单元测试是现代软件开发最基本,也普遍落地不力的实践。市面关于React单元测试的文章,普遍停留在“可以如何写”和介绍工具的层面,既未回答“为何必须做单元测试”,也未回答“单元测试的最佳实践”两个关键问题。本文正是要对这两个问题作出回答。 本文所用技术栈为前端React栈,测试框架与断言工具是jest。文章将略过对测试框架本身的语法介绍,着眼于“为何做”与“最佳实践”的部分。阅读第二部分的代码,需...

IDEAL Garden 753

21天教你学会PCIe专栏(14)--安全性考虑

1.1 定义安全性(Security):安全性是指保护系统免受未经授权的访问、攻击和破坏,确保数据的机密性、完整性和可用性。1.2 重要性数据保护:保护敏感数据,防止数据泄露和篡改。系统防护:防止恶意攻击,确保系统的稳定性和可靠性。合规性:满足行业标准和法律法规的要求,避免法律风险。用户信任:提高用户对系统的信任度,增强市场竞争力。通过今天的学习,你应该对PCIe安全性的重要性和常见威胁有了深入的了解,掌握了实施安全措施的方法和工具,以及安全开发的最佳实践。

xiaoheshang_123的博客 850

倍福控制第三方伺服走CSV模式--以汇川伺服为例

在使用TwinCAT软件控制标准CiA402协议的EtherCAT伺服时使用TwinCAT软件NC自带的运动控制指令控制伺服,此时伺服驱动器一般工作在CSP/CSV/CST模式。最常见伺服驱动器工作在CSP模式,轨迹规划运行在TwinCAT软件,位置环速度环电流环运行在伺服驱动器中。该模式下速度波动较大,需要极度平稳的速度需要采用CSV模式。本文讲述的是伺服驱动器工作在CSV模式,此时轨迹规划和位置环均运行在TwinCAT软件,速度环电流环运行在伺服驱动器中。为了简化文字描述,本文采用图文结合的方式介绍伺服

weixin_41883890的博客 4848

react采坑日记之报错Unexpected token, expected

报错图片: 这个错误我第一反应就是哪边的标签写错了,或者多了些expected后面的一串 但是并不是 后来我一行行删代码,最终发现了一个问题

weixin_43925630的博客 4973

运维系列【仅供参考】: Windos10和Windos11 配置intel网卡vlan的方法

Windos10和Windos11 配置intel网卡vlan的方法

weixin_54626591的博客 477

**深入学习React测试驱动开发第二版实战**

深入学习React测试驱动开发第二版实战 1. 项目介绍 本项目是《深入学习React测试驱动开发》第二版的代码仓库,由Packt出版社出版。本书专注于利用React、Redux和GraphQL构建简单且可维护的Web应用程序,并通过实践测试驱动开发(TDD)理念。TDD方法论帮助开发者通过编写自动化测试来定义应用程序行为,从而增强软件的健壮性,确保在后续修改中不会破坏现有功能。此外,良好的测试...

gitblog_00582的博客 1114

深度学习工具包 DeepLearningToolbox

深度学习工具包Deprecation notice.-----This toolbox is outdated and no longer maintained.There are much better tools available for deep learning than this toolbox, e.g. [Theano](http://deeplearning.net/software/theano/), [torch](http://torch.ch/) or [tensorflow](http://www.tensorflow.org/)I would suggest you use one of the tools mentioned above rather than use this toolbox.Best, Rasmus.DeepLearnToolbox================A Matlab toolbox for Deep Learning.Deep Learning is a new subfield of machine learning that focuses on learning deep hierarchical models of data.It is inspired by the human brain's apparent deep (layered, hierarchical) architecture.A good overview of the theory of Deep Learning theory is[Learning Deep Architectures for AI](http://www.iro.umontreal.ca/~bengioy/papers/ftml_book.pdf)For a more informal introduction, see the following videos by Geoffrey Hinton and Andrew Ng.* [The Next Generation of Neural Networks](http://www.youtube.com/watch?v=AyzOUbkUf3M) (Hinton, 2007)* [Recent Developments in Deep Learning](http://www.youtube.com/watch?v=VdIURAu1-aU) (Hinton, 2010)* [Unsupervised Feature Learning and Deep Learning](http://www.youtube.com/watch?v=ZmNOAtZIgIk) (Ng, 2011)If you use this toolbox in your research please cite [Prediction as a candidate for learning deep hierarchical models of data](http://www2.imm.dtu.dk/pubdb/views/publication_details.php?id=6284)```@MASTERSTHESIS\{IMM2012-06284, author = "R. B. Palm", title = "Prediction as a candidate for learning deep hierarchical models of data", year = "2012",}```Contact: rasmusbergpalm at gmail dot comDirectories included in the toolbox-----------------------------------`NN/` - A library for Feedforward Backpropagation Neural Networks`CNN/` - A library for Convolutional Neural Networks`DBN/` - A library for Deep Belief Networks`SAE/` - A library for Stacked Auto-Encoders`CAE/` - A library for Convolutional Auto-Encoders`util/` - Utility functions used by the libraries`data/` - Data used by the examples`tests/` - unit tests to verify toolbox is workingFor references on each library check REFS.mdSetup-----1. Download.2. addpath(genpath('DeepLearnToolbox'));Example: Deep Belief Network---------------------```matlabfunction test_example_DBNload mnist_uint8;train_x = double(train_x) / 255;test_x = double(test_x) / 255;train_y = double(train_y);test_y = double(test_y);%% ex1 train a 100 hidden unit RBM and visualize its weightsrand('state',0)dbn.sizes = [100];opts.numepochs = 1;opts.batchsize = 100;opts.momentum = 0;opts.alpha = 1;dbn = dbnsetup(dbn, train_x, opts);dbn = dbntrain(dbn, train_x, opts);figure; visualize(dbn.rbm{1}.W'); % Visualize the RBM weights%% ex2 train a 100-100 hidden unit DBN and use its weights to initialize a NNrand('state',0)%train dbndbn.sizes = [100 100];opts.numepochs = 1;opts.batchsize = 100;opts.momentum = 0;opts.alpha = 1;dbn = dbnsetup(dbn, train_x, opts);dbn = dbntrain(dbn, train_x, opts);%unfold dbn to nnnn = dbnunfoldtonn(dbn, 10);nn.activation_function = 'sigm';%train nnopts.numepochs = 1;opts.batchsize = 100;nn = nntrain(nn, train_x, train_y, opts);[er, bad] = nntest(nn, test_x, test_y);assert(er < 0.10, 'Too big error');```Example: Stacked Auto-Encoders---------------------```matlabfunction test_example_SAEload mnist_uint8;train_x = double(train_x)/255;test_x = double(test_x)/255;train_y = double(train_y);test_y = double(test_y);%% ex1 train a 100 hidden unit SDAE and use it to initialize a FFNN% Setup and train a stacked denoising autoencoder (SDAE)rand('state',0)sae = saesetup([784 100]);sae.ae{1}.activation_function = 'sigm';sae.ae{1}.learningRate = 1;sae.ae{1}.inputZeroMaskedFraction = 0.5;opts.numepochs = 1;opts.batchsize = 100;sae = saetrain(sae, train_x, opts);visualize(sae.ae{1}.W{1}(:,2:end)')% Use the SDAE to initialize a FFNNnn = nnsetup([784 100 10]);nn.activation_function = 'sigm';nn.learningRate = 1;nn.W{1} = sae.ae{1}.W{1};% Train the FFNNopts.numepochs = 1;opts.batchsize = 100;nn = nntrain(nn, train_x, train_y, opts);[er, bad] = nntest(nn, test_x, test_y);assert(er < 0.16, 'Too big error');```Example: Convolutional Neural Nets---------------------```matlabfunction test_example_CNNload mnist_uint8;train_x = double(reshape(train_x',28,28,60000))/255;test_x = double(reshape(test_x',28,28,10000))/255;train_y = double(train_y');test_y = double(test_y');%% ex1 Train a 6c-2s-12c-2s Convolutional neural network %will run 1 epoch in about 200 second and get around 11% error. %With 100 epochs you'll get around 1.2% errorrand('state',0)cnn.layers = { struct('type', 'i') %input layer struct('type', 'c', 'outputmaps', 6, 'kernelsize', 5) %convolution layer struct('type', 's', 'scale', 2) %sub sampling layer struct('type', 'c', 'outputmaps', 12, 'kernelsize', 5) %convolution layer struct('type', 's', 'scale', 2) %subsampling layer};cnn = cnnsetup(cnn, train_x, train_y);opts.alpha = 1;opts.batchsize = 50;opts.numepochs = 1;cnn = cnntrain(cnn, train_x, train_y, opts);[er, bad] = cnntest(cnn, test_x, test_y);%plot mean squared errorfigure; plot(cnn.rL);assert(er<0.12, 'Too big error');```Example: Neural Networks---------------------```matlabfunction test_example_NNload mnist_uint8;train_x = double(train_x) / 255;test_x = double(test_x) / 255;train_y = double(train_y);test_y = double(test_y);% normalize[train_x, mu, sigma] = zscore(train_x);test_x = normalize(test_x, mu, sigma);%% ex1 vanilla neural netrand('state',0)nn = nnsetup([784 100 10]);opts.numepochs = 1; % Number of full sweeps through dataopts.batchsize = 100; % Take a mean gradient step over this many samples[nn, L] = nntrain(nn, train_x, train_y, opts);[er, bad] = nntest(nn, test_x, test_y);assert(er < 0.08, 'Too big error');%% ex2 neural net with L2 weight decayrand('state',0)nn = nnsetup([784 100 10]);nn.weightPenaltyL2 = 1e-4; % L2 weight decayopts.numepochs = 1; % Number of full sweeps through dataopts.batchsize = 100; % Take a mean gradient step over this many samplesnn = nntrain(nn, train_x, train_y, opts);[er, bad] = nntest(nn, test_x, test_y);assert(er < 0.1, 'Too big error');%% ex3 neural net with dropoutrand('state',0)nn = nnsetup([784 100 10]);nn.dropoutFraction = 0.5; % Dropout fraction opts.numepochs = 1; % Number of full sweeps through dataopts.batchsize = 100; % Take a mean gradient step over this many samplesnn = nntrain(nn, train_x, train_y, opts);[er, bad] = nntest(nn, test_x, test_y);assert(er < 0.1, 'Too big error');%% ex4 neural net with sigmoid activation functionrand('state',0)nn = nnsetup([784 100 10]);nn.activation_function = 'sigm'; % Sigmoid activation functionnn.learningRate = 1; % Sigm require a lower learning rateopts.numepochs = 1; % Number of full sweeps through dataopts.batchsize = 100; % Take a mean gradient step over this many samplesnn = nntrain(nn, train_x, train_y, opts);[er, bad] = nntest(nn, test_x, test_y);assert(er < 0.1, 'Too big error');%% ex5 plotting functionalityrand('state',0)nn = nnsetup([784 20 10]);opts.numepochs = 5; % Number of full sweeps through datann.output = 'softmax'; % use softmax outputopts.batchsize = 1000; % Take a mean gradient step over this many samplesopts.plot = 1; % enable plottingnn = nntrain(nn, train_x, train_y, opts);[er, bad] = nntest(nn, test_x, test_y);assert(er < 0.1, 'Too big error');%% ex6 neural net with sigmoid activation and plotting of validation and training error% split training data into training and validation datavx = train_x(1:10000,:);tx = train_x(10001:end,:);vy = train_y(1:10000,:);ty = train_y(10001:end,:);rand('state',0)nn = nnsetup([784 20 10]); nn.output = 'softmax'; % use softmax outputopts.numepochs = 5; % Number of full sweeps through dataopts.batchsize = 1000; % Take a mean gradient step over this many samplesopts.plot = 1; % enable plottingnn = nntrain(nn, tx, ty, opts, vx, vy); % nntrain takes validation set as last two arguments (optionally)[er, bad] = nntest(nn, test_x, test_y);assert(er < 0.1, 'Too big error');```[![Bitdeli Badge](https://d2weczhvl823v0.cloudfront.net/rasmusbergpalm/deeplearntoolbox/trend.png)](https://bitdeli.com/free "Bitdeli Badge")

老版QQ原生表情gif动态

这是老版的QQ表情,不过全是动态的的,Windows照片查看器是不会动的,可以右击用浏览器打开查看,再说一遍是老版的,只是分享给宁愿用老版也要动态的人。

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

本章向你展示了如何将 TDD 应用于不仅仅是玩具示例之外。虽然你可能永远不会想自己实现表单验证,但你可以看到如何使用你在本书第一部分学到的相同方法来驱动复杂的代码。首先,你学习了如何在适当的时候验证字段值:当字段失去焦点和表单提交时。你还看到了如何将服务器端错误集成到其中,以及如何显示指示器来告知用户数据正在保存过程中。本章还介绍了如何将逻辑从你的 React 组件移动到它们自己的模块中。在下一章中,我们将向我们的系统添加一个新功能:一个时尚的搜索界面。

龙哥盟 1394

Devexpress16.2.6破解工具

Devexpress16.2.6破解工具Devexpress16.2.6破解工具Devexpress16.2.6破解工具Devexpress16.2.6破解工具

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

我们已经准备好在我们的步骤定义中使用这种新行为,引入一个新的调用,等待元素上的类出现(或消失):在中,向World类添加以下两个方法。第一个方法等待在中,搜索这个文件中的单个When(${} });\n`注意类转换我们在每个指令输入后等待动画。这很重要,因为它反映了类将在应用程序中添加和删除的方式。如果我们只有一个函数作为页面上的最后一个指令,那么如果在一系列指令的中间捕获到类的移除,而不是捕获最后一个,我们可能会提前退出步骤定义。现在,打开;

龙哥盟 1091

Probability and Statistics - 4th edition

Probability and Statistics - 4th edition

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

这是一本关于教条的书籍。我的教条。它是一套原则、实践和仪式,我发现它们在构建 React 应用程序时极为有益。我试图在我的日常工作中应用这些想法,并且我非常相信它们,以至于我抓住每一个机会向他人传授它们。这就是我写这本书的原因:向你展示那些帮助我在自己的职业生涯中取得成功的想法。正如任何教条一样,你有权自己做出判断。有些人会不喜欢这本书的每一部分。有些人会喜欢这本书的每一部分。还有更多的人会吸收一些内容而忘记其他内容。这些都很好。我唯一要求的是,你在跟随的同时保持开放的心态,并准备好挑战你自己的教条。

龙哥盟 494

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

Redux是一个可预测的状态容器。对于初学者来说,这些词意义不大。幸运的是,TDD 可以帮助我们理解如何思考和实现 Redux 应用架构。本章中的测试将帮助你了解 Redux 如何集成到任何应用中。Redux 的主要优势是能够在异步浏览器环境中以提供数据一致性的方式在组件之间共享状态。其重大缺点是必须在应用中引入大量管道和复杂性。这里是龙对于许多应用来说,Redux 的复杂性超过了其好处。仅仅因为本章存在于这本书中,并不意味着你应该急忙去使用 Redux。实际上,我希望本书中的代码示例足以作为警告,提醒你将

龙哥盟 476

掌握React测试驱动开发 - 第二版:构建简单且可维护的Web应用

掌握React测试驱动开发 - 第二版:构建简单且可维护的Web应用 项目介绍 欢迎来到《掌握React测试驱动开发 - 第二版》的代码仓库!本书由Packt出版,旨在通过React、Redux和GraphQL等技术,指导您构建简单且可维护的Web应用。本书深入探讨了测试驱动开发(TDD)的工作流程,帮助您通过自动化测试来规范和优化应用行为,确保应用的长期稳定性和可维护性。 项目技术分析 本书涵盖...

gitblog_00208的博客 540

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

在本章中,我们探讨了如何测试浏览器 API。这不是一个简单的过程,如果你希望完全覆盖,需要编写多个测试。尽管如此,你已经看到为屏幕上的动画编写自动化测试是完全可能的。这样做的好处是,复杂的生产代码通过测试得到了完全的文档记录。在下一章中,我们将探讨如何将 WebSocket 通信添加到 Spec Logo 中。在本章中,我们介绍了如何针对 WebSocket API 进行测试。你已经看到了如何模拟 WebSocket 构造函数,以及如何测试其onopenonclose和onmessage回调。

龙哥盟 519

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

单元测试中的单元指的是在测试期间我们关注的单个函数或组件。测试的行为阶段应该只涉及一个单元的一个动作。但单元不会孤立行动:函数调用其他函数,组件渲染子组件并调用从父组件传入的回调属性。可以将您的应用程序视为一个依赖关系的网络,测试替身帮助我们设计和测试这些依赖关系。当我们编写测试时,我们会隔离被测试的单元。通常这意味着我们避免对任何协作对象进行操作。为什么?首先,这有助于我们朝着独立、专注的测试目标前进。其次,有时那些协作对象会有副作用,这会复杂化我们的测试。

龙哥盟 391

React 测试驱动开发教程(二)

现在让我们考虑另一个变化:如果的长度大于 300 个字符,我们需要在 300 个字符处截断内容,并显示一个链接。当用户单击该链接时,将显示完整的内容。book: {})这促使我们以满足需求的方式编写或修改代码。一旦所有的测试都通过了,我们就可以进行重构:提取方法,创建新文件,移动方法或类,重命名变量或改变文件夹结构,等等。这是一种无休止的过程。我们总有进步的空间。当我们有足够的时间时,我们可以重复这个过程,直到代码变得干净并且自文档化。

龙哥盟 1066

React测试驱动开发reactjs-interview-questions:TDD实践指南

在当今快节奏的前端开发环境中,React开发者经常面临这样的困境:代码重构时担心破坏现有功能、新功能开发时缺乏明确的质量标准、团队协作时难以理解他人代码逻辑。测试驱动开发(Test-Driven Development,TDD)正是解决这些痛点的最佳实践。 通过本文,你将掌握: - TDD在React项目中的核心工作流程 - Jest和React Testing Library的实战应用技巧 -...

gitblog_00567的博客 308

ReactJS 101完整指南:从零开始掌握现代React开发的10个关键步骤

ReactJS 101是一本专门为初学者设计的React中文入门教学书籍,旨在帮助开发者从零开始掌握现代React开发的核心概念和最佳实践。这本指南由浅入深地介绍了React生态系统的各个方面,包括Flux、Redux、React Router、ImmutableJS、React Native、Relay/GraphQL等技术,让你能够打造跨平台的高质量应用程式。无论你是前端开发新手还是想系统学习

gitblog_01024的博客 768

React 测试驱动开发教程(一)

我们浏览了测试金字塔和敏捷测试象限,并介绍了验收测试驱动的开发,作为我们通过这本书编写代码的方式。在做 ATDD 时,我们将继续做经典的红绿重构循环。重构依赖于识别代码气味的感觉和经验。一旦你发现了代码的味道,你就可以应用相应的重构技术。然后我们可能会实现可维护的、人类可读的、可扩展的、干净的代码。TDD 总是伴随着另一个强大的实践——任务。你可以使用投资原则来帮助你把一个大任务分成几个小部分。适当拆分后,可以逐步完善基础版本,迭代完成大任务。

龙哥盟 1233

Mastering React Test-Driven Development:掌握React测试驱动开发的利器

Mastering React Test-Driven Development:掌握React测试驱动开发的利器 项目介绍 在现代前端开发中,React框架因其组件化、声明式编程的特点受到了广泛欢迎。然而,随着项目规模的扩大,如何确保代码质量、提升开发效率成为了开发者关注的焦点。《Mastering React Test-Driven Development》正是针对这一问题,提供了一套系统的Re...

gitblog_00557的博客 992
上一篇: 精通 React 测试驱动开发第二版(一)
下一篇: 精通 React 测试驱动开发第二版(三)
绝不原创的飞龙
绝不原创的飞龙 优质创作者: 人工智能、数据科学与机器学习技术领域 优质创作者: 人工智能、数据科学与机器学习技术领域
博客等级 码龄15年 4万+粉丝 2万+原创
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

个

红包个数最小为10个

元

红包金额最低5元

当前余额3.43元 前往充值 >
需支付:10.00元
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付元
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值