测试复合组件
2021年1月,特斯拉召回了158,000辆汽车,原因是一个模块——显示屏——出现故障。显示屏损坏后,您将无法访问倒车摄像头、转向灯或驾驶员辅助系统。这会大大增加发生事故的风险。
一个有缺陷的模块导致了重大故障。
UI也面临着类似的挑战,因为应用程序就像汽车一样,是由相互连接的零件组成的。一个组件中的错误会影响它周围的所有其他组件。更不用说应用程序中它所使用的每个部分了。测试UI组件的组合方式有助于您避免此类错误。
测试UI中更复杂的部件很棘手。它们是通过组合许多更简单的组件来创建的,并且还连接着应用程序的状态。在本章中,我们将探讨如何隔离复合组件并对其应用视觉测试。在此过程中,您将了解模拟数据和模拟应用程序逻辑。以及测试组件集成的方法。
微小的错误最终会导致应用程序崩溃
应用程序是通过将组件相互连接来构建的。这意味着一个元素中的错误可能会影响其邻居。例如,重命名一个prop可能会破坏从父组件到子组件的数据流。或者UI元素中不正确的CSS常常会导致布局损坏。

以Storybook设计系统的Button组件为例。它在多个页面中被无数次使用。Button中的一个错误将不可避免地导致所有这些页面都出现错误。换句话说,一次失败可能会指数级地叠加。当您向上移动到页面的组件层次结构时,这些错误的数量会增加。因此,我们需要一种方法来尽早捕获此类级联问题并找出根本原因。

组合测试
视觉测试通过在真实浏览器中捕获和比较故事的图像快照来捕获错误。这使得它们非常适合发现UI更改和识别根本原因。以下是流程的快速回顾
- 🏷 隔离组件。使用Storybook一次测试一个组件。
- ✍🏽 写出测试用例。使用props重现每个组件状态。
- 🔍 手动验证每个测试用例的外观。
- 📸 使用视觉回归测试自动捕获错误。
组合测试就是对树中更高级别的“复合”组件运行视觉测试,这些组件由几个更简单的组件组成。这样您就可以量化任何更改可能对整个应用程序产生的影响。并确保系统作为一个整体正常工作。
关键区别在于,复合组件会跟踪应用程序状态并将行为向下传递到树中。在编写测试用例时,您需要考虑这些。
让我们通过为TaskList组件编写测试来实际演示这个过程,该组件显示属于用户的任务的完整列表。
它将固定任务移到列表顶部。并具有加载和空状态。我们将从为所有这些场景编写故事开始。

创建一个故事文件,注册TaskList组件,并为默认情况添加一个故事。
import TaskList from './TaskList';
import * as TaskStories from './Task.stories';
export default {
component: TaskList,
title: 'TaskList',
argTypes: {
...TaskStories.argTypes,
},
};
export const 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定义故事输入的机制。可以将其视为与框架无关的props。在组件级别定义的Args会自动传递到每个故事。在我们的例子中,我们使用Actions插件定义了三个事件处理程序。
当您与TaskList交互时,这些模拟的操作将显示在插件面板中。让您能够验证组件是否已正确连接。
组合Args
就像您可以组合组件来创建新的UI一样,您可以组合Args来创建新的故事。通常,复合组件的Args甚至会组合其子组件的Args。
事件处理程序Args已经在Task故事文件中定义,我们可以重复使用它们。同样,我们也可以使用默认故事中的Args来创建固定任务的故事。
import TaskList from './TaskList';
import * as TaskStories from './Task.stories';
export default {
component: TaskList,
title: 'TaskList',
argTypes: {
...TaskStories.argTypes,
},
};
export const 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 = {
args: {
tasks: [
{
id: '6',
title: 'Draft monthly blog to customers',
state: 'TASK_PINNED',
},
...Default.args.tasks.slice(0, 5),
],
},
};
export const Loading = {
args: {
tasks: [],
loading: true,
},
};
export const Empty = {
args: {
...Loading.args,
loading: false,
},
};
通过Args组合来塑造故事是一种强大的技术。它使我们能够编写故事而无需一遍又一遍地重复相同的数据。更重要的是,它测试组件的集成。如果您重命名了Task组件的props之一,那将导致TaskList的测试用例失败。
到目前为止,我们只处理了通过props接收数据和回调的组件。当您的组件连接到API或具有内部状态时,事情会变得更加棘手。接下来,我们将研究如何隔离和测试这些已连接的组件。
有状态的复合组件
InboxScreen使用自定义hook从Taskbox API获取数据并管理应用程序状态。就像单元测试一样,我们希望将组件与实际后端分离,并单独测试功能。

这时就轮到Storybook插件发挥作用了。它们允许您模拟API请求、状态、上下文、提供程序以及组件依赖的任何其他内容。The Guardian和Sidewalk Labs(Google)的团队使用它们来单独构建整个页面。
对于InboxScreen,我们将使用Mock Service Worker (MSW)插件在网络级别拦截请求并返回模拟响应。
这已在介绍章节中转移的模板中提供。我们需要设置它。让我们看看如何做。
运行以下命令,在您的public文件夹中生成一个新的服务工作线程。
yarn init-msw
💡 Public目录可能因项目而异。对于自定义配置,我们建议阅读MSW的文档以了解有关它们的更多信息。要使更改在Storybook中生效,您需要更新.storybook/main.js中的staticDirs配置项。
在您的.storybook/preview.js文件中启用MSW插件
import '../src/index.css';
+ import { initialize, mswLoader } from 'msw-storybook-addon';
+ // Initialize MSW
+ initialize();
/** @type { import('@storybook/react').Preview } */
const preview = {
parameters: {
actions: { argTypesRegex: '^on[A-Z].*' },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/,
},
},
},
+ loaders: [mswLoader],
};
export default preview;
最后,重新启动yarn storybook命令。这样我们就准备好在故事中模拟API请求了。
InboxScreen调用useTaskshook,该hook反过来从/tasks端点获取数据。我们可以使用msw参数指定模拟响应。注意您可以在每个故事中返回不同的响应。
import { http, HttpResponse } from 'msw';
import InboxScreen from './InboxScreen';
import { Default as TaskListDefault } from './components/TaskList.stories';
export default {
component: InboxScreen,
title: 'InboxScreen',
};
export const Default = {
parameters: {
msw: {
handlers: [
http.get('/tasks', () => {
return HttpResponse.json(TaskListDefault.args);
}),
],
},
},
};
export const Error = {
args: {
error: 'Something',
},
parameters: {
msw: {
handlers: [
http.get('/tasks', () => {
return HttpResponse.json([]);
}),
],
},
},
};
状态有许多不同的形式。有些应用程序使用Redux和MobX等库全局跟踪状态。或通过执行GraphQL查询。或者它们可能使用容器组件。Storybook足够灵活,可以支持所有这些场景。有关更多信息,请参阅:Storybook插件用于管理数据和状态。
单独构建组件可以减少开发复杂性。您不必启动后端,以用户身份登录,然后单击UI来调试一些CSS。您可以将其全部设置为一个故事并开始。您甚至可以在这些故事上运行自动回归测试。
捕获回归
在上一章中,我们设置了Chromatic并回顾了基本工作流程。现在我们有了所有复合组件的故事,我们可以通过运行以下命令来执行视觉测试
npx chromatic --project-token=<project-token>
您应该会看到一个包含TaskList和InboxScreen故事的差异。

现在尝试更改Task组件中的某些内容,例如字体大小或背景颜色。然后进行提交并重新运行Chromatic。
应用程序的树状结构意味着对Task组件的任何调整都将被更高级别组件的测试捕获。组合测试使您能够理解每个小更改的潜在影响。
验证组件功能
接下来,我们将超越外观,进入测试交互。当用户完成一项任务时,您如何确保已触发适当的事件并且状态已正确更新?