在新人聘请路易斯参加婚礼的甜点自助餐之前,他们总是先安排一次品尝会。在这些课程中,Louis 准备了许多不同的甜点,从 éclairs 和果仁糖到蛋糕和馅饼,让他的客户可以看到和品尝他的每一道美味佳肴。
编写故事类似于准备品尝自助餐。故事是组件特定用例或视觉状态的演示。故事展示了单个组件的功能,以便您和您的团队可以可视化该组件并与之交互,而无需运行整个应用程序。
例如,想想你必须做什么才能看到 ItemList 工作。要查看 ItemList,您必须自己编写组件,在 App 中使用它,向数据库添加一些项目,并使您的应用程序从您的服务器获取库存项目。此外,您还必须构建前端应用程序、构建后端以及迁移和种子数据库。
通过故事,您可以编写包含 ItemList 的各种实例的页面,以及不同的静态数据集。这种技术的第一个优点是,您将能够在开发过程中更早地查看 ItemList 并与之交互,甚至在您开始在实际应用程序中使用它之前。
除了加速反馈循环之外,故事还促进了团队之间的协作,因为它们允许任何人随时尝试使用组件并查看它们的外观。
通过编写故事,您可以让其他人在组件级别执行验收测试。不必为 QA 或产品团队创建单独的环境来验证您的 UI 是否可以接受,而是通过故事,这些团队可以单独且更快地测试每个组件。
在本节中,您将学习如何使用 Storybook 编写组件故事和记录组件。您将从 ItemList 开始,然后继续为除 App 之外的所有组件编写故事。编写多个故事后,我将深入探讨它们在简化开发过程、促进协作和提高质量方面所发挥的作用。
写完故事后,我会教你如何记录你的组件以及为什么这样做会有帮助。
8.4.1 写故事
要编写组件故事,您将使用名为 Storybook 的工具。 Storybook 能够加载您的故事并通过有组织且易于理解的 UI 显示它们。
首先,使用 npm install --save-dev @storybook/react 将 Storybook for React 作为开发依赖项安装。 然后,为了让 Storybook 能够捆绑您将用于导航故事的应用程序,您必须使用 npm install --save-dev babel-loader 安装 babel-loader。
安装这两个包后,您必须通过在项目的根目录中创建一个 .storybook 文件夹来配置 Storybook。 在该文件夹中,您将放置一个 main.js 配置文件,用于确定 Storybook 将作为故事加载的文件。
module.exports = {
stories: ["../**/*.stories.jsx"], ❶
};
❶ 通知 Storybook 哪些文件包含您的故事
注意 在撰写本文时,我使用的是 Storybook 的最新可用版本:版本 6。在此版本中,Storybook 工具链中存在一个问题,导致它在构建过程中找不到所需的某些文件。
如果你希望使用 Storybook 的第 6 版,你可能需要更新你的 Storybook 配置,以便它告诉 Webpack 在构建过程中在哪里可以找到它需要的文件,如下所示。
module.exports = {
// ...
webpackFinal: async config => {
return {
...config,
resolve: {
...config.resolve,
alias: {
"core-js/modules": "@storybook/core/node_modules/core-js/modules",
"core-js/features": "@storybook/core/node_modules/core-js/features"
}
}
};
}
};
创建此文件后,您可以通过运行 ./node_modules/.bin/start-storybook 来启动 Storybook。
提示 为避免每次运行时都必须键入 Storybook 可执行文件的完整路径,请将名为 storybook 的脚本添加到 package.json 文件中。
{
"name": "my-application",
// ...
"scripts": {
"storybook": "start-storybook", ❶
// ...
}
// ...
}
❶ 创建一个 NPM 脚本,当你执行 npm run storybook 时它会启动 Storybook
现在,您可以使用 npm run storybook 运行 Storybook,而无需键入 start-storybook 可执行文件的完整路径。
当您启动 Storybook 时,它将创建一个 Web 应用程序,允许您浏览组件故事。一旦捆绑了这个 Web 应用程序,Storybook 将提供它并在新的浏览器选项卡中打开它。
提示 为了促进开发、设计和产品团队之间的信息交换,您可以将 Storybook 生成的应用程序部署到这些团队的每个成员都可以访问的地方。
要为 ItemList 组件创建您的第一个故事,请添加一个名为 ItemList.stories.jsx 的文件。在此文件中,您将导出一个对象,其中包含您将编写的故事组的元数据以及您希望 Storybook 显示的每个故事的名称。
要编写单独的故事,请创建一个命名导出,其值是一个函数,该函数返回您要展示的组件。
import React from "react";
import { ItemList } from "./ItemList";
export default { ❶
title: "ItemList",
component: ItemList,
includeStories: ["staticItemList"]
};
export const staticItemList = () =>
❶ 为 ItemList 配置故事集,通知这些故事的标题、它们关联的组件以及要包含的故事
❷ 创建一个故事,用静态项目列表呈现 ItemList 的实例
一旦你写完这个故事,你会看到你的 Storybook 实例渲染了一个 ItemList,就像 App 一样。因为您已经编写了静态数据来填充 ItemList,所以您不需要任何正在运行的服务器或来自应用程序其余部分的任何数据。
一旦您的组件可以呈现,您就可以通过故事查看它并与之交互。
现在您有了 ItemList 的故事,您团队中的每个人都将能够看到它的外观并以原子方式与之交互。每当他们需要更改 ItemList 时,他们可以使用您的故事快速迭代,而不必处理您的整个应用程序。
尽管这个故事让人们可以更快、更容易地更改 ItemList 并与之交互,但它还没有演示该组件的所有功能。
为了展示 ItemList 如何为进入或离开库存的物品设置动画,从而涵盖该组件的全部功能,您将编写一个新故事。这个故事应该返回一个有状态的组件,其中包括 ItemList 和两个更新外部组件状态的按钮。其中一个按钮将向列表中添加一个项目,另一个按钮将 ItemList 重置为其原始状态。
import React, { useState } from "react";
// ...
export default {
title: "ItemList",
component: ItemList,
includeStories: ["staticItemList", "animatedItems"]
};
// ...
export const animatedItems = () => { ❶
const initialList = { cheesecake: 2, croissant: 5 }; ❷
const StatefulItemList = () => { ❸
const [itemList, setItemList] = useState(initialList); ❹
const add = () => setItemList({ ...initialList, macaroon: 96 }); ❺
const reset = () => setItemList(initialList); ❻
return ( ❼
);
};
return ❽
};
❶ 一个故事来演示 ItemList 如何为进入或离开它的项目设置动画
❷ 创建一个静态的项目列表
❸ 故事将呈现的有状态组件
❹ 一个包含项目列表的状态
❺ 将 96 个杏仁饼添加到列表中的功能
❻ 一个将列表重置为其初始状态的函数
❼ 使有状态组件返回按钮以添加项目并重置项目列表和具有 ItemList 实例的 div,其 itemList 属性是有状态组件状态中的项目列表
❽ 渲染有状态组件的一个实例
每当您需要与您的组件进行交互时,您都可以创建一个有状态的包装器,就像您刚刚所做的那样。这些包装器的问题在于它们给你的故事增加了一层额外的复杂性,并将观众的互动限制在你最初认为他们想要做的事情上。
您可以使用名为 @storybook/addon-knobs 的包,而不是使用有状态包装器,以允许查看者以他们想要的任何方式操纵传递给您的组件的道具。
@storybook/addon-knobs 附加组件在 Storybook 的底部面板中添加了一个新选项卡,观众可以在其中即时更改与您的故事相关的任何道具的值。
继续使用 npm install --save-dev @storybook/addon-knobs 作为开发依赖项安装 @storybook/addon-knobs。然后,更新您的 .storybook/main.js 文件,并向其添加 addons 属性。此属性的值将是一个数组,其中包含 Storybook 应加载的附加组件列表。
module.exports = {
stories: ["../**/*.stories.jsx"],
addons: ["@storybook/addon-knobs/register"], ❶
// ...
};
❶ 配置 Storybook 以使用 @storybook/addon-knobs 附加组件
使用此附加组件,您可以更新您的故事,以便 @storybook/addon-knobs 管理传递给您的组件的道具。
import React from "react";
import { withKnobs, object } from "@storybook/addon-knobs";
export default {
title: "ItemList",
component: ItemList,
includeStories: ["staticItemList", "animatedItems"],
decorators: [withKnobs] ❶
};
// ...
export const animatedItems = () => {
const knobLabel = "Contents";
const knobDefaultValue = { cheesecake: 2, croissant: 5 };
const itemList = object(knobLabel, knobDefaultValue); ❷
return ❸
};
❶ 为 ItemList 配置故事,以便他们可以使用旋钮插件
❷ 创建一个 itemList 对象,该对象将由旋钮附加组件管理
❸ 渲染一个 ItemList 的实例,其 itemList 属性是由旋钮管理的对象
使用新插件将托管属性传递给 ItemList 后,打开 Storybook 并尝试通过故事底部名为“动画项目”的旋钮选项卡更改 itemListprop。当您更改这些属性时,您会看到组件更新,为进入或离开列表的项目设置动画。
@storybook/addon-knobs 提供的灵活性使测试人员可以更轻松地检查您的组件、模拟边缘情况和执行探索性测试。对于产品团队而言,这种灵活性将有助于更好地了解组件的功能。
既然您已经为 ItemList 编写了故事,那么您也将为 ItemForm 编写一个故事。在您项目的根目录中,创建一个名为 ItemForm.stories.jsx 的文件,并编写一个故事来呈现您的表单并在用户提交时显示警报。
import React from "react";
import { ItemForm } from "./ItemForm";
export default { ❶
title: "ItemForm",
component: ItemForm,
includeStories: ["itemForm"]
};
export const itemForm = () => { ❷
return (
{
alert(JSON.stringify(data));
}}
/>
);
};
❶ 为 ItemForm 配置故事集,告知它们的标题、它们关联的组件以及要包含的故事
❷ ItemForm 的故事,在添加项目时显示警报
即使这个故事呈现您的组件并显示带有提交数据的警报,ItemForm 仍在向您的后端发送请求。如果您在与此组件的 this story 交互时运行您的服务器,您将看到您的数据库在您提交 ItemForm 时得到更新。为避免 ItemForm 向后端发送任何请求,您必须存根该功能。
以前,您已经使用 nock 创建了响应 HTTP 请求的拦截器,但您将无法在 Storybook 中使用它。因为 nock 依赖于 Node.js 特定的模块,比如 fs,所以它不能在你的浏览器上运行。
您将使用一个名为 fetch-mock 的包,而不是使用 nock 来拦截和响应 HTTP 请求。它的 API 类似于 nock 的,并且可以在浏览器中运行。
使用 npm install --save-dev fetch-mock 将 fetch-mock 作为开发依赖项安装,并更新 ItemForm.stories.jsx 以便您拥有 ItemForm 执行的 POST 请求的拦截器。
// ...
import fetchMock from "fetch-mock";
import { API_ADDR } from "./constants";
// ...
export const itemForm = () => {
fetchMock.post(`glob:${API_ADDR}/inventory/*`, 200); ❶
return (
{
alert(JSON.stringify(data));
}}
/>
);
};
❶ 创建一个拦截器,以 200 状态响应对 /inventory/* 的任何 POST 请求
使用 fetch-mock 拦截请求后,ItemForm 永远不会到达你的后端,你总是会得到成功的响应。要确认 ItemForm 不发送任何 HTTP 请求,请尝试与表单的故事进行交互并在打开开发人员工具的“网络”选项卡的同时提交一些项目。
现在,使这个故事完整的最后一步是清除您编写的拦截器,以免干扰其他故事。目前,当您打开表单的故事时,它将创建一个拦截器,该拦截器将一直存在,直到用户刷新故事查看器。该拦截器可能会影响其他故事,例如,如果您有另一个故事将请求发送到与 ItemForm 相同的 URL。
为了在用户离开这个故事时清除拦截器,您将 ItemForm 包装到另一个组件中,该组件在安装时创建一个拦截器并在卸载时消除拦截器。
// ...
export const itemForm = () => {
const ItemFormStory = () => {
useEffect(() => { ❶
fetchMock.post(`glob:${API_ADDR}/inventory/*`, 200)
return () => fetchMock.restore(); ❷
}, []);
return (
{
alert(JSON.stringify(data));
}}
/>
);
}
return
};
❶ 当 ItemFormStory 挂载时,创建一个拦截器,该拦截器以 200 状态响应对 /inventory/* 的任何 POST 请求
❷ 导致故事在卸载时销毁拦截器
在您的故事中使用存根时,请记住清除您创建的任何挂起的存根或间谍,就像您刚刚在恢复拦截器时所做的那样。 要执行存根和清理,您可以使用带有钩子或生命周期方法的包装器组件。
最后,您将摆脱 ItemForm 触发的警报。 您将使用 @storybook/addon-actions 包将操作记录到 Storybook UI 中的单独选项卡,而不是显示破坏性的弹出窗口。
要使用此附加组件,请使用 npm install --save-dev @storybook/addon-actions 将其安装为开发依赖项,并更新您的 Storybook 配置文件。 在 .storybook/main.js 中,向导出的对象添加一个 addons 属性,并为其分配一个包含附加组件注册命名空间的数组。
module.exports = {
stories: ["../**/*.stories.jsx"],
addons: [
"@storybook/addon-knobs/register",
"@storybook/addon-actions/register" ❶
],
// ...
};
❶ 配置 Storybook 以使用 @storybook/addon-actions 插件
安装此附加组件并重新运行 Storybook 后,您将在每个故事的底部看到一个操作选项卡。 在此选项卡中,Storybook 将记录对插件操作创建的操作的每次调用。
要开始记录操作,您将更新 ItemForm.stories.js。 在此文件中,您将从 @storybook/addon-actions 导入动作并使用此函数创建将传递给故事中的 ItemForm 实例的回调。
// ...
import { action } from "@storybook/addon-actions";
// ...
export const itemForm = () => {
const ItemFormStory = () => {
// ...
return ;
};
return ;
};
❶ 使表单在提交时将操作记录到 Storybook 中的 Actions 选项卡
更新表单的故事后,打开 Storybook 并尝试多次提交表单。每次提交时,Storybook 都应该在故事的“操作”选项卡中记录一个新操作。
使用操作而不是警报可以更容易地理解您的组件正在做什么并检查它调用传递的回调的参数。
现在您已经知道如何创建故事了,作为练习,尝试为 ActionLog 组件创建一个故事。创建一个新的 .stories.jsx 文件,并编写一个故事来演示 ActionLog 的工作原理。
注意您可以在 GitHub 上本书的存储库中的 Chapter8/4_component _stories/1_stories 目录中找到本练习的解决方案,网址为 https://github.com/lucasfcosta/testing-javascript-applications。
除了缩短反馈循环并为其他人手动测试组件创造更友好的环境之外,这些故事还促进了开发人员和其他团队成员之间的交流。当设计师可以访问故事时,他们更容易准备符合现有 UI 模式的布局,因为他们知道应用程序当前组件的外观和行为。
最终,故事是迈向 UI 工业化的一步。通过尝试将新功能的开发限制在一组现有组件中,您可以减少返工并最终获得更可靠的应用程序。这种改进不仅是因为您有更多时间专注于测试,还因为代码更少,隐藏错误的地方也更少。
读完上一段后,很多人可能会争辩说 UI 的工业化会限制创造力——我完全同意这一点。尽管如此,我想说这个限制是一个特性,而不是一个错误。
创造力是有代价的,由于产品团队接触新组件的频率,这一点常常被忽视。尝试将 UI 模式限制为一组现有组件会使其他团队更容易注意到实现新功能所需的工作量。
组件库的目标不是限制设计师的创作自由,而是明确创意成本,以便业务蓬勃发展。
此类库代表一组 UI 组件、约束和最佳实践,也称为设计系统,并且近年来流行度激增。
尽管越来越受欢迎,但故事并不总是一个好主意。就像测试一样,故事是需要维护的代码片段。当你更新你的组件时,你需要确保你的故事仍然充分展示了你的组件的用例。
即使您有足够的带宽来使故事保持最新,您仍然需要为维护付出更高的代价。承担这些维护成本的优势在于,您将减少概念化和实施新功能所涉及的成本。这种成本降低的发生是因为故事促进了组件的重用,并将设计团队的创造力限制在已有的东西上,从而使变更成本更加明确。
8.4.2 编写文档
路易斯相信他有能力让顾客品尝到的每一种甜点都让他们眼前一亮。尽管如此,他知道他的员工要这样做,必须仔细解释他的食谱,从最谨慎的可可粒到最大胆的生奶油。
通过编写故事,您可以展示组件的外观并演示其行为方式,但要让其他人了解他们应该如何使用它,您必须编写文档。
在本节中,您将学习如何使用 Storybook 为您的组件编写和发布文档,从 ItemList 开始。
要编写文档,您将使用一种称为 MDX 的文件格式。 MDX 文件支持 Markdown 和 JSX 代码的组合,因此您可以编写纯文本来解释组件的工作方式,并在整个文档中包含组件的真实实例。
要让 Storybook 支持 MDX 文件,您将使用 @storybook/addon-docs 插件。此附加组件会导致您的每个故事显示一个名为 Docs 的额外选项卡。在此选项卡中,您将找到与当前故事相对应的 MDX 文档。
安装@storybook/addon-docs 时,您还必须安装该附加组件所依赖的 react-is 包。要将两者安装为开发依赖项,请执行 npm install --save-dev react-is @storybook/addon-docs。
安装@storybook/addon-docs 及其依赖项后,更新 .storybook/main.js 中的配置,以便 Storybook 支持用 MDX 编写的文档。
除了更新配置中的 addons 属性外,您还必须更新 stories 属性,以便 Storybook 包含扩展名为 .mdx 的文件。
module.exports = {
stories: ["../**/*.stories.@(jsx|mdx)"],
addons: [
"@storybook/addon-knobs/register",
"@storybook/addon-actions/register",
{
name: "@storybook/addon-docs", ❶
options: { configureJSX: true } ❷
}
],
// ...
};
❶ 配置 Storybook 以使用 @storybook/addon-docs 插件
❶ 鉴于您当前的 Babel 配置,此选项是必要的,以确保附加组件能够处理 JSX 文件。
更新此文件后,重新运行 Storybook,并访问您的一个故事以查看顶部的“文档”选项卡。
现在您已经配置了这个附加组件,您将编写项目列表的 Docs 选项卡的内容。
继续创建一个名为 ItemList.docs.mdx 的文件,您将在其中使用 Markdown 来描述您的组件如何工作,并使用 JSX 来包含真实的 ItemList 实例来说明您的文档。
为了让 Storybook 充分呈现您的组件实例,不要忘记将其包装到 @storybook/addon-docs 导出的 Preview 和 Story 组件中。此外,要将必要的元数据链接到您的故事,您还必须导入附加组件的 Meta 组件并将其添加到文件的开头。
注意您可以在 https://mdxjs.com 上找到 MDX 格式的完整文档。
import { Meta, Story, Preview } from '@storybook/addon-docs/blocks';
import { ItemList } from './ItemList';
# Item list
The `ItemList` component displays a list of inventory items.
It's capable of:
* Animating new items
* Highlighting items that are about to become unavailable
## Props
* An object in which each key represents an item's name and each value represents its quantity.
在您为 ItemList 编写了一些文档后,在 Storybook 上打开它的故事并检查 Docs 选项卡,这样您就可以看到您的 MDX 文件的外观。
良好的文档有助于测试人员确定组件的预期行为。通过在组件所在的 Storybook 中编写文档,您可以向测试人员清楚地传达组件的预期行为。反过来,清晰的沟通会导致更快、更有效的测试,开销更少,因此可以降低成本。
适当的文档还有助于产品团队以减少实施时间的方式设计新功能,让工程师能够专注于可靠性和软件开发的其他重要方面。
概括
在测试您的组件时,在组件树中尽可能高地编写集成测试以获得可靠的保证。您的测试目标在组件树中越高,测试就越可靠。
为了避免触发组件的副作用或测试第三方库,您可以存根组件。要存根组件,您可以使用 Jest 创建存根并使用 jest.mock 使导入解析为您的测试替身。
快照测试是一种测试技术,您可以在第一次运行测试时保存断言目标的快照。然后,在每次后续执行中,您将断言的目标与存储的快照进行比较。
快照对于测试包含大量标记或大量文本的组件很有用。因为您可以自动创建和更新快照,所以您可以避免花费时间进行繁重的活动,例如编写和重写又长又复杂的字符串。
在测试组件以外的目标时,请确保您使用了足够的序列化程序,以便 Jest 可以生成可读的快照,因此更易于查看。可理解的快照有助于代码审查,并使错误更难以被忽视。
在测试样式时,如果没有可视化测试,您无法保证组件看起来像它应有的样子,但是您可以确保将正确的类或规则应用于它。要对组件的样式进行断言,您可以对其类名或样式属性进行断言。
因为样式通常会变得又长又复杂,你可以将 Jest 的快照与 CSS-in-JS 结合起来,让开发人员更快地更新测试。在这种情况下,请确保使用正确的序列化程序,以便您的快照可读。
故事是一段代码,用于演示不同单个组件的功能。要编写故事,您可以使用名为 Storybook 的工具。
故事使测试人员更容易在组件级别执行验收测试,因为它们消除了在与组件交互之前启动整个应用程序的必要性。
故事是迈向 UI 工业化的一步。它们将新功能的开发限制在现有的组件集上。这种重用组件的鼓励减少了开发时间,从而降低了成本。使用更少的代码,您将有更少的地方可以隐藏错误,并有更多时间专注于质量控制。
要在 Storybook 中记录您的组件,您可以使用 @storybook/addon-docs 包。此附加组件允许您编写 MDX 文件来记录您的组件。这种文件格式接受 Markdown 和 JSX 的混合,因此您既可以解释组件的工作原理,又可以在整个文档中包含真实的组件实例。