8 测试 React 应用程序

本章涵盖

如何测试相互交互的组件
快照测试
测试组件的样式
故事和组件级验收测试
在找到专业厨房的方法并学会了使用糕点袋的一两个技巧后,在某个时候,糕点师必须打碎几个鸡蛋,准备一些面团,并进行一些真正的烘焙。

在本章中,我们将采用类似的方法来测试 React 应用程序。现在您已经了解了 React 测试生态系统的方法并了解了其工具的作用,我们将深入探讨如何为您的 React 应用程序编写有效、健壮和可维护的测试。

要学习如何编写这些类型的测试,您将扩展您在前一章中构建的应用程序,并学习如何使用高级技术来测试它。

首先,您将在多个隔离级别学习如何验证相互交互的组件。在整个过程中,我将解释如何以易于理解和可维护的方式进行操作。为此,您将学习如何存根组件,这些存根如何影响您的测试,以及如何在测试 React 应用程序时应用测试金字塔概念。

在本章的第二部分,我将解释什么是快照测试,如何进行,最重要的是,何时进行。在本节中,您将了解在决定是否应该使用快照测试来测试应用程序的特定部分时要考虑的因素。在解释快照测试的优缺点时,我将坚持本书的价值驱动方法,以便您有权做出自己的决定。

然后,考虑到 CSS 在您的软件开发中扮演的不可或缺的角色,以及客户端应用程序不仅表现得足够好而且看起来还不错的重要性,您将了解如何测试应用程序的样式规则。您将了解组件样式的哪些方面值得测试,以及您可以实现和不能实现的目标。

在本章的最后一部分,我将解释什么是组件故事以及如何编写它们。当您使用 Storybook 编写故事时,我将阐明它们如何改进您的开发过程并帮助您生成可靠且文档齐全的组件。

您将了解故事对反馈循环的速度和简化 UI 开发、改善不同团队之间的沟通以及使您和您的同事能够在组件级别执行验收测试的影响。
8.1 测试组件集成
在处理昂贵的设备(如工业烤箱)时,至关重要的是检查每一件物品是否都在手册上说它应该在的地方。但仅仅这样做是不够的。面包师越早转动烤箱的旋钮,按下其按钮并拨动其开关,他们就越早激活保修并在出现任何问题时订购更换。尽管如此,路易斯从不认为烤箱是完美无缺的,除非尝过用它烤过的一批酸面包。

同样,在测试组件时,您可以检查所有元素是否都在正确的位置。然而,如果不填写几个字段并按下几个按钮,您就无法判断组件是否能够充分响应用户的输入。此外,如果没有在集成中测试您的组件,就很难创建可靠的保证。

在本节中,您将学习如何测试相互交互的组件。首先,每当操作员将产品添加到库存时,您将让应用程序更新项目列表。然后,您将在多个不同的集成级别为该功能编写不同类型的测试。我将解释每种方法的优缺点。

注意本章建立在您在上一章中编写的应用程序以及我们当时使用的服务器的基础上。

您可以在本书的 GitHub 存储库中找到用于本章示例的客户端和服务器的代码,网址为 https://github.com/lucasfcosta/testing-javascript-applications。

在第 8 章的文件夹中,您将找到一个名为 server 的目录,其中包含一个 HTTP 服务器,该服务器能够处理您的 React 应用程序将发出的请求。

正如您之前所做的那样,要运行该服务器,请导航到其文件夹,使用 npm install 安装其依赖项,并使用 npm run migrate:dev 确保您的数据库架构是最新的。安装依赖项并准备好数据库后,使用 npm start 启动服务器。默认情况下,您的 HTTP 服务器将绑定到端口 3000。

为了让 App 能够更新其状态,从而更新其子项,它将为 ItemForm 创建一个回调函数,以便在用户添加项目时调用。此回调函数应采用项目名称、添加数量并更新 App 内的状态。

在开始更改 App 之前,更新 ItemForm,如下面的清单所示,以便它采用 onItemAdded 函数作为道具。如果定义了这个属性,则 ItemForm 应该在提交表单时调用它,传递项目的名称和添加的数量作为参数。

import React from "react";
 
// ...
 
export const ItemForm = ({ onItemAdded }) => {
  const [itemName, setItemName] = React.useState("");
  const [quantity, setQuantity] = React.useState(0);
 
  const onSubmit = async e => {
    e.preventDefault();
    await addItemRequest(itemName, quantity);
    if (onItemAdded) onItemAdded(itemName, quantity);       ❶
  };
 
  return (
    
{ /* ... */ }
); };

❶ 提交表单时调用传入的 onItemAdded 回调

现在,为了验证 ItemForm 组件是否在存在时调用传递的 onItemAdded 函数,您将创建一个单元测试,如下所示。 您的测试应该呈现 ItemForm,通过 onItemAdded 属性将存根传递给它,提交表单,等待请求解析,并检查此组件是否调用了传递的存根。

// ...
 
test("invoking the onItemAdded callback", async () => {
  const onItemAdded = jest.fn();
  const { getByText, getByPlaceholderText } = render(                      ❶
    
  );
 
  nock(API_ADDR)                                                           ❷
    .post("/inventory/cheesecake", JSON.stringify({ quantity: 2 }))
    .reply(200);
 
  fireEvent.change(getByPlaceholderText("Item name"), {                    ❸
    target: { value: "cheesecake" }
  });
  fireEvent.change(getByPlaceholderText("Quantity"), {                     ❹
    target: { value: "2" }
  });
  fireEvent.click(getByText("Add item"));                                  ❺
 
  await waitFor(() => expect(nock.isDone()).toBe(true));                   ❻
 
  expect(onItemAdded).toHaveBeenCalledTimes(1);                            ❼
  expect(onItemAdded).toHaveBeenCalledWith("cheesecake", 2);               ❽
});

❶ 渲染一个 ItemForm 组件,其 onItem-Added 属性是一个使用 Jest 创建的虚拟存根

❷ 创建一个拦截器来响应表单提交时发送的 POST 请求

❸ 更新了“cheesecake”,项目名称的表单字段

❹ 更新为“2”,即商品数量的表单字段

❺ 点击提交表单的按钮

❻ 等待拦截器到达

❼ 期望 onItemAdded 回调被调用一次

❽ 期望 onItemAdded 回调以“cheesecake”作为第一个参数和“2”作为第二个参数被调用

该测试的覆盖范围和交互如图8.1所示,可以验证ItemForm是否会调用App传递的函数,但不能检查App是否向ItemForm传递函数,也不能检查传递的函数是否正确。


图8-1

为了保证 App 在用户添加新项目时充分更新其状态,您需要一个单独的测试来验证 App 及其与 ItemForm 的集成。

继续更新您的 App 组件,当用户通过表单提交新项目时,项目列表将更新。

要实现该功能,您将编写一个能够获取项目名称和数量并更新 App 内状态的函数。 然后,您将通过 onItemAddedprop 将此函数传递给 ItemForm。

// ...
 
export const App = () => {
  const [items, setItems] = useState({});                                  ❶
 
  // ...
 
  const updateItems = (itemAdded, addedQuantity) => {                      ❷
    const currentQuantity = items[itemAdded] || 0;
    setItems({ ...items, [itemAdded]: currentQuantity + addedQuantity });
  };
 
  return (
    

Inventory Contents

); };

❶ 创建一个代表库存项目列表的状态

❷ 创建一个函数,将添加的项目名称和添加到表示库存项目列表的状态片中的数量合并

❸ 渲染一个项目列表,其 itemList 属性是 App 中的项目状态

每当用户提交新项目时,ItemForm 组件都会调用 updateItems。此函数将接收添加的项目名称和数量,并将使用该信息更新 App 内的状态,该状态将传递给 ItemList。因为提交表单会更新 ItemList 使用的那块状态,它会导致项目列表更新,反映添加的项目。

在为此行为编写测试之前,请快速尝试一下。使用 npm run build 构建您的应用程序,使用 npx http-server ./ 为其提供服务,并访问 localhost:8080。当您将项目添加到库存时,您会看到项目列表自动更新。

因为您还没有添加测试来检查 App 与其子项之间的集成,所以即使您的应用程序不起作用,您的测试也可能通过。

您当前的测试检查列表是否正确显示库存以及项目表单是否调用传递的 onItemAdded 函数。尽管如此,他们并未验证 App 是否与这些组件充分集成。目前,如果您忘记为 ItemForm 提供 updateItems 函数,或者该函数不正确,则您的测试将通过。

单独测试组件是在开发过程中获得快速反馈的绝佳方式,但在创建可靠保证方面则没有那么有价值。

为了验证当用户添加新项目时 App 是否能充分更新自身,您将编写一个测试来呈现 App、提交表单并期望 ItemList 更新。

在这个测试中,必须考虑到 ItemForm 在添加项目时会向 POST 发送请求,并且只有在该请求解决后才会调用传递的 onItemAdded 函数。因此,为了能够编写通过的测试,您必须使请求成功并且测试在运行断言之前等待请求已解决。

为了使测试成功,您将为将项目添加到库存的路由创建一个拦截器。然后,您将通过将断言包装到 waitFor 中,让测试等待请求解决。

import { render, fireEvent, waitFor } from "@testing-library/react";
 
// ...
 
test("updating the list of items with new items", async () => {
  nock(API_ADDR)                                                           ❶
    .post("/inventory/cheesecake", JSON.stringify({ quantity: 6 }))
    .reply(200);
 
  const { getByText, getByPlaceholderText } = render();             ❷
 
  await waitFor(() => {                                                    ❸
    const listElement = document.querySelector("ul");
    expect(listElement.childElementCount).toBe(3);
  });
 
  fireEvent.change(getByPlaceholderText("Item name"), {                    ❹
    target: { value: "cheesecake" }
  });
  fireEvent.change(getByPlaceholderText("Quantity"), {                     ❺
    target: { value: "6" }
  });
  fireEvent.click(getByText("Add item"))
 
  await waitFor(() => {                                                    ❻
    expect(getByText("cheesecake - Quantity: 8")).toBeInTheDocument();
  });
 
  const listElement = document.querySelector("ul");
  expect(listElement.childElementCount).toBe(3);                           ❼
 
  expect(getByText("croissant - Quantity: 5")).toBeInTheDocument();        ❽
  expect(getByText("macaroon - Quantity: 96")).toBeInTheDocument();        ❾

❶ 创建一个拦截器,当添加 6 个芝士蛋糕时,它会响应表单将发送的请求

❷ 渲染一个App实例

❸ 等待物品列表有三个孩子

❹ 使用“cheesecake”更新项目名称的表单字段

❺ 将商品数量的表单字段更新为“6”

❻ 等待一个元素表明库存包含 8 个芝士蛋糕

❼ 断言项目列表有三个孩子

❽ 等待一个元素表明库存包含 5 个羊角面包

❾ 等待一个元素表明库存包含 96 个杏仁饼

您刚刚编写的测试为您的组件集成工作提供了可靠的保证,因为它涵盖了 App 组件及其所有子组件。它通过使用 ItemForm 将产品添加到库存中来覆盖 ItemForm,并通过检查项目列表是否包含具有预期文本的元素来覆盖 ItemList。

这个测试也涵盖了 App,如图 8.2 所示,因为只有当 App 为 ItemForm 提供一个回调来充分更新传递给 ItemList 的状态时,它才会通过。


图8-2

进行此测试的唯一缺点是,如果您更改任何底层组件,您将有一个额外的测试需要修复。 例如,如果您更改了 ItemList 呈现的文本格式,则必须同时更新 ItemList 的测试和刚刚为 App 组件编写的测试,如图 8.3 所示。

图8.3

进行更改时必须更新的测试越多,更改的成本就越高,因为您将花费更多时间让测试套件再次通过。

另一方面,通过在 App 中验证列表的项目,您可以可靠地保证您的应用程序在收到服务器响应后将呈现正确的元素。

关于决定在哪个集成级别测试组件,我个人的意见与 react-testing-library 文档中的建议一致,这表明您应该根据需要在组件树中尽可能高地编写测试获得可靠的保证。您的测试目标在组件树中越高,您的保证就越可靠,因为它们更接近于您的应用程序的运行时场景。

尽管渲染多个组件会产生更可靠的质量保证,但在现实世界中,这并不总是可行的,并且可能会在您的测试之间产生显着的重叠。

使您的测试更易于维护同时保持其可靠性的另一种方法是将每个列表元素的文本生成集中到具有单独测试的单独函数中。然后,您可以在 app App 测试和 ItemList 测试中使用该函数。通过这样做,当您更改文本格式时,您只需更新您的文本生成功能及其自己的测试。

通过在 ItemList 中创建一个新函数来尝试集中此依赖项,您将其导出为 generateItemText。此函数采用项目的名称和数量,并返回每个元素应显示的足够文本。

// ...
 
export const generateItemText = (itemName, quantity) => {
  return `${itemName} - Quantity: ${quantity}`;
};

一旦你实现了这个函数,就为它编写一个测试。 为了更好地在 ItemList.test.jsx 中组织您的测试,我建议您将文本生成功能的测试和 ItemList 本身的测试分成两个单独的描述块。

// ...
 
import { ItemList, generateItemText } from "./ItemList.jsx";
 
describe("generateItemText", () => {
  test("generating an item's text", () => {          ❶
    expect(generateItemText("cheesecake", 3))
      .toBe("cheeseceake - Quantity: 3");
    expect(generateItemText("apple pie", 22))
      .toBe("apple pie - Quantity: 22");
  });
});
 
describe("ItemList Component", () => {
  // ...
});

❶ 将项目名称和数量传递给 generateItemText 函数并检查它是否产生正确的结果

现在您已经测试了 generateItemText,更新 ItemList 组件本身,以便它使用这个新函数为列表的每个项目创建文本。

// ...
 
export const ItemList = ({ itemList }) => {
  return (
    
    {Object.entries(itemList).map(([itemName, quantity]) => { return (
  • { generateItemText(itemName, quantity) } ❶
  • ); })}
); }; // ...

❶ 使用 generateItemText 为 ItemList 中的每个项目生成文本

因为您已经通过测试 generateItemText 函数创建了可靠的保证,所以您可以在整个测试套件中自信地使用它。 如果 generateItemText 函数失败,即使使用它的测试会通过,generateItemText 本身的测试也会失败。 像这样的测试是如何利用传递保证的一个很好的例子。

继续更新 ItemList 和 App 的测试,以便它们使用这个新函数。

// ...
 
describe("ItemList Component", () => {
  test("list items", () => {
    const itemList = { cheesecake: 2, croissant: 5, macaroon: 96 };
    const { getByText } = render();
 
    const listElement = document.querySelector("ul");
    expect(listElement.childElementCount).toBe(3);
    expect(getByText(generateItemText("cheesecake", 2))).toBeInTheDocument();❶
    expect(getByText(generateItemText("croissant", 5))).toBeInTheDocument(); ❷
    expect(getByText(generateItemText("macaroon", 96))).toBeInTheDocument(); ❸
  });
});

❶ 使用 generateItemText 函数创建字符串,测试将通过该字符串找到指示库存有 2 个芝士蛋糕的元素

❷ 使用 generateItemText 函数创建字符串,测试将通过该字符串找到指示库存有 5 个羊角面包的元素

❸ 使用 generateItemText 函数创建字符串,测试将通过该字符串找到指示库存有 96 个杏仁饼的元素

import { generateItemText } from "./ItemList.jsx";
 
// ...
 
test("rendering the server's list of items", async () => {
  // ...
 
  expect(getByText(generateItemText("cheesecake", 2))).toBeInTheDocument();
  expect(getByText(generateItemText("croissant", 5))).toBeInTheDocument();
  expect(getByText(generateItemText("macaroon", 96))).toBeInTheDocument();
});
 
test("updating the list of items with new items", async () => {
  // ...
 
  await waitFor(() => {
    expect(getByText(generateItemText("cheesecake", 8))).toBeInTheDocument();
  });
 
  const listElement = document.querySelector("ul");
  expect(listElement.childElementCount).toBe(3);
 
  expect(getByText(generateItemText("croissant", 5))).toBeInTheDocument();
  expect(getByText(generateItemText("macaroon", 96))).toBeInTheDocument();
});

如果您在这些更改之后运行测试,您会看到它们仍然通过。 唯一的区别是它们现在更加经济。

通过为 generateItemText 创建一个单独的测试并在其他测试中使用这个函数,你已经创建了一个传递保证。 组件的两个测试相信 generateItemText 可以正常工作,但还有一个专门针对 generateItemText 的额外测试,以确保此特定功能正常工作(图 8.4)。


图8-4

要查看测试生成的维护开销有多少,请尝试更改 ItemList 中的文本格式,以便每个项目名称的第一个字母始终大写,然后重新运行测试。

// ...
 
export const generateItemText = (itemName, quantity) => {
  const capitalizedItemName =                                ❶
    itemName.charAt(0).toUpperCase() +
    itemName.slice(1);
  return `${capitalizedItemName} - Quantity: ${quantity}`;
};
 
// ...

❶ 将项目名称的第一个字符大写

重新运行测试后,您应该看到只有 generateItemText 本身的测试会失败,但所有其他测试都已通过。 要使所有测试再次通过,您只需更新一个测试:generateItemText 的测试。

describe("generateItemText", () => {
  test("generating an item's text", () => {          ❶
    expect(generateItemText("cheesecake", 3))
      .toBe("Cheesecake - Quantity: 3");
    expect(generateItemText("apple pie", 22))
      .toBe("Apple pie - Quantity: 22");
  });
});

❶ 调用generateItemText 加上几个项目名称和数量,并检查结果是否正确,包括项目的第一个字符是否已经大写

当您有太多依赖于单点故障的测试时,请将该故障点集中到您将在整个测试中使用的单个部分,就像您对 generateItemText 所做的那样。模块化可以使您的应用程序代码和测试更加健壮。

8.1.1 存根组件
你并不总是能够通过将它们渲染到 DOM 来测试多个组件。有时,您必须将组件包装到其他具有不良副作用的组件或第三方库提供的组件中,正如我之前提到的,您不应该测试自己。

在本节中,您将 react-spring 集成到您的应用程序中,以便您可以添加动画来突出显示进入或离开库存的新类型的项目。然后,您将学习如何使用存根来测试您的组件,而无需测试 react-spring 本身。

首先,使用 npm install react-spring 安装 react-spring,以便您可以在 ItemList.jsx 中使用它。

注意 由于 react-spring 使用的导出类型,您必须在捆绑应用程序时使用名为 esmify 的 Browserify 插件。

要使用 esmify,请使用 npm install --save-dev esmify 安装它,然后更新 package.json 中的构建脚本,使其使用 esmify 作为插件。

//...
{
  "name": "my-application-name",
  "scripts": {
    "build": "browserify index.jsx -p esmify -o bundle.js",      ❶
   // ...
  }
  // ...
}

❶ 配置 Browserify 在生成包时使用 esmify

安装 react-spring 后,使用 react-spring 中的 Transition 组件为进入或离开列表的每个项目设置动画。

// ...
 
import { Transition } from "react-spring/renderprops";
 
// ...
 
export const ItemList = ({ itemList }) => {
  const items = Object.entries(itemList);
 
  return (
    
    itemName} from={{ fontSize: 0, opacity: 0 }} enter={{ fontSize: 18, opacity: 1 }} leave={{ fontSize: 0, opacity: 0 }} > {([itemName, quantity]) => styleProps => ( ❷
  • ❸ {generateItemText(itemName, quantity)}
  • )}
); };

❶ 一个 Transition 组件,可以为进入或离开项目列表的每个项目设置动画

❷ 一个函数,该函数将被列表中的每一项调用,并返回另一个函数,该函数接受一个表示与动画当前状态对应的样式的属性

❸ 一个 li 元素,其样式将对应动画的当前状态,使每个项目都被动画化

注意您可以在 https://www.react-spring.io/docs 找到关于 react-spring 的完整文档。

要试用您的应用程序并查看它对项目列表元素的动画效果,请使用 npm run build 运行您的应用程序,并在使用 npx http-server ./ 为其提供服务后,访问 localhost:8080。在测试您的应用程序时,您的后端必须可用并在端口 3000 上运行。

现在,在测试 ItemList 时,您应该小心不要测试 Transition 组件本身。测试 react-spring 库是其维护者的责任,它会给您的测试增加额外的复杂性。如果您认为增加的复杂性不会显着影响您的测试,您可以随时选择不使用任何测试替身。尽管如此,鉴于您最终必须这样做,通过本节中的示例学习如何这样做对您很有用。

要充分存根 Transition,您需要做的第一件事是观察它的工作方式,以便您可以准确地重现其行为。在这种情况下,Transition 组件将通过 items 属性传递给它的每个项目调用其子组件,然后使用表示过渡状态的样式调用结果函数。最后一个函数调用将返回一个包含项目文本的 li 元素,其中包括生成的样式。

要在整个测试中一致地模拟 Transition 组件,首先在项目的根目录中创建一个 mocks 文件夹,然后在该文件夹中创建另一个名为 react-spring 的文件夹。在该文件夹中,您将创建一个名为 renderprops.jsx 的文件,该文件将包含您对 react-spring 库的 renderprops 命名空间的模拟。

在 react-spring.jsx 文件中,创建一个 FakeReactSpringTransition 组件并将其导出为 Transition。此组件应将项目和子项作为属性。它将映射调用通过子项传递的函数的项。这些调用中的每一个都将返回一个接受样式并返回一个组件实例的函数。然后将使用表示一组假样式的对象调用该函数,从而导致子组件呈现。

const FakeReactSpringTransition = jest.fn(                 ❶
  ({ items, children }) => {
    return items.map(item => {
      return children(item)({ fakeStyles: "fake " });
    });
  }
);
 
export { FakeReactSpringTransition as Transition };

❶ 一个假的 Transition 组件,它用一个列表项调用它的每个子组件,然后用一个表示一组假样式的对象调用返回的函数。 这个最后的调用导致应该被动画化的孩子们呈现。

用这个测试替身替换 react-spring 中的 Transition 组件将导致它仅仅渲染每个子组件,就好像没有 Transition 组件包裹它们一样。

要在您的测试中使用此存根,请在您想在其中使用 FakeReactSpringTransition 的每个测试文件的顶部调用 jest.mock("react-spring/renderprops")。

目前,您正在使用 ItemList,它依赖于 App.test.jsx 和 ItemList.test.jsx 中的 Transition,因此继续将模拟 react-spring/renderprops 的调用添加到每个文件的顶部。

import React from "react";
 
// ...
 
jest.mock("react-spring/renderprops");       ❶
 
beforeEach(() => { /* ... */ });
 
// ...

❶ 导致 react-spring/renderprops 解析为您在 mocks/react-spring 中创建的存根

import React from "react";
 
// ...
 
jest.mock("react-spring/renderprops");               ❶
 
describe("generateItemText", () => { /* ... */ });
 
// ...

❶ 导致 react-spring/renderprops 解析为您在 mocks/react-spring 中创建的存根

通过在 mocks 文件夹中创建一个测试替身并调用 jest.mock,您将导致 ItemList 中 react-spring/renderprops 的导入解析为您的模拟,如图 8.5 所示。


图8.5

使用此测试替身后,您的组件的行为将与它们在引入 react-spring 之前的行为类似,因此您的所有测试仍应通过。

由于这个测试替身,您能够避免测试 react-spring 所涉及的所有可能的复杂性。与其自己测试 react-spring,不如依赖于它的维护者已经这样做的事实,因此,你避免测试第三方代码。

如果你想检查传递给 Transition 的属性,你也可以检查你的测试替身的调用,因为你已经使用 jest.fn 来包装你的假组件。

在决定是否应该模拟组件时,请考虑是否要测试第三方代码、它会给测试增加多少复杂性,以及要验证组件之间集成的哪些方面。

在测试我的 React 应用程序时,我尽量避免用测试替身替换组件。如果我可以控制组件,我会选择扩展 react-testing-library 或创建测试实用程序来帮助我使我的测试更易于维护。我只存根那些会触发我无法控制的不良副作用的组件,例如动画。当测试变得太长或在没有存根的情况下变得复杂时,使用测试替身也是一个好主意。

注意 Enzyme 是我在上一章中提到的 react-testing-library 的替代品,它使测试组件变得更容易一些,而不必存根它们的孩子。使用 Enzyme 时,您可以使用它的浅层方法来避免渲染子组件,而不是手动创建测试替身。

浅层渲染组件的缺点是它们会降低运行时环境和测试之间的相似性,就像存根一样。因此,浅渲染会导致测试的保证较弱。

正如我在整本书中多次说过的那样,说“总是这样做”或“永远不要那样做”是危险的。相反,在决定采用哪种方法之前,了解每种方法的优缺点更为有益。

8.2 快照测试

直到今天,路易斯更喜欢通过看别人烘焙而不是阅读食谱来学习食谱。当您可以将面糊的稠度和巧克力糖霜的外观与其他人的进行比较时,烤出美味的蛋糕就容易多了。

在测试组件时,您可以遵循类似的方法。每当组件的标记符合您的预期时,您就可以对其进行快照。然后,当你迭代你的组件时,你可以将它与你拍摄的快照进行比较,以检查是否仍然呈现正确的内容。

在本节中,您将实现一个组件,该组件记录会话中发生的所有事情,并使用 Jest 的快照测试该组件。在整个过程中,我将解释使用快照的优缺点以及如何决定何时使用它。然后,我将解释超越测试 React 组件的快照用例。

在开始编写任何涉及快照的测试之前,请创建一个名为 ActionLog 的新组件。该组件将采用一组对象并呈现一系列操作。数组中的每个对象都将包含一个消息属性,通知发生了什么,以及一个时间属性,通知它何时发生。此外,这些对象中的每一个都可以有一个数据属性,其中包含任何其他有用的任意信息,例如您的应用程序在第一次加载项目时从服务器获得的响应。

import React from "react";
 
export const ActionLog = ({ actions }) => {                   ❶
  return (
    

Action Log

    {actions.map(({ time, message, data }, i) => { ❷ const date = new Date(time).toUTCString(); return (
  • {date} - {message} - {JSON.stringify(data)}
  • ); })}
); };

❶ 一个 ActionLog 组件,它接受一个表示应用程序中发生的事情的动作道具

❷ 遍历操作中的每个项目,并生成一个 li 元素,通知用户发生了什么以及何时发生

现在,创建一个名为 ActionLog.test.jsx 的文件,您将在其中为 ActionLog 组件编写测试。

在此文件中,您将使用 Jest 的 toMatchSnapshot 匹配器编写第一个快照测试。

此测试将呈现 ActionLog 并检查呈现的组件是否与特定快照匹配。 要获取渲染的组件并将其与快照进行比较,您将使用渲染结果中的容器属性和 toMatchSnapshot 匹配器。

import React from "react";
import { ActionLog } from "./ActionLog";
import { render } from "@testing-library/react";
 
const daysToMs = days => days * 24 * 60 * 60 * 1000;                       ❶
 
test("logging actions", () => {
  const actions = [                                                        ❷
    {
      time: new Date(daysToMs(1)),
      message: "Loaded item list",
      data: { cheesecake: 2, macaroon: 5 }
    },
    {
      time: new Date(daysToMs(2)),
      message: "Item added",
      data: { cheesecake: 2 }
    },
    {
      time: new Date(daysToMs(3)),
      message: "Item removed",
      data: { cheesecake: 1 }
    },
    {
      time: new Date(daysToMs(4)),
      message: "Something weird happened",
      data: { error: "The cheesecake is a lie" }
    }
  ];
 
  const { container } = render();           ❸
  expect(container).toMatchSnapshot();                                     ❹
});

❶ 将天数转换为毫秒

❷ 创建一个静态的动作列表

❸ 使用在测试中创建的静态动作列表呈现 ActionLog 的实例

❹ 期望渲染元素与快照匹配

当你第一次执行这个测试时,你会看到,除了测试通过之外,Jest 还告诉你它已经写了一个快照。

PASS  ./ActionLog.test.jsx
 › 1 snapshot written.
 
Snapshot Summary
 › 1 snapshot written from 1 test suite.

Jest 创建此文件是因为您第一次运行使用快照的测试时,Jest 没有以前的快照来与您的组件进行比较。 因此,下次运行测试时需要使用组件的快照,如图 8.6 所示。 在 Jest 保存了一个快照之后,在每次测试的后续执行中,Jest 都会将断言的目标与存储的快照进行比较。


图8.6

如果你重新运行你的测试,你会看到 Jest 不会说它写了一个快照。 相反,它将现有快照与断言的目标进行比较,并检查它们是否匹配,如图 8.7 所示。 如果他们这样做,则断言通过。 否则,它会抛出错误。

图8.7

Jest 将这些快照写入与使用它的测试文件相邻的文件夹中。 此文件夹称为快照。

现在,打开项目根目录下的 snapshots 文件夹,看看里面的 ActionLog.test.jsx.snap 。

您将看到此文件包含您的测试名称和 ActionLog 组件在测试期间呈现的标记。

exports[`logging actions 1`] = `

Action Log

    // ...
`;

注意快照中的这个额外 div 是 react-testing-library 将组件渲染到的 div。为了避免在你的快照中有这个额外的 div,不要使用容器作为断言的目标,而是使用 container.firstChild。

如果您仔细观察,您会注意到这个文件只是一个 JavaScript 文件,它导出一个字符串,其中包含您的测试名称,后跟一个数字,代表它所链接到的 toMatchSnapshot 断言。

在运行测试时,Jest 使用测试的名称和 toMatchSnapshot 断言的顺序从 .snap 文件中获取将与断言目标进行比较的字符串。

重要事项 生成快照时要小心。如果您的组件呈现不正确的内容,Jest 用来比较您的组件的标记也将是不正确的。

只有在确定断言的目标正确后才应生成快照。或者,在第一次运行测试后,您还可以读取快照文件的内容以确保其内容正确。

在同一个测试中,尝试创建一个新的动作日志,渲染另一个 ActionLog 组件,并再次使用 toMatchSnapshot。

// ...
 
test("logging actions", () => {
  // ...
 
  const { container: containerOne } = render(         ❶
    
  );
  expect(containerOne).toMatchSnapshot();             ❷
 
  const newActions = actions.concat({                 ❸
    time: new Date(daysToMs(5)),
    message: "You were given lemons",
    data: { lemons: 1337 }
  });
 
  const { container: containerTwo } = render(         ❹
    
  );
  expect(containerTwo).toMatchSnapshot();             ❺
});

❶ 使用静态项目列表呈现 ActionLog 组件

❷ 期望 ActionLog 匹配快照

❸ 用一个额外的项目创建一个新的项目列表

❹ 使用新的动作列表渲染另一个 ActionLog 组件

❺ 期望第二个 ActionLog 匹配另一个快照

再一次,当你运行这个测试时,你会看到 Jest 会告诉你它写了一个快照。

Jest 必须为此测试编写一个新快照,因为它无法在 ActionLog.test.jsx.snap 中找到与断言目标进行比较的字符串。

如果再次打开 ActionLog.test.jsx.snap 文件,您会看到它现在导出两个不同的字符串,每个 toMatchSnapshot 断言一个。

// Jest Snapshot v1, https://goo.gl/fbAQLP
 
exports[`logging actions 1`] = `
  // ...
`;
 
exports[`logging actions 2`] = `
  // ...
`

现在尝试更改每个日志条目的格式,然后重新运行您的测试。

import React from "react";
 
export const ActionLog = ({ actions }) => {
  return (
    

Action Log

    {actions.map(({ time, message, data }, i) => { const date = new Date(time).toUTCString(); return (
  • Date: {date} -{" "} Message: {message} -{" "} Data: {JSON.stringify(data)}
  • ); })}
); };

在此更改之后,由于呈现的组件将不再匹配快照的内容,您的测试将失败。

要让您的测试再次通过,请使用 -u 选项(--updateSnapshot 的缩写)运行您的测试。 此选项将导致 Jest 更新 toMatchSnapshot 匹配器失败的快照。

PASS  ./ActionLog.test.jsx
 › 1 snapshot updated.
 
Snapshot Summary
 › 1 snapshot updated from 1 test suite.

提示如果您使用 NPM 脚本来运行测试,请附加 -- 以向脚本添加选项。如果您使用 NPM 测试脚本运行测试,您可以尝试使用 npm run test -- --updateSnapshot。

重要仅当您确定断言的目标正确时才更新快照。当您使用 --updateSnapshot 选项时,与您第一次生成快照时类似,如果它们与快照不匹配,Jest 不会导致任何测试失败。

完成组件开发后,如果您使用的是 git 等版本控制系统,请确保在提交中包含快照。否则,你的测试总是会在别人的机器上通过,因为 Jest 会写新的快照文件,即使组件渲染了不正确的内容。

感谢 Jest 的快照,您可以使用更简洁的测试来测试您的 ActionLog 组件。您不必编写包含长字符串的多个断言,而是编写了一个能够验证组件整个内容的断言。

快照对于替换一长串复杂的断言特别有用。带有固定标记的复杂组件的日志(长文本)是 Jest 快照最闪亮的用例示例。

因为创建和更新快照非常容易,所以您不需要经常更新测试中的断言。当测试涉及多个低价值、昂贵的更改(例如更新一堆相似的字符串)时,避免手动更新测试尤其有用。

到目前为止,考虑到编写和更新涉及快照的测试是多么快速和容易,它们似乎是单元测试的灵丹妙药,但它们并不总是适用于所有类型的测试。

快照最明显的问题之一是错误太容易被忽视了。由于快照是自动生成的,您可能最终会错误地更新快照,从而导致您的测试通过,即使断言的目标不正确。

即使您有代码审查过程,当一次更新多个快照时,也很容易错过更改,尤其是当您的快照太大或一次更改太多代码时。

注意 在第 13 章中,我将更详细地讨论如何进行有用的代码审查。

为避免意外更新快照,请避免在运行多个测试时使用 --updateSnapshot 标志。谨慎使用它,并且仅在一次运行单个测试文件时使用它,以便您确切知道 Jest 更新了哪些快照。

提示 Jest 有一种使用模式,允许您以交互方式更新快照。在交互式快照模式下,Jest 将向您显示在执行测试期间更改的每个快照的差异,并允许您选择新快照是否正确。

要进入交互式快照模式,请使用 --watch 选项运行 Jest,然后按 i 键。

此外,为了让您的同事更容易发现不正确的快照,请避免生成太长的快照。

提示如果您正在使用 eslint,您可以使用 eslint-plugin-jest 中的 no-large-snapshots 选项禁止大快照,您可以在 https://github.com/jest-community/eslint 上找到更多详细信息-插件开玩笑。我将在第 13 章深入介绍像 eslint 这样的 linter。

使用快照的另一个缺点是将测试的行为锁定到特定输出。

例如,如果您有多个 ActionLog 测试并且所有测试都使用快照,那么如果您决定更改操作日志的标题,所有测试都会失败。相比之下,对 ActionLog 组件的不同部分进行多个小测试,您将获得更精细的反馈。

为了避免粗略的反馈,同时仍能获得快照测试的好处,您可以缩小要对组件的哪些部分进行快照测试的范围。除了使您的测试更精细之外,这种技术还可以减少快照的大小。

如果您希望刚刚编写的测试仅检查 ActionLog 中列表的内容,例如,您可以仅使用 ul 元素作为使用 toMatchSnapshot 的断言中的断言目标。

// ...
 
test("logging actions", () => {
  // ...
 
  const { container } = render();
  const logList = document.querySelector("ul")
  expect(logList).toMatchSnapshot();
});

现在您知道如何进行快照测试,您将更新 App,以便它向 ActionLog 传递操作列表。

首先,更新 App.jsx 使其拥有一个状态,在其中存储一系列操作。 然后,App 组件会将这部分状态传递给 ActionLog 组件,它将作为其子组件之一呈现。

// ...
 
import { ActionLog } from "./ActionLog.jsx";
 
export const App = () => {
  const [items, setItems] = useState({});
  const [actions, setActions] = useState([]);
 
  // ...
 
  return (
    

Inventory Contents

); };

为了让 ActionLog 组件具有一些要显示的初始内容,请在它接收到包含初始清单项的服务器响应时执行 App 更新操作,如下所示。

// ...
 
export const App = () => {
  const [items, setItems] = useState({});
  const [actions, setActions] = useState([]);                         ❶
  const isMounted = useRef(null);
 
  useEffect(() => {
    isMounted.current = true;
    const loadItems = async () => {
      const response = await fetch(`${API_ADDR}/inventory`);
      const responseBody = await response.json();
 
      if (isMounted.current) {
        setItems(responseBody);
        setActions(actions.concat({                                   ❷
          time: new Date().toISOString(),
          message: "Loaded items from the server",
          data: { status: response.status, body: responseBody }
        }));
      }
    };
    loadItems();
    return () => (isMounted.current = false);
  }, []);
 
  // ...
 
  return (
    

Inventory Contents

); };

❶ 创建一个状态来表示应用程序的动作

❷ 更新 App 内的状态,使其动作列表包含一个动作,通知它已从服务器加载了项目列表

现在您已经为 ActionLog 组件编写了标记并为其提供了一些数据,接下来构建您的应用程序、提供服务并检查操作日志的内容。 一旦客户端从服务器收到初始项目,您的操作日志应包含响应的正文和状态。

再次测试App渲染的ActionLog,可以使用快照测试。

首先,要将快照测试限制为 ActionLog 组件,请将 data-testid 属性添加到其最外层 div,以便您可以在测试中找到它。

import React from "react";
 
export const ActionLog = ({ actions }) => {
  return (
    
{ /* ... */ }
); };

有了这个属性,编写一个测试来渲染 App 并等待加载项目的请求解析,然后使用 toMatchSnapshot 为 ActionLog 的内容生成快照。

test("updating the action log when loading items", async () => {
  const { getByTestId } = render();                              ❶
 
  await waitFor(() => {                                                 ❷
    const listElement = document.querySelector("ul");
    expect(listElement.childElementCount).toBe(3);
  });
 
  const actionLog = getByTestId("action-log");                          ❸
  expect(actionLog).toMatchSnapshot();                                  ❹
});

❶ 渲染一个 App 实例

❷ 等待渲染的项目列表有三个孩子

❸ 查找动作日志容器

❹ 期望操作日志与快照匹配

此测试将在您第一次运行时通过,但在所有后续执行中都会失败。 这些失败的发生是因为 Jest 为操作列表生成的快照包含当前时间,每次重新运行测试时都会更改。

为了使该测试具有确定性,您可以像在第 5 章中所做的那样使用伪计时器,或者您可以直接存根 toISOString 以便它始终返回相同的值。

test("updating the action log when loading items", async () => {
  jest.spyOn(Date.prototype, "toISOString")
    .mockReturnValue("2020-06-20T13:37:00.000Z");                   ❶
 
  const { getByTestId } = render();                          ❷
  await waitFor(() => {                                             ❸
    const listElement = document.querySelector("ul");
    expect(listElement.childElementCount).toBe(3);
  });
 
  const actionLog = getByTestId("action-log");                      ❹
  expect(actionLog).toMatchSnapshot();                              ❺
});

❶ 在 Date.prototype 中存根 toIsoString 以便它总是返回相同的日期

❷ 渲染一个App实例

❸ 等待渲染的项目列表有三个孩子

❹ 查找动作日志容器

❺ 期望动作日志匹配快照

在此更改后,使用 --updateSnapshot 选项重新运行您的测试。然后,在 Jest 更新此测试的快照后,多次重新运行您的测试,您将看到它们总是会通过。

在测试中使用快照时,请确保您的测试是确定性的。否则,它们在第一次执行后总是会失败。

作为练习,更新应用程序,以便在用户每次向库存添加项目时向操作日志添加一个新条目。然后,使用 toMatchSnapshot 对其进行测试。

注意您可以在 GitHub 上本书的存储库中的 Chapter8/2_snapshot _testing/1_component_snapshots 目录中找到本练习的解决方案,网址为 https://github.com/lucasfcosta/testing-javascript-applications。

8.2.1 组件之外的快照
快照测试不仅限于测试 React 组件。您可以使用它来测试任何类型的数据,从 React 组件到简单对象,或原始值,如字符串。

想象一下,您已经构建了一个小型实用程序,如下所示,根据给定的项目、数量和价格列表,将报告写入 .txt 文件。

const fs = require("fs");
 
module.exports.generateReport = items => {
  const lines = items.map(({ item, quantity, price }) => {                 ❶
    return `${item} - Quantity: ${quantity} - Value: ${price * quantity}`;
  });
  const totalValue = items.reduce((sum, { price }) => {                    ❷
    return sum + price;
  }, 0);
 
  const content = lines.concat(`Total value: ${totalValue}`).join("\n");   ❸
  fs.writeFileSync("/tmp/report.txt", content);                            ❹
};

❶ 生成行,告知每个项目的数量和每种项目的总价值

❷ 计算存货总值

❸ 生成文件的最终内容

❹ 将报告同步写入文件

要测试此实用程序,您可以使用 Jest 的快照测试功能将生成的值与快照进行比较,而不是在断言中编写长文本。

尝试通过创建一个名为 generate_report.test.js 的文件来实现这一点,并编写一个调用 generateReport 并带有项目列表的测试,从 /tmp/report.txt 读取,并将该文件的内容与快照进行比较。

const fs = require("fs");
const { generateReport } = require("./generate_report");
 
test("generating a .txt report", () => {
  const inventory = [                                              ❶
    { item: "cheesecake", quantity: 8, price: 22 },
    { item: "carrot cake", quantity: 3, price: 18 },
    { item: "macaroon", quantity: 40, price: 6 },
    { item: "chocolate cake", quantity: 12, price: 17 }
  ];
 
  generateReport(inventory);                                       ❷
  const report = fs.readFileSync("/tmp/report.txt", "utf-8");      ❸
  expect(report).toMatchSnapshot();                                ❹
});

❶排列:创建一个静态的项目列表

❷ Act:练习generateReport函数

❸ 读取生成的文件

❹ 断言:期望文件的内容与快照匹配

编写此测试后,运行它并检查快照文件夹中 generate_report.test.js.snap 文件的内容。 在该文件中,您将找到一个包含文件内容的字符串。

exports[`generating a .txt report 1`] = `
"cheesecake - Quantity: 8 - Value: 176
carrot cake - Quantity: 3 - Value: 54
macaroon - Quantity: 40 - Value: 240
chocolate cake - Quantity: 12 - Value: 204
Total value: 63"
`;

现在,无论何时重新运行测试,Jest 都会将 /tmp/report.txt 文件的内容与快照中的内容进行比较,就像测试 React 组件时所做的那样,如图 8.8 所示。


图8.8

这种技术对于测试转换代码或写入终端的程序非常方便。

例如,Jest 项目使用自身及其快照测试功能来验证它生成的测试摘要。 当 Jest 的贡献者编写新功能时,他们会编写执行 Jest 的测试,并将通过 stdout 写入终端的内容与快照进行比较。

8.2.2 序列化器
为了让 Jest 能够将数据写入快照,它需要知道如何正确地序列化它。

例如,当您测试 React 组件时,Jest 知道如何以一种使您的快照可读的方式序列化这些组件。 这个专门用于 React 组件的序列化程序,如图 8.9 所示,这就是为什么您会在快照中看到漂亮的 HTML 标签而不是一堆令人困惑的对象的原因。


图8.9

可理解的快照可让您更轻松地发现错误,并在您将代码推送到远程存储库后让其他人更轻松地查看您的快照,从而提高了测试质量。

在撰写本文时,当前版本的 Jest (26.6) 已经为所有 JavaScript 的原始类型、HTML 元素、React 组件和 ImmutableJS 数据结构提供了序列化程序,但您也可以构建自己的序列化程序。

例如,您可以使用自定义序列化程序来比较组件的样式,您将在下一节中看到。

8.3 测试风格
路易斯知道大多数时候蛋糕上的樱桃不仅仅是一个细节。这实际上是让顾客决定他们是否会把那块甜甜的甜点带回家的原因。当芝士蛋糕看起来不错时,它肯定会卖得更好。

同样,组件的样式是决定您是否可以发布它的一个组成部分。例如,如果您的组件的根元素具有永久可见性:隐藏规则,则它可能对您的用户不会很有用。

在本节中,您将学习如何测试组件的样式以及通过测试可以实现和不能实现的目标。

要了解如何测试组件的样式,您需要为应用程序制作动画并以红色突出显示即将缺货的商品。实施这些更改后,我将完成测试过程并解释您可以测试和不能测试的内容,以及哪些工具可以帮助您生成更好的自动化测试。

首先,创建一个styles.css 文件,您将在其中编写一个类来为即将脱销的商品设置样式。

.almost-out-of-stock {
  font-weight: bold;
  color: red;
}

创建该文件后,将样式标记添加到 index.html 以加载它。



  
    
    Inventory
    

❶ 加载styles.css

现在您可以将此类中的规则应用于页面中的元素,更新 ItemList 以便它使用几乎缺货的样式来设置数量少于五个的元素。

// ...
 
export const ItemList = ({ itemList }) => {
  const items = Object.entries(itemList);
 
  return (
    
    {([itemName, quantity]) => styleProps => (
  • {generateItemText(itemName, quantity)}
  • )}
); };

❶ 将几乎缺货类应用于表示数量小于 5 的物品的 li 元素

要查看数量少于五个以红色突出显示的项目,请重新构建您的应用程序,并在浏览器中手动试用。

最后,是时候为它编写自动化测试了。 您将编写的测试应将 itemListprop 传递给 ItemList 组件,并检查数量小于 5 的项目是否应用了几乎缺货类。

describe("ItemList Component", () => {
  // ...
 
  test("highlighting items that are almost out of stock", () => {
    const itemList = { cheesecake: 2, croissant: 5, macaroon: 96 };        ❶
 
    const { getByText } = render();        ❷
    const cheesecakeItem = getByText(generateItemText("cheesecake", 2));   ❸
    expect(cheesecakeItem).toHaveClass("almost-out-of-stock");             ❹
  });
});

❶排列:创建一个静态项目列表

❷ Act:使用静态项目列表呈现 ItemList 的实例

❸ 找到一个元素表明库存包含 2 个芝士蛋糕

❹ 断言:期望渲染的 li 具有几乎脱销的类

一旦你运行你的测试,它们应该都通过了,但这并不一定意味着它们是可靠的。例如,如果您更改几乎缺货类的名称或其任何规则以使它们不再突出显示项目,则您刚刚编写的测试不会失败。

例如,尝试从几乎缺货的 CSS 规则中删除将颜色设置为红色的规则。如果你这样做并重新运行你的测试,你会看到它仍然会通过,即使应用程序没有用即将变得不可用的红色项目突出显示。

在测试您的样式时,如果您使用的是外部 CSS 文件,您将无法检查是否应用了类中的特定样式规则。您将只能检查组件的 classname 属性是否正确。

如果您使用外部 CSS 文件,我会建议您不要对未更改的类进行断言。例如,如果您总是将一个名为 item-list 的类应用于 ItemList 中的 ul 元素,则测试 ul 是否具有某个 className 将没有多大价值。像这样的测试不能确保组件应用了正确的样式规则,或者它看起来应该是这样。相反,这个测试会产生更多的工作,因为它会经常因完全任意的字符串而中断,这在你的测试上下文中没有多大意义。如果有的话,您应该在这种情况下编写快照测试。

使您的样式测试更有价值的一种替代方法是在您的组件中编写内联样式。因为这些样式将包含强制组件以某种方式显示的规则,所以您可以编写更具体的断言,从而提供更可靠的保证。

例如,尝试将几乎缺货中的规则封装到 ItemList.jsx 中的单独对象中。然后,在渲染 li 元素时使用该对象,而不是使用类。

// ...
 
const almostOutOfStock = {                                    ❶
  fontWeight: "bold",
  color: "red"
};
 
export const ItemList = ({ itemList }) => {
  const items = Object.entries(itemList);
 
  return (
    
    {([itemName, quantity]) => styleProps => (
  • {generateItemText(itemName, quantity)}
  • )}
); };

❶ 代表一组样式的对象,应用于几乎缺货的商品

❷ 如果一个item的数量小于5,将almostOutOfStock对象中的样式与Transition提供的动画生成的样式合并; 否则,只使用动画的样式

进行此更改后,您将能够使用 toHaveStyle 断言对测试中的特定样式进行断言。

describe("ItemList Component", () => {
  // ...
 
  test("highlighting items that are almost out of stock", () => {
    const itemList = { cheesecake: 2, croissant: 5, macaroon: 96 };        ❶
 
    const { getByText } = render();        ❷
    const cheesecakeItem = getByText(generateItemText("cheesecake", 2));   ❸
    expect(cheesecakeItem).toHaveStyle({ color: "red" });                  ❹
  });
});

❶排列:创建一个静态项目列表

❷ Act:使用静态项目列表呈现 ItemList 的实例

❸ 找到一个元素表明库存包含 2 个芝士蛋糕

❹ 断言:期望渲染的 li 在其样式中具有颜色属性,其值为红色

由于这个断言,您将验证您的列表在项目即将变得不可用时以红色呈现。

这种策略在大多数情况下效果很好,但它有局限性。即使您可以断言单独的样式规则,您也无法确保您的应用程序看起来像它应该的样子。例如,组件可能出现在彼此之上,某个特定浏览器不支持特定规则,或者另一个样式表与组件的样式相互影响。

验证应用程序实际外观的唯一方法是使用使用图像的工具将浏览器的渲染结果与之前的快照进行比较。这种技术称为视觉回归测试,您将在第 10 章中了解更多相关信息。

如果您使用内联样式,一次断言多个样式可能会变得重复,甚至无法执行动画。例如,如果您想让即将变得不可用的物品脉动以使其更加引人注目,该怎么办?

为了更轻松地解决这些情况,您现在将采用我最喜欢的策略来设计 React 组件的样式。您将使用 css-in-js——也就是说,您将使用允许您在组件文件中使用 CSS 语法的工具。

除了可以更轻松地管理组件中的样式之外,许多 CSS-in-JS 库还使您能够扩展 linter 等工具,从而使您的自动化质量保证过程更加可靠。

我认为 CSS-in-JS 是设计 React 组件样式的最佳方式,因为它以一种与 React 采用的理念兼容的方式解决了管理 CSS 所带来的许多范围问题。它使您的组件封装了它们正常工作所需的一切。

要使用 CSS-in-JS,您需要安装一个专门为此制作的库。您将使用的库称为emotion,您可以使用npm install @emotion/core 安装它。

注意 因为您使用的是 React,情感库文档建议您使用 @emotion/core 包。

安装emotion后,在实现我提到的动画之前,更新ItemList组件,以便它使用emotion为即将不可用的列表项定义样式。

/* @jsx jsx */
 
// ...
 
import { css, jsx } from "@emotion/core"
 
// ...
 
const almostOutOfStock = css`                                      ❶
  font-weight: bold;
  color: red;
`;
 
export const ItemList = ({ itemList }) => {
  const items = Object.entries(itemList);
 
  return (
    
    {([itemName, quantity]) => styleProps => (
  • {generateItemText(itemName, quantity)}
  • )}
); };

❶ 使用来自@emotion/core 的 css 创建一组样式,这些样式将应用于数量小于 5 的项目

❷ 将使用情感创建的样式应用于表示数量小于 5 的项目的 li 元素

在您运行或更新任何测试之前,重新构建应用程序,并手动测试它以查看您的项目列表仍然可以用红色突出显示几乎缺货的项目。

即使您的应用程序正常工作,您的测试现在也会失败,因为您的组件不再使用内联样式属性以红色突出显示项目。相反,由于情感的工作方式,您的应用程序将为您使用情感创建的规则自动生成类,并将这些类应用于您的元素。

提示 要查看在浏览器中查看应用程序时情绪生成的类,您可以使用检查器检查哪些类名称和规则应用于每个列表项。

为了避免类名是自动生成的这一事实,并且仍然保持断言简洁、严谨和精确,您将使用 jest-emotion 包。此包允许您使用 toHaveStyleRule 匹配器扩展 Jest,该匹配器验证情感应用的样式规则。

使用 npm install --save-dev jest-emotion 将 jest-emotion 作为开发依赖项安装,然后创建一个名为 setupJestEmotion.js 的文件,该文件使用 jest-emotion 中的匹配器扩展 jest。

const { matchers } = require("jest-emotion");
 
expect.extend(matchers);                        ❶

❶ 扩展 Jest 与来自 jest-emotion 的匹配器

要使 setupJestEmotion.js 在每个测试文件之前运行,请将其添加到 jest.config.js 的 setupFilesAfterEnv 属性中的脚本列表中。

module.exports = {
  setupFilesAfterEnv: [
    "/setupJestDom.js",
    "/setupGlobalFetch.js",
    "/setupJestEmotion.js"           ❶
  ]
};

❶ 在每个测试文件之前,让 Jest 执行 setupJestEmotion.js,它将使用 jest-emotion 中的断言扩展 Jest

最后,在 ItemList 的测试中使用 toHaveStyleRule 匹配器。

describe("ItemList Component", () => {
  // ...
 
  test("highlighting items that are almost out of stock", () => {
    const itemList = { cheesecake: 2, croissant: 5, macaroon: 96 };        ❶
 
    const { getByText } = render();        ❷
    const cheesecakeItem = getByText(generateItemText("cheesecake", 2));   ❸
    expect(cheesecakeItem).toHaveStyleRule("color", "red");                ❹
  });
});

❶排列:创建一个静态项目列表

❷ Act:使用静态项目列表呈现 ItemList 的实例

❸ 找到一个元素表明库存包含 2 个芝士蛋糕

❹ 断言:使用来自 jest-emotion 的断言来断言找到的 li 有一个名为 color 的样式规则,其值为红色

再一次,您的所有测试都应该通过。

既然您正在使用 jest-emotion,您仍然可以对应用于组件的特定样式规则进行断言,并且您还可以执行更复杂的任务,例如动画。

继续为即将不可用的项目应用的样式添加动画。

// ...
 
import { css, keyframes, jsx } from "@emotion/core"
 
const pulsate = keyframes`                                    ❶
  0% { opacity: .3; }
  50% { opacity: 1; }
  100% { opacity: .3; }
`;
 
const almostOutOfStock = css`                                 ❷
  font-weight: bold;
  color: red;
  animation: ${pulsate} 2s infinite;
`;
 
export const ItemList = ({ itemList }) => {
  const items = Object.entries(itemList);
 
  return (
    
    {([itemName, quantity]) => styleProps => (
  • {generateItemText(itemName, quantity)}
  • )}
); };

❶ 创建一个动画,使数量小于 5 的项目产生脉动

❷ 创建样式以应用于表示数量小于 5 的项目的 li 元素。这些样式包括脉动动画。

❸ 将使用情感创建的样式应用于表示数量小于 5 的项目的 li 元素

多亏了情感,即将缺货的物品现在应该包括脉动动画。

在此更改之后,我强烈建议您使用 Jest 的快照功能,这样您就可以避免在断言中编写任何长而复杂的字符串。

更新您的测试,使它们将列表的元素样式与快照相匹配。

describe("ItemList Component", () => {
  // ...
 
  test("highlighting items that are almost out of stock", () => {
    const itemList = { )                                             ❶
      cheesecake: 2,
      croissant: 5,
      macaroon: 96
    };
 
    const { getByText } = render(                                    ❷
      
    );
 
    const cheesecakeItem = getByText(                                ❸
      generateItemText("cheesecake", 2)
    );
 
    expect(cheesecakeItem).toMatchSnapshot();                        ❹
  });
});

❶ 创建一个静态项目列表

❷ 使用静态项目列表渲染 ItemList 的实例

❸ 找到一个元素表明库存包含 2 个芝士蛋糕

❹ 期望找到的 li 与快照匹配

在第一次运行此测试以便 Jest 可以创建快照后,再次运行它几次以查看它总是通过。

这个测试的问题在于它的快照不是很丰富或易于查看。 如果您打开为此测试创建的快照,您将看到它包含一个神秘的类名,而不是组件的实际样式。

exports[`ItemList Component highlighting items that are almost out of stock 1`] = `
  • Cheesecake - Quantity: 2
  • `;

    如果您还记得我在上一节中所说的内容,那么不提供信息的快照会使您和审查代码的人很容易错过重要的更改,也很容易忽略错误。

    要解决此问题,请使用 jest-emotion 提供给您的自定义序列化程序扩展 Jest。 如图 8.10 所示,这个序列化器将告诉 Jest 如何正确序列化情绪风格,以便您的快照可读和可理解。 多亏了 jest-emotion 中包含的序列化程序,您的快照将包含实际的 CSS 规则而不是神秘的类名。


    图8.10

    更新 jest.config.js,并将包含 jest-emotion 的数组分配给 snapshotSerializers 属性。

    module.exports = {
      snapshotSerializers: ["jest-emotion"],        ❶
      setupFilesAfterEnv: [
        // ...
      ]
    };
    
    

    ❶ 使用 jest-emotion 中的序列化程序扩展 Jest,以便它知道如何正确序列化样式,包括快照中的所有规则,而不是仅包括神秘的类名

    既然 Jest 知道如何序列化由情感创建的样式,请使用 --updateSnapshot 标志重新运行测试,并再次检查该快照文件。

    exports[`ItemList Component highlighting items that are almost out of stock 1`] = `
    @keyframes animation-0 {
      0% {
        opacity: .3;
      }
     
      50% {
        opacity: 1;
      }
     
      100% {
        opacity: .3;
      }
    }
     
    .emotion-0 {
      font-weight: bold;
      color: red;
      -webkit-animation: animation-0 2s infinite;
      animation: animation-0 2s infinite;
    }
     
    
  • Cheesecake - Quantity: 2
  • `;

    由于快照文件现在包含有关应用于组件的样式的可读信息,因此您的快照更易于查看,从而可以更快地发现错误。

    每当您处理复杂的样式时,请尝试使用快照而不是手动编写多个繁琐且重复的断言。

    作为练习,尝试将不同的样式和动画应用于库存中过多的物品,然后使用您在本节中学到的技术对其进行测试。

    样式是一种情况,在这种情况下,您选择的工具会对您编写测试的方式产生深远的影响。 因此,这是一个很好的例子,可以证明在选择用于构建应用程序的库和框架类型时,还应该考虑测试。

    你可能感兴趣的:(8 测试 React 应用程序)