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 和模拟数据来重现每种状态。
- 🔍 手动验证每个测试用例的外观。
- 📸 自动捕获 UI bug。捕获每个测试用例的快照,并使用基于机器的差异对比来检查回归问题。
可视化测试的关键是将 UI 与应用程序的其余部分(数据、后端、API)隔离开来。这使你能够单独观察每种状态。然后你可以手动抽查并自动对这些状态进行回归测试。
让我们详细讲解每个步骤。
1. 隔离组件
通过一次测试一个组件并为其每个状态编写测试用例,更容易找出 bug。传统方法是在组件首次使用的应用页面上构建它。这使得模拟和验证所有这些状态变得困难。有更好的方法——Storybook。
Storybook 是构建隔离组件的行业标准。Twitter、Slack、Airbnb、Shopify、Stripe 和 Microsoft 都在使用它。它作为一个小型独立工具与你的应用一同存在,为你提供
- 📥 用于隔离渲染每个组件的沙箱
- 🔭 将其所有状态可视化为 stories
- 📑 为每个组件记录 props 和使用指南
- 🗃️ 包含你所有组件的目录,以便于查找
让我们回到那个 Task 组件。隔离它意味着我们单独加载和渲染这个组件。为此,我们需要 Storybook。
设置 Storybook
我们的项目已预先配置为使用 Storybook。config 位于 .storybook
文件夹中,所有必要的 scripts 都已添加到 package.json
。
我们可以先为 Task 组件创建一个 story 文件。这将把该组件注册到 Storybook 并添加一个 default 测试用例。
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',
},
},
};
最后,运行以下命令以在 development 模式下启动 Storybook。你应该能看到 Task 组件加载出来。
yarn storybook
现在我们准备好编写测试用例了。
2. 编写测试用例
在 Storybook 中,测试用例被称为 stories。一个 story 捕获组件的特定 state——即在浏览器中实际渲染的 state。
Task 组件有三种 states——default、pinned 和 archived。我们将为每一种添加一个 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 中的外观。也就是说,它是否符合设计规范?
通常的开发工作流程是
- 编辑代码
- 将组件设置为适当的 state
- 评估其外观
然后重复整个循环,直到你验证了所有 states。
通过为每个 state 编写一个 story,你就可以省去第二步。你可以直接从编辑代码转到验证所有测试用例。因此,极大地加快了整个流程。
编写 stories 还能揭示那些如果你以更随意(ad-hoc)的方式开发时可能不会考虑到的场景。例如,如果用户输入一个非常长的任务会发生什么?让我们添加那个 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
登录并创建一个新项目,然后获取你的 project-token。
Chromatic 专为 Storybook 构建,无需配置。运行下面的命令会触发它捕获每个 story 的快照(使用云端浏览器)。
npx chromatic --project-token=<project-token>
首次运行将被设置为 baseline,即起点。每个 story 都有自己的 baseline。
运行测试
每次 commit 时,都会捕获新的 snapshots 并与现有的 baselines 进行比较,以检测 UI 变化。让我们看看这个检查的实际效果。
首先,对 UI 进行微调。我们将更改 pinned 图标和文本样式。更新 Task 组件,然后进行 commit 并重新运行 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)。
回归测试确保我们不会意外引入更改。但仍然由你来决定这些更改是故意的还是非故意的。
✅ 如果更改是故意的,请按 accept。新的快照现在将被设置为 baseline。
❌ 如果更改是非故意的,请按 deny。构建将失败。修复代码并再次运行 Chromatic。
在我们的例子中,更改是故意的。继续点击 accept 接受所有 stories。整个工作流程如下图所示。
防止一个 bug 蔓延成多个
一点点不合适的 CSS 或一个损坏的组件可能会像滚雪球一样演变成多个问题。这些 bug 特别难以调试。在下一章中,我们将基于这些概念学习如何捕获此类级联问题。