Storybook 中的视觉测试
要发布无 bug 的 UI 是很难的。过去,开发人员使用单元测试和快照测试来扫描 HTML 块中的 bug。但这些方法不能反映用户实际看到的内容,因此 bug 始终存在。
视觉测试通过在真实浏览器中捕获和比较图像快照来发现 bug。它允许您自动化检查 UI 是否看起来正确的流程。
什么是视觉 bug?
视觉 bug 无处不在。截断的元素。错误的颜色或字体样式。损坏的布局。以及缺失的错误状态。
如今,每家公司都是一家软件公司。这意味着每家公司都负责维护 UI。但如果你和我一样,你可能会注意到,公司似乎总是不够人手来时刻监控 UI 的每一个部分。

视觉 bug 是 UI 外观中无意发生的错误,这些错误会让 UI 看起来不可靠。它们很容易凭肉眼发现,但常见的测试方法却无法捕获。
大多数测试旨在验证逻辑,这是有道理的:您运行一个函数,获取其输出,然后检查它是否正确。计算机非常擅长验证数据。但关于外观呢?
嗯,这个问题有两个层面。
1. 看起来正确吗?
以这个 Task 组件为例。根据不同的状态,它的外观不同。我们显示一个已选中(或未选中)的复选框、一些任务信息和一个图钉按钮。当然,还有所有相关的样式。

第一个挑战只是验证组件在所有这些场景中的外观。这需要大量调整 props 和 state 来设置和测试每种情况。哦,而且计算机真的不能告诉你它是否符合规范。你,开发人员,必须对其进行视觉检查。
2. 它仍然看起来正确吗?
你第一次就把它做对了。它在所有状态下看起来都很好。但随着开发的自然进行,会发生变化。bug 几乎不可避免地会潜入。特别是对于界面。一个小的 CSS 调整可能会破坏一个组件或其某个状态。
每次更改代码时,你都无法手动检查 UI 的全部范围。你需要更自动化的东西。
视觉测试
视觉测试允许您通过一个统一的工作流程来解决这两个任务。它是指在构建组件时验证其外观的过程。并且在迭代以发布新功能时再次进行。
视觉测试工作流程是这样的:
- 🏷 隔离组件。使用 Storybook 一次专注于和测试一个组件。
- ✍🏽 写出测试用例。使用 props 和 mock 数据重现每种状态。
- 🔍 手动验证每个测试用例的外观。
- 📸 自动捕获 UI bug。捕获每个测试用例的快照,并使用基于机器的 diff 来检查回归。
视觉测试的核心是将 UI 与应用程序的其他部分(数据、后端、API)隔离。这允许您单独观察每种状态。然后,您可以手动进行抽查,并自动对这些状态进行回归测试。
让我们详细了解每一步。
1. 隔离组件
一次测试一个组件并为每种状态编写测试用例,这样更容易发现 bug。传统的方法是在组件首次使用的应用程序页面上构建组件。这使得模拟和验证所有这些状态变得困难。有更好的方法——Storybook。
Storybook 是构建独立组件的行业标准。Twitter、Slack、Airbnb、Shopify、Stripe 和 Microsoft 都在使用它。它被打包成一个小型独立工具,与您的应用程序一起存在,为您提供:
- 📥 一个用于独立渲染每个组件的沙箱
- 🔭 将所有状态可视化为故事
- 📑 为每个组件记录 props 和使用指南
- 🗃️ 一个包含所有组件的目录,便于发现
让我们回到那个 Task 组件。隔离它意味着我们单独加载和渲染这个组件。为此,我们需要 Storybook。
设置 Storybook
我们的项目已预先配置为使用 Storybook。配置位于 .storybook 文件夹中,所有必要的脚本都已添加到 package.json 中。
我们可以通过为 Task 组件创建一个 story 文件来开始。这会将组件注册到 Storybook,并添加一个默认的测试用例。
import Task from './Task';
export default {
component: Task,
title: 'Task',
argTypes: {
onArchiveTask: { action: 'onArchiveTask' },
onTogglePinTask: { action: 'onTogglePinTask' },
onEditTitle: { action: 'onEditTitle' },
},
};
export const Default = {
args: {
task: {
id: '1',
title: 'Buy milk',
state: 'TASK_INBOX',
},
},
};
最后,运行以下命令以在开发模式下启动 Storybook。您应该会看到 Task 组件加载。
yarn storybook

现在我们可以开始编写测试用例了。
2. 编写测试用例
在 Storybook 中,测试用例被称为 stories。一个 story 捕获了组件的特定状态——在浏览器中实际渲染的状态。
Task 组件有三种状态——默认、图钉和已归档。我们将为每种状态添加一个 story。

import Task from './Task';
export default {
component: Task,
title: 'Task',
argTypes: {
onArchiveTask: { action: 'onArchiveTask' },
onTogglePinTask: { action: 'onTogglePinTask' },
onEditTitle: { action: 'onEditTitle' },
},
};
export const Default = {
args: {
task: {
id: '1',
title: 'Buy milk',
state: 'TASK_INBOX',
},
},
};
export const Pinned = {
args: {
task: {
id: '2',
title: 'QA dropdown',
state: 'TASK_PINNED',
},
},
};
export const Archived = {
args: {
task: {
id: '3',
title: 'Write schema for account menu',
state: 'TASK_ARCHIVED',
},
},
};
3. 验证
验证是指您在 Storybook 中评估组件的外观。也就是说,它是否符合设计规范?
通常的开发工作流程是:
- 编辑代码
- 让组件进入适当的状态
- 评估其外观
然后重复整个周期,直到您验证了所有状态。
通过为每种状态编写一个 story,您可以省去第二步。您可以直接从编辑代码跳转到验证所有测试用例。从而大大加快了整个过程。
编写 stories 还会浮现您在以更随意的 T.方式开发时可能未曾考虑到的场景。例如,如果用户输入了一个非常长的任务,会发生什么?让我们添加那个 story 来找出答案。
const longTitleString = `This task's name is absurdly large. In fact, I think if I keep going I might end up with content overflow. What will happen? The star that represents a pinned task could have text overlapping. The text could cut-off abruptly when it reaches the star. I hope not!`;
export const LongTitle = {
args: {
task: {
id: '4',
title: longTitleString,
state: 'TASK_INBOX',
},
},
};
现在我们已经验证了每种测试用例的外观,我们可以进入下一步。自动捕获回归。但首先,提交您的更改。
4. 自动捕获回归
Task 组件在其所有用例中看起来都符合我们的预期。但是,我们如何确保一行 CSS 不会在将来破坏它?每次进行更改时,手动遍历整个组件目录是不现实的。
这就是为什么开发人员使用视觉回归测试工具来自动检查回归。Auth0、Twilio、Adobe 和 Peloton 使用 Chromatic(由 Storybook 团队构建)。
此时,我们知道组件处于良好状态。Chromatic 将捕获每个 story 的图像快照——正如它在浏览器中显示的那样。然后,任何时候您进行更改,都会捕获新的快照并与之前的快照进行比较。然后,您审查发现的任何视觉差异,以决定它们是故意的更新还是意外的 bug。

设置 Chromatic
登录并创建一个新项目并获取您的项目令牌。
Chromatic 是专门为 Storybook 构建的,无需配置。运行以下命令将触发它捕获每个 story 的快照(使用云浏览器)。
npx chromatic --project-token=<project-token>
第一次运行将设置为基线,即起点。每个 story 都有自己的基线。

运行测试
每次提交时,都会捕获新的快照并与现有基线进行比较,以检测 UI 更改。让我们看看这个检查是如何工作的。
首先,对 UI 进行一些 T.。我们将更改图钉图标和文本样式。更新 Task 组件,然后进行 T.并重新运行 Chromatic。
import PropTypes from 'prop-types';
export default function Task({
task: { id, title, state },
onArchiveTask,
onTogglePinTask,
onEditTitle,
}) {
return (
<div
className={`list-item ${state}`}
role="listitem"
aria-label={`task-${id}`}
>
<label
htmlFor="checked"
aria-label={`archiveTask-${id}`}
className="checkbox"
>
<input
type="checkbox"
disabled={true}
name="checked"
id={`archiveTask-${id}`}
checked={state === "TASK_ARCHIVED"}
/>
<span
className="checkbox-custom"
onClick={() => onArchiveTask("ARCHIVE_TASK", id)}
role="button"
aria-label={`archiveButton-${id}`}
/>
</label>
<label htmlFor="title" aria-label={title} className="title">
<input
type="text"
value={title}
name="title"
placeholder="Input title"
style={{ textOverflow: "ellipsis" }}
onChange={(e) => onEditTitle(e.target.value, id)}
/>
</label>
{state !== "TASK_ARCHIVED" && (
<button
className="pin-button"
onClick={() => onTogglePinTask(state, id)}
id={`pinTask-${id}`}
aria-label={state === "TASK_PINNED" ? "unpin" : "pin"}
key={`pinTask-${id}`}
>
+ <span className={`icon-star`} />
</button>
)}
</div>
);
}
Task.propTypes = {
/** Composition of the task */
task: PropTypes.shape({
/** Id of the task */
id: PropTypes.string.isRequired,
/** Title of the task */
title: PropTypes.string.isRequired,
/** Current state of the task */
state: PropTypes.string.isRequired,
}),
/** Event to change the task to archived */
onArchiveTask: PropTypes.func.isRequired,
/** Event to change the task to pinned */
onTogglePinTask: PropTypes.func.isRequired,
/** Event to change the task title */
onEditTitle: PropTypes.func.isRequired,
};
您现在将看到一个 diff。
回归测试确保我们不会意外引入更改。但最终还是由您决定这些更改是否是故意的。
✅ 如果更改是故意的,请按“接受”。新快照现在将设置为基线。
❌ 如果更改是无意的,请按“拒绝”。构建将失败。修复代码并再次运行 Chromatic。
在我们的例子中,这些更改是故意的。请继续为所有 stories 点击“接受”。整个工作流程如下所示。

防止一个 bug 演变成多个 bug
一点点泄露的 CSS 或一个损坏的组件可能会滚雪球般地导致多个问题。这些 bug 特别难以调试。在下一章中,我们将在此基础上学习如何捕获此类级联问题。