
测试复合组件
防止微小更改演变成重大回退

特斯拉召回了 158,000 辆汽车,原因是一个模块(显示屏)发生故障。显示控制台损坏后,你无法使用倒车摄像头、转向灯或驾驶辅助系统。这会显著增加碰撞风险。
一个有缺陷的模块升级成了一个重大故障。
界面也面临类似的挑战,因为应用程序就像汽车一样,是一个相互连接的部件网络。一个组件中的错误会影响其周围的所有其他组件。更不用说应用程序中使用它的每一个部分。测试 UI 组件是如何组成的有助于你防止这类错误。
在上一篇文章中,我们学习了如何使用 Storybook 构建隔离的组件、编写可视化测试 以及使用 Chromatic 自动捕获回退。然而,测试 UI 中更复杂的部分很棘手。它们是通过组合许多简单组件创建的,并且还与应用程序状态连接。
本文教你如何隔离复合组件并对其应用可视化测试。在此过程中,你将学习如何模拟数据和模拟应用程序逻辑,以及如何测试组件集成。
小错误最终会破坏应用程序
应用程序是通过将组件相互连接来构建的。这意味着一个元素中的错误可能会影响其相邻元素。例如,重命名一个 prop 会中断从父组件到子组件的数据流。或者 UI 元素中不正确的 CSS 通常会导致布局损坏。


考虑Storybook 的设计系统中的 Button 组件。它在多个页面中使用了无数次。Button 中的错误会无意中导致所有这些页面中的错误。换句话说,一个故障会指数级地累积。当你沿着组件层级向页面级别移动时,这些错误的影响会增加。因此,我们需要一种方法来及早捕获此类级联问题并找出根本原因。

组合测试
可视化测试通过捕获并比较 Story(在真实浏览器中)的图像快照来捕获错误。这使得它们非常适合发现 UI 更改并找出根本原因。以下是流程的快速回顾
- 🏷 隔离组件。使用 Storybook 一次测试一个组件。
- ✍🏽 写出测试用例。使用 props 重现每个组件状态。
- 🔍 手动验证每个测试用例的外观。
- 📸 使用视觉回退测试自动捕获错误。
组合测试就是在层级结构中更高层的、由多个简单组件组成的“复合”组件上运行可视化测试。这样你就可以量化任何更改可能对整个应用程序产生的影响。并确保系统作为一个整体正常工作。
关键区别在于复合组件会跟踪应用程序状态并将行为沿树向下传递。在编写测试用例时,你必须考虑这些因素。
让我们看看这个过程的实际应用。我们将使用我在第 2 部分中介绍的任务框应用程序。获取代码并跟着做。我们的起点是 visual-testing
分支。
教程
TaskList
显示属于用户的完整任务列表。它将置顶的任务移到列表顶部。并且有一个加载状态和空状态。我们将从为所有这些场景编写 Story 开始。

创建一个 story 文件,注册 TaskList
组件,并添加一个默认情况下的 story。
// TaskList.stories.js
import React from 'react';
import { TaskList } from './TaskList';
import Task from './Task.stories';
export default {
component: TaskList,
title: 'TaskList',
argTypes: {
...Task.argTypes,
},
};
const Template = (args) => <TaskList {...args} />;
export const Default = Template.bind({});
Default.args = {
tasks: [
{ id: '1', state: 'TASK_INBOX', title: 'Build a date picker' },
{ id: '2', state: 'TASK_INBOX', title: 'QA dropdown' },
{
id: '3',
state: 'TASK_INBOX',
title: 'Write a schema for account avatar component',
},
{ id: '4', state: 'TASK_INBOX', title: 'Export logo' },
{ id: '5', state: 'TASK_INBOX', title: 'Fix bug in input error state' },
{ id: '6', state: 'TASK_INBOX', title: 'Draft monthly blog to customers' },
],
};
注意 argTypes
。Args 是 Storybook 定义 Story 输入的机制。可以把它们看作与框架无关的 props。在组件级别定义的 Args 会自动传递到每个 Story。在我们的案例中,我们使用Actions 插件定义了三个事件处理程序。
这些模拟的 action 将在你与 TaskList
交互时显示在插件面板中。让你验证组件是否连接正确。

组合 Args
就像你组合组件创建新的 UI 一样,你也可以组合 Args 创建新的 Story。通常,复合组件的 Args 甚至会组合其子组件的 Args。
事件处理程序 Args 已在 Task stories 文件中定义,我们可以重用它们。类似地,我们也可以使用默认 Story 的 Args 来创建置顶任务的 story。
// TaskList.stories.js
import React from 'react';
import { TaskList } from './TaskList';
import Task from './Task.stories';
export default {
component: TaskList,
title: 'TaskList',
argTypes: {
...Task.argTypes,
},
};
const Template = (args) => <TaskList {...args} />;
export const Default = Template.bind({});
Default.args = {
tasks: [
{ id: '1', state: 'TASK_INBOX', title: 'Build a date picker' },
{ id: '2', state: 'TASK_INBOX', title: 'QA dropdown' },
{
id: '3',
state: 'TASK_INBOX',
title: 'Write a schema for account avatar component',
},
{ id: '4', state: 'TASK_INBOX', title: 'Export logo' },
{ id: '5', state: 'TASK_INBOX', title: 'Fix bug in input error state' },
{ id: '6', state: 'TASK_INBOX', title: 'Draft monthly blog to customers' },
],
};
export const WithPinnedTasks = Template.bind({});
WithPinnedTasks.args = {
tasks: [
{ id: '6', title: 'Draft monthly blog to customers', state: 'TASK_PINNED' },
...Default.args.tasks.slice(0, 5),
],
};
export const Loading = Template.bind({});
Loading.args = {
tasks: [],
loading: true,
};
export const Empty = Template.bind({});
Empty.args = {
...Loading.args,
loading: false,
};
通过组合 Args 来塑造 Story 是一种强大的技术。它使我们无需重复一遍又一遍地编写相同的 props。更重要的是,它测试了组件集成。如果你重命名 Task
组件的一个 prop,这将导致 TaskList
的测试用例失败。

到目前为止,我们只处理了通过 props 接受数据和回调的组件。当你的组件连接到 API 或具有内部状态时,事情会变得更棘手。接下来我们将看看如何隔离和测试这些连接的组件。
有状态的复合组件
InboxScreen
使用自定义 Hook 从 Taskbox API 获取数据并管理应用程序状态。就像单元测试一样,我们希望将组件与真实后端分离,并隔离测试功能。

这就是 Storybook 插件的作用。它们允许你模拟 API 请求、状态、上下文、 Provider 以及组件依赖的任何其他东西。卫报和 Sidewalk Labs (Google) 的团队使用它们来隔离构建整个页面。
对于 InboxScreen,我们将使用Mock Service Worker (MSW) 来在网络级别拦截请求并返回模拟响应。
安装 msw 及其 storybook 插件。
yarn add -D msw msw-storybook-addon
然后,在你的 public 文件夹中生成一个新的 service worker。
npx msw init public/
通过将此内容添加到你的 ./storybook/preview.js
文件中来在 Storybook 中启用 MSW 插件
import { addDecorator } from '@storybook/react';
import { initialize, mswDecorator } from 'msw-storybook-addon';
initialize();
addDecorator(mswDecorator);
最后,重启 yarn storybook
命令。这样我们就可以在 Story 中模拟 API 请求了。
InboxScreen
调用 useTasks
Hook,该 Hook 又从 /tasks
端点获取数据。我们可以使用 msw
参数指定模拟响应。注意你可以为每个 Story 返回不同的响应。
// InboxScreen.stories.js
import React from 'react';
import { rest } from 'msw';
import { InboxScreen } from './InboxScreen';
import { Default as TaskListDefault } from './components/TaskList.stories';
export default {
component: InboxScreen,
title: 'InboxScreen',
};
const Template = (args) => <InboxScreen {...args} />;
export const Default = Template.bind({});
Default.parameters = {
msw: [
rest.get('/tasks', (req, res, ctx) => {
return res(ctx.json(TaskListDefault.args));
}),
],
};
export const Error = Template.bind({});
Error.args = {
error: 'Something',
};
Error.parameters = {
msw: [
rest.get('/tasks', (req, res, ctx) => {
return res(ctx.json([]));
}),
],
};

状态有多种不同的形式。有些应用程序使用 Redux 和 MobX 等库全局跟踪部分状态。或者通过进行 GraphQL 查询。或者它们可能使用容器组件。Storybook 非常灵活,足以支持所有这些场景。有关更多信息,请参见:用于管理数据和状态的 Storybook 插件。
隔离构建组件可以减少开发的复杂性。你无需启动后端、以用户身份登录并在 UI 中点击来调试一些 CSS。你可以将其全部设置为一个 Story 并开始工作。你甚至可以对这些 Story 运行自动化回退测试。
捕获回退
在我早期的可视化测试文章中,我们花了一些时间设置 Chromatic 并回顾了基本工作流程。Chromatic 会捕获每个 Story 的快照,并将其与现有基线进行比较。你会看到一个可视化差异,你可以选择批准或拒绝。
现在我们有了所有复合组件的 Story,我们可以通过运行以下命令执行可视化测试:
npx chromatic --project-token=<project-token>
你应该会看到一个差异,其中包括 TaskList 和 InboxScreen 的 Story。

现在尝试更改 Task 组件中的某些内容,例如字体大小或背景颜色。然后进行提交并重新运行 Chromatic。

应用程序的树状特性意味着对 Task 组件的任何调整也会被更高级别组件的测试捕获。测试复合组件使你能够在部署到生产环境之前捕获错误。
结论
考虑到现代应用程序的规模,开发人员无法了解组件在所有不同位置的使用情况。因此,你经常会意外地发布错误。这会让你陷入困境——在生产环境中修复这些错误需要5-10 倍的时间。组合测试使我们能够了解微小更改对更大系统的潜在影响。你可以在错误滚雪球般变成重大回退之前捕获它们。
接下来,我们将深入探讨交互测试。当用户勾选任务时,如何确保触发了合适的事件并且状态正确更新?加入邮件列表,以便在更多 UI 测试文章发布时收到通知。
阻止错误的连锁反应!
— Storybook (@storybookjs) July 14, 2021
应用程序是一个相互连接的组件树。如果一个失败,其周围的其他也会随之崩溃。
组合测试使你能够了解每一个小错误对整个系统的影响。并将其扼杀在萌芽状态。https://#/bqMyQD8MNR pic.twitter.com/nrdjDWeNWw