Storybook 中的视觉测试
交付无错误的 UI 非常困难。过去,开发人员使用单元测试和快照测试来扫描 HTML 代码块中的错误。但是这些方法无法代表用户实际看到的内容,因此错误始终无法消除。
视觉测试通过捕获和比较真实浏览器中的图像快照来捕获错误。它允许您自动化检查 UI 外观是否正确的过程。
什么是视觉错误?
视觉错误无处不在。元素被截断。颜色或字体样式不正确。布局损坏。以及缺少错误状态。
现在每家公司都是软件公司。这意味着每家公司都有责任维护 UI。但是,如果您像我一样,您可能已经注意到,公司似乎永远没有足够的人员来始终监控其 UI 的每个部分。
视觉错误是 UI 外观中无意的错误,使其看起来不可靠。它们是易于用肉眼观察到,但常见测试方法无法捕获的回归。
大多数测试旨在验证逻辑,这是有道理的:您运行一个函数,获取其输出并检查其是否正确。计算机非常擅长验证数据。但是,事物的外观又如何呢?
嗯,这个问题有两个层面。
1. 它看起来是否正确?
以这个 Task 组件为例。它的外观因其所处的状态而异。我们显示一个选中(或未选中)的复选框、有关任务的一些信息以及一个固定按钮。当然,还有所有相关的样式。
第一个挑战只是验证组件在所有这些场景中的外观。这需要大量摆弄 props 和 state 来设置和测试每种情况。哦,而且计算机实际上无法告诉您它是否与规范匹配。您,开发人员,必须目视检查它。
2. 它仍然看起来是否正确?
您第一次构建它是正确的。它在所有状态下都看起来不错。但是,更改会在自然开发过程中发生。错误不可避免地会潜入。对于界面尤其如此。细微的 CSS 调整可能会破坏组件或其状态之一。
您无法在每次进行更改时手动检查 UI 的广度。您需要更自动化的东西。
视觉测试
视觉测试允许您使用一个统一的工作流程来处理这两个任务。它是验证组件外观的过程,无论是在您构建它时,还是在您迭代以交付新功能时。
以下是视觉测试工作流程的样子
- 🏷 隔离组件。使用 Storybook 专注于并一次测试一个组件。
- ✍🏽 写出测试用例。 每个状态都使用 props 和模拟数据再现。
- 🔍 手动验证每个测试用例的外观。
- 📸 自动捕获 UI 错误。 捕获每个测试用例的快照,并使用基于机器的差异比较来检查回归。
视觉测试的关键是将 UI 与应用程序的其余部分(数据、后端、API)隔离。这使您可以单独观察每个状态。然后,您可以手动抽查并自动回归测试这些状态。
让我们详细了解每个步骤。
1. 隔离组件
通过一次测试一个组件并为其每个状态编写测试用例,可以更容易地查明错误。传统方法是在首次使用组件的应用程序页面上构建组件。这使得模拟和验证所有这些状态变得困难。有一种更好的方法——Storybook。
Storybook 是行业标准的用于隔离构建组件的工具。Twitter、Slack、Airbnb、Shopify、Stripe 和 Microsoft 都在使用它。它被打包为一个小型独立工具,与您的应用程序并存,为您提供
- 📥 一个 沙箱,用于隔离渲染每个组件
- 🔭 将其所有状态可视化为故事
- 📑 为每个组件记录 props 和使用指南
- 🗃️ 所有组件的目录,使发现更容易
让我们回到 Task 组件。隔离它意味着我们单独加载和渲染这一个组件。为此,我们需要 Storybook。
设置 Storybook
我们的项目已预先配置为使用 Storybook。配置位于 .storybook
文件夹中,所有必要的脚本都已添加到 package.json
中。
我们可以从为 Task 组件创建一个故事文件开始。这会将组件注册到 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 中,测试用例称为故事。一个故事捕获组件的特定状态——浏览器中实际渲染的状态。
Task 组件有三种状态——默认、固定和已存档。我们将为每种状态添加一个故事。
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 中的外观。也就是说,它是否符合设计规范?
通常的开发工作流程是
- 编辑代码
- 使组件处于适当状态
- 评估其外观
然后重复整个周期,直到您验证了其所有状态。
通过为每个状态编写一个故事,您可以省去第二个步骤。您可以直接从编辑代码到验证所有测试用例。因此,大大加快了整个过程。
写出故事还会浮现出您以更临时的方式开发时不会考虑到的场景。例如,如果用户输入一个非常长的任务会发生什么?让我们添加该故事以找出答案。
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 将捕获每个故事的图像快照——就像它在浏览器中显示的那样。然后,每当您进行更改时,都会捕获新的快照并将其与之前的快照进行比较。然后,您审查发现的任何视觉差异,以确定它们是预期的更新还是意外的错误。
设置 Chromatic
登录并创建一个新项目并获取您的项目令牌。
Chromatic 专为 Storybook 构建,无需任何配置。运行以下命令将触发它捕获每个故事的快照(使用云浏览器)。
npx chromatic --project-token=<project-token>
首次运行将设置为基线,即起点。并且每个故事都有其自己的基线。
运行测试
在每次提交时,都会捕获新的快照并将其与现有基线进行比较,以检测 UI 更改。让我们看看正在运行的检查。
首先,对 UI 进行调整。我们将更改固定图标和文本样式。更新 Task 组件,然后提交并重新运行 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,
};
现在您将看到差异。
回归测试确保我们不会意外引入更改。但这仍然取决于您来决定更改是否是故意的。
✅ 如果更改是故意的,请按接受。新快照现在将设置为基线。
❌ 如果更改是无意的,请按拒绝。构建将失败。修复代码并再次运行 Chromatic。
在我们的例子中,更改是故意的。继续并单击接受所有故事。整个工作流程如下图所示。
阻止一个错误演变成多个错误
一点泄露的 CSS 或一个损坏的组件可能会滚雪球般变成多个问题。这些错误尤其令人沮丧地难以调试。在下一章中,我们将基于这些概念学习如何捕获此类级联问题。