
Storybook 中的视觉测试
了解如何自动精确定位 UI bug

上个月,超过14,000名开发者阅读了《视觉测试手册》。 这是 Storybook 维护者关于检测 UI 外观错误的详尽指南。
对视觉测试日益增长的兴趣源于编写无错误 UI 的难度。过去,开发者使用单元测试和快照测试来扫描 HTML 块中的错误。但这些方法无法准确反映用户实际看到的内容,因此错误从未消失。
视觉测试通过在真实浏览器中捕获和比较图像快照来发现错误。它使您能够自动化检查 UI 外观是否正确的流程。
《视觉测试手册》是关于测试 UI 外观的深度指南——汇集了 BBC、Adobe、Target 等领先工程团队的经验。本文总结了指南中概述的核心工具和技术。
什么是视觉错误?
视觉错误无处不在。元素被截断。颜色或字体样式不正确。布局损坏。以及缺失的错误状态。
如今,每家公司都是软件公司。这意味着每家公司都必须维护 UI。但如果您和我一样,您可能会注意到,公司似乎总是人手不足,无法时刻监视其 UI 的每一个部分。

视觉错误是指 UI 外观中导致其看起来不值得信赖的无意错误。它们是容易被肉眼发现但通用测试方法却无法捕获的回归。
大多数测试旨在验证逻辑。这很合理。您运行一个函数,获取其输出,然后检查它是否正确。计算机非常擅长验证数据。但外观呢?
嗯,这个问题分为两个层面。
1. 外观是否正确?
以这个 Task 组件为例。它根据不同的状态显示不同的外观。我们显示一个已选中(或未选中)的复选框,有关任务的一些信息,以及一个置顶按钮。当然,还有所有相关的样式。

第一个挑战是验证组件在所有这些场景下的外观。这需要大量调整 props 和 state 来设置和测试每种情况。哦,而且计算机实际上无法告诉您它是否符合规范。您,开发者,必须通过视觉检查。
2. 它仍然看起来正确吗?
您第一次就做得很好。它在所有状态下看起来都不错。但随着开发的自然进行,总会发生变化。错误不可避免地会悄悄溜进来。这对界面来说尤其如此。一个微小的 CSS 调整可能会破坏一个组件或其某个状态。
每次进行更改时,您都无法手动检查 UI 的全部范围。您需要更自动化的东西。
视觉测试
视觉测试允许您通过一个统一的工作流程来处理这两个任务。它是您在构建组件时验证其外观的过程。并在您迭代以发布新功能时再次进行。
以下是视觉测试工作流程的样子
- 🏷 隔离组件。使用Storybook一次专注于测试一个组件。
- ✍🏽 写出测试用例。使用 props 和模拟数据重现每种状态。
- 🔍 手动验证每个测试用例的外观。
- 📸 自动捕获 UI 错误。捕获每个测试用例的快照,并使用基于计算机的差异检查来检测回归。
视觉测试的核心是将 UI 与应用程序的其他部分(数据、后端、API)隔离开来。这样您就可以单独观察每种状态。然后,您可以手动进行抽查并自动进行回归测试。
教程
让我们详细了解每个步骤。但是,我们需要测试一些东西。我将使用Taskbox 应用程序作为示例。这是一个类似于 Asana 的任务管理应用程序。
在此处获取代码以进行跟进:https://github.com/chromaui/ui-testing-guide-code
请注意,实现细节并不重要,因为我们更关注如何测试这个 UI。我们在这里使用 React,但请放心,这些测试概念适用于所有基于组件的框架。

1. 隔离组件
通过一次测试一个组件并为每种状态编写测试用例,可以更容易地查明错误。传统方法是在组件首次使用的应用程序页面上构建组件。这使得模拟和验证所有这些状态变得困难。有更好的方法——Storybook。
Storybook 是行业标准的独立构建组件工具。Twitter、Slack、Airbnb、Shopify、Stripe 和 Microsoft 都在使用它。它被打包成一个小型独立工具,与您的应用程序并行运行,为您提供
- 📥 一个沙箱,用于独立渲染每个组件
- 🔭 将其所有状态可视化为故事
- 📑文档记录每个组件的 props 和使用指南
- 🗃️ 一个包含您所有组件的目录,以便于查找
让我们回到那个 Task 组件。隔离它意味着我们加载并单独渲染这个组件。为此,我们需要 Storybook。

设置 Storybook
我共享的存储库已预先配置为使用 Storybook。但是,在其他情况下,您可以在项目根目录下运行此命令来设置 Storybook。
npx sb initStorybook CLI 会尝试找出您正在使用的前端框架,并在此基础上
- 📦 添加默认配置并安装所需的依赖项
- 🛠 设置运行 Storybook 所需的脚本
- 📝 添加样板故事以帮助您入门
然后,我们为 Task 组件创建一个故事文件。这会将组件注册到 Storybook,并添加一个默认的测试用例。
// src/components/Task.stories.js
import React from 'react';
import { Task } from './Task';
export default {
component: Task,
title: 'Task',
argTypes: {
onArchiveTask: { action: 'onArchiveTask' },
onTogglePinTask: { action: 'onTogglePinTask' },
onEditTitle: { action: 'onEditTitle' },
},
};
const Template = (args) => <Task {...args} />;
export const Default = Template.bind({});
Default.args = {
task: {
id: '1',
title: 'Buy milk',
state: 'TASK_INBOX',
},
};
最后,运行以下命令以在开发模式下启动 Storybook。您应该会看到 Task 组件加载。
yarn storybook
我们现在准备好编写测试用例了。
2. 编写测试用例
在 Storybook 中,测试用例被称为故事。故事捕获组件的特定状态——在浏览器中实际渲染的状态。
Task 组件有三种状态——默认、置顶和已归档。我们将为每种状态添加一个故事。

// src/components/Task.stories.js
import React from 'react';
import { Task } from './Task';
export default {
component: Task,
title: 'Task',
argTypes: {
onArchiveTask: { action: 'onArchiveTask' },
onTogglePinTask: { action: 'onTogglePinTask' },
onEditTitle: { action: 'onEditTitle' },
},
};
const Template = (args) => <Task {...args} />;
export const Default = Template.bind({});
Default.args = {
task: {
id: '1',
title: 'Buy milk',
state: 'TASK_INBOX',
},
};
export const Pinned = Template.bind({});
Pinned.args = {
task: {
id: '2',
title: 'QA dropdown',
state: 'TASK_PINNED',
},
};
export const Archived = Template.bind({});
Archived.args = {
task: {
id: '3',
title: 'Write schema for account menu',
state: 'TASK_ARCHIVED',
},
};
3. 验证
验证是您在 Storybook 中评估组件外观的过程。也就是说,它是否符合设计规范。
通常的开发工作流程是
- 编辑代码
- 使组件处于适当的状态
- 评估其外观
然后重复整个周期,直到您验证了所有状态。
通过为每种状态编写故事,您可以省略第二个步骤。您可以直接从编辑代码转到验证所有测试用例。因此,极大地加快了整个过程。
编写故事还可以揭示您在以更随意的、临时的方式进行开发时可能没有考虑到的场景。例如,如果用户输入一个非常长的任务会发生什么?让我们添加那个故事来找出答案。
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 = Template.bind({});
LongTitle.args = {
task: {
id: '4',
title: longTitleString,
state: 'TASK_INBOX',
},
};4. 自动捕获回归
Task 组件在其所有用例中都如我们所料。但是,我们如何确保一行意外的 CSS 不会在将来破坏它呢?每次进行更改时,手动浏览整个组件目录是不现实的。
这就是为什么开发者使用视觉回归测试工具来自动检查回归的原因。Auth0、Twilio、Adobe 和 Peloton 使用 Storybook 团队的Chromatic。
此时,我们知道该组件处于良好状态。Chromatic 将捕获浏览器中每个故事的图像快照。然后,每当您进行更改时,都会捕获新的快照并将其与之前的快照进行比较。然后,您将审查找到的所有视觉差异,以确定它们是故意的更新还是意外的错误。
设置 Chromatic
登录并创建一个新项目并获取您的项目 token。
Chromatic 是专门为 Storybook 构建的,无需配置。运行下面的命令将触发它在(使用云浏览器)捕获每个故事的快照。
npx chromatic --project-token=<project-token>第一次运行将被设置为基线,即起点。每个故事都有自己的基线。

运行测试
在每次提交时,都会捕获新的快照并与现有基线进行比较,以检测 UI 更改。让我们看看这个检查是如何进行的。
首先,对 UI 进行一些调整。我们将更改置顶图标和文本样式。更新 Task 组件,然后进行提交并重新运行 Chromatic。
// src/components/Task.js
import React from 'react';
import PropTypes from 'prop-types';
import {
Checkbox,
Flex,
IconButton,
Input,
Box,
VisuallyHidden,
} from '@chakra-ui/react';
import { StarIcon } from '@chakra-ui/icons';
export const Task = ({
task: { id, title, state },
onArchiveTask,
onTogglePinTask,
onEditTitle,
...props
}) => (
<Flex
as="li"
_notLast={{
borderBottom: '1px',
borderColor: 'gray.200',
}}
h={12}
bg="white"
alignItems="center"
_hover={{
bgGradient: 'linear(to-b, brand.100, brand.50)',
}}
aria-label={title}
tabIndex="0"
{...props}
>
<Checkbox
px={4}
isChecked={state === 'TASK_ARCHIVED'}
onChange={(e) => onArchiveTask(e.target.checked, id)}
>
<VisuallyHidden>Archive</VisuallyHidden>
</Checkbox>
<Box width="full" as="label">
<VisuallyHidden>Edit</VisuallyHidden>
<Input
variant="unstyled"
flex="1 1 auto"
color={state === 'TASK_ARCHIVED' ? 'gray.400' : 'gray.700'}
textDecoration={state === 'TASK_ARCHIVED' ? 'line-through' : 'none'}
fontSize="sm"
isTruncated
value={title}
onChange={(e) => onEditTitle(e.target.value, id)}
/>
</Box>
<IconButton
p={5}
flex="none"
aria-label={state === 'TASK_PINNED' ? 'unpin' : 'pin'}
variant={state === 'TASK_PINNED' ? 'unpin' : 'pin'}
icon={<StarIcon />}
onClick={() => onTogglePinTask(state, id)}
/>
</Flex>
);
Task.propTypes = {
task: PropTypes.shape({
id: PropTypes.string.isRequired,
title: PropTypes.string.isRequired,
state: PropTypes.string.isRequired,
}),
onArchiveTask: PropTypes.func.isRequired,
onTogglePinTask: PropTypes.func.isRequired,
onEditTitle: PropTypes.func.isRequired,
};现在您将看到一个 diff。

回归测试确保我们不会意外引入更改。但您仍然需要决定更改是故意的还是故意的。
✅ 如果更改是故意的,请按“接受”。新快照现在将被设置为基线。
❌ 如果更改是无意的,请按“拒绝”。构建将失败。修复代码并再次运行 Chromatic。
在我们的例子中,更改是故意的。继续并为所有故事单击“接受”。
整个工作流程下图所示。该手册更详细地介绍了如何使用Github Actions在每次提交时触发此工作流程。

结论
视觉错误很容易引入——泄露的 CSS 或一个损坏的组件会级联导致多个问题。它们特别难以调试,而且很少被常见的测试方法捕获。
视觉测试的价值很高,而且维护成本非常低。与其他形式的测试不同,它们会评估组件的实际渲染输出。这样您就可以确保 UI 在首次构建时就如您所愿——并且在后续更新过程中不会出现任何无意的更改。有关此主题的更深入探讨,请阅读完整指南:《视觉测试手册》。
如何真正测试 UI
外观只是 UI 的众多特征之一。在下一篇文章中,我将在此基础上探讨组合测试。您将学习如何验证多个组件是否作为一个系统协同工作。订阅邮件列表,以便在更多 UI 测试文章发布时收到通知。