返回UI 测试手册
React
  • 简介
  • 可视化
  • 组合
  • 交互
  • 可访问性
  • 用户流程
  • 自动化
  • 工作流程
  • 结论

Storybook 中的可视化测试

学习如何自动找出 UI 错误

发布无 bug 的 UI 很难。过去,开发者使用单元测试和快照测试来扫描 HTML 大块中的 bug。但这些方法无法代表用户实际看到的内容,因此 bug 总是难以根除。

可视化测试通过在真实浏览器中捕获并比较图像快照来捕获 bug。它允许你自动化检查 UI 外观是否正确的流程。

什么是可视化 bug?

可视化 bug 无处不在。元素被截断。颜色或字体样式错误。布局破损。以及缺少错误状态。

现在每家公司都是软件公司。这意味着每家公司都有责任维护 UI。但如果你像我一样,你可能注意到公司似乎总没有足够的人手一直监控 UI 的每个部分。

可视化 bug 是你的 UI 外观中无意的错误,这些错误让 UI 看起来不可靠。它们是很容易用肉眼发现但普通测试方法无法捕获的回归问题。

大多数测试旨在验证逻辑,这很有道理:你运行一个函数,获取其输出并检查是否正确。计算机非常擅长验证数据。但外观呢?

嗯,这个问题有两个层面。

1. 外观是否正确?

例如,以这个 Task 组件为例。它的外观会根据其所处的状态而有所不同。我们展示一个勾选(或未勾选)的复选框、一些关于任务的信息以及一个图钉按钮。当然,还有所有相关的样式。

Different states of a task component

第一个挑战是验证组件在所有这些场景下的外观。这需要大量调整 props 和 state 来设置和测试每种情况。而且,计算机无法真正告诉你它是否符合设计规范。你,作为开发者,必须亲自检查其外观。

2. 它仍然外观正确吗?

你第一次构建时是对的。它在所有状态下看起来都很好。但随着开发的自然进展,更改会发生。Bug 不可避免地会悄悄出现。对于界面来说尤其如此。一个微小的 CSS 调整就可能破坏一个组件或其某个状态。

每次进行更改时,你无法手动检查整个 UI 的广度。你需要更自动化的东西。

可视化测试

可视化测试允许你通过一个统一的工作流程来处理这两个任务。它是在构建组件时验证其外观的过程。以及在你迭代发布新功能时再次验证。

可视化测试工作流程如下所示

  1. 🏷 隔离组件。使用Storybook一次专注于并测试一个组件。
  2. ✍🏽 写出测试用例。使用 props 和模拟数据来重现每种状态。
  3. 🔍 手动验证每个测试用例的外观。
  4. 📸 自动捕获 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 测试用例。

复制
src/components/Task.stories.jsx
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。

复制
src/components/Task.stories.jsx
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 中的外观。也就是说,它是否符合设计规范?

通常的开发工作流程是

  1. 编辑代码
  2. 将组件设置为适当的 state
  3. 评估其外观

然后重复整个循环,直到你验证了所有 states。

通过为每个 state 编写一个 story,你就可以省去第二步。你可以直接从编辑代码转到验证所有测试用例。因此,极大地加快了整个流程。

编写 stories 还能揭示那些如果你以更随意(ad-hoc)的方式开发时可能不会考虑到的场景。例如,如果用户输入一个非常长的任务会发生什么?让我们添加那个 story 来看看。

复制
src/components/Task.stories.jsx
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。

复制
src/components/Task.jsx
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。整个工作流程如下图所示。

Build in storybook and run visual tests with Chromatic. If changes look good, then merge your PR.

防止一个 bug 蔓延成多个

一点点不合适的 CSS 或一个损坏的组件可能会像滚雪球一样演变成多个问题。这些 bug 特别难以调试。在下一章中,我们将基于这些概念学习如何捕获此类级联问题。

使你的代码与本章保持同步。在 GitHub 上查看 6a337af。
这份免费指南对你有帮助吗?发推文点赞,帮助其他开发者找到它。
下一章
组合
防止微小更改演变成主要回归问题
✍️ 在 GitHub 上编辑 – 欢迎 PR!
加入社区
6,975开发者,并且仍在增加
为什么为什么选择 Storybook组件驱动的 UI
开源软件
Storybook - Storybook 中文

特别鸣谢 Netlify CircleCI