返回博客

如何测试组件交互

了解如何模拟用户行为并运行功能检查

loading
Varun Vachhar
@winkerVSbecks
最后更新

你拨动开关,灯却没有亮。可能是灯泡烧坏了,也可能是线路故障。开关和灯泡通过墙壁内的电线连接在一起。

应用程序也是如此。表面是用户看到并与之交互的 UI。在底层,UI 连接起来以促进数据和事件的流动。

随着你构建更复杂的 UI(如页面),组件不仅要负责渲染 UI,还要负责更多。它们获取数据并管理状态。本文将介绍如何测试交互式组件。你将学习如何使用计算机来模拟和验证用户交互。

该组件真的能正常工作吗?

组件的主要任务是根据一组属性渲染 UI 的一部分。更复杂的组件还会跟踪应用程序状态并将行为传递到组件树中。

例如,一个组件将从初始状态开始。当用户在输入字段中键入内容或单击按钮时,它会在应用程序内触发一个事件。组件响应此事件更新状态。这些状态更改随后更新渲染的 UI。这就是交互的完整周期。

考虑一下我在之前的文章中介绍的 Taskbox 应用程序。在 InboxScreen 上,用户可以单击星标图标来置顶任务。或者单击复选框来存档它。可视化测试确保组件在所有这些状态下看起来都是正确的。我们还需要确保 UI 对这些交互做出正确的响应。

以下是交互测试工作流程的样子

  1. 📝 设置: 隔离组件并为初始状态提供适当的属性。
  2. 🤖 操作: 渲染组件并模拟交互。
  3. 运行断言 以验证状态是否已正确更新。

Taskbox 应用程序是使用 Create React App 引导的,它预先配置了 Jest。 这就是我们将用来编写和运行测试的工具。

测试组件的功能,而不是其实现方式

与单元测试非常相似,我们希望避免测试组件的内部工作原理。这会使测试变得脆弱,因为每次你重构代码时,无论输出是否更改,它都会破坏测试。 这反过来会减慢你的速度。

这就是为什么 Adobe、Twilio、Gatsby 和更多团队使用 Testing-Library 的原因。它允许你评估渲染的输出。它的工作原理是在虚拟浏览器 (JSDOM) 中挂载组件,并提供复制用户交互的实用程序。

我们可以编写模拟真实世界用法的测试,而不是访问组件的内部状态和方法。从用户的角度编写测试让我们更有信心我们的代码能够正常工作。

让我们深入研究一些代码,看看这个过程是如何运作的。我们这次的起点是 composition-testing 分支。

重用故事作为交互测试用例

我们首先编写一个测试用例。之前,我们在 InboxScreen.stories.js 文件中编录了 InboxScreen 组件的所有用例。 这使我们能够在开发期间进行外观检查,并通过可视化测试捕获回归。 这些故事现在也将为我们的交互测试提供支持。

// 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([]));
   }),
 ],
};

故事以基于标准 JavaScript 模块的可移植格式编写。 你可以将它们与任何基于 JavaScript 的测试库(Jest、Testing Lib、Playwright)一起重用。 这使你无需为套件中的每个测试工具设置和维护测试用例。 例如,Adobe Spectrum 设计系统团队使用这种模式来测试交互,用于他们的菜单和对话框组件。

当你将测试用例编写为故事时,任何形式的断言都可以分层在顶部。 让我们尝试一下。 创建 InboxScreen.test.js 文件并编写第一个测试。 与上面的示例类似,我们将一个故事导入到此测试中,并使用 Testing-Library 中的 render 函数来挂载它。

it 代码块描述了我们的测试。 我们首先渲染组件,等待它获取数据,找到特定任务,然后单击置顶按钮。 断言检查置顶状态是否已更新。 最后,afterEach 代码块通过卸载在测试期间挂载的 React 树来进行清理。

// InboxScreen.test.js
import React from 'react';
import '@testing-library/jest-dom/extend-expect';
import { render, waitFor, cleanup } from '@testing-library/react';
import * as stories from './InboxScreen.stories';
 
describe('InboxScreen', () => {
 afterEach(() => {
   cleanup();
 });
 
 const { Default } = stories;
 
 it('should pin a task', async () => {
   const { queryByText, getByRole } = render(<Default />);
 
   await waitFor(() => {
     expect(queryByText('You have no tasks')).not.toBeInTheDocument();
   });
 
   const getTask = () => getByRole('listitem', { name: 'Export logo' });
 
   const pinButton = within(getTask()).getByRole('button', { name: 'pin' });
 
   fireEvent.click(pinButton);
 
   const unpinButton = within(getTask()).getByRole('button', {
     name: 'unpin',
   });
 
   expect(unpinButton).toBeInTheDocument();
 });
});

运行 yarn test 以启动 Jest。 你会注意到测试失败了。

InboxScreen 从后端获取数据。 在之前的文章中,我们设置了 Storybook MSW 插件来模拟此 API 请求。 但是,这在 Jest 中不可用。 我们需要一种方法来引入此项和其他组件依赖项。

组件配置即将到来

复杂组件依赖于外部依赖项,例如主题提供器和上下文来共享全局数据。 Storybook 使用 装饰器 来包装故事并提供此类功能。 为了导入故事以及它们的所有配置,我们将使用 @storybook/testing-react 库。

这通常是一个两步过程。 首先,我们需要注册所有全局装饰器。 在我们的例子中,我们有两个:一个提供 Chakra UI 主题的装饰器和一个用于 MSW 插件的装饰器。 我们之前.storybook/preview 中配置了这些。

Jest 提供了一个全局设置文件 setupTests.js,它是在项目引导时由 CRA 自动生成的。 更新该文件以注册 Storybook 的全局配置。

// setupTests.js
import '@testing-library/jest-dom';
 
import { setGlobalConfig } from '@storybook/testing-react';
import * as globalStorybookConfig from '../.storybook/preview';
 
setGlobalConfig(globalStorybookConfig);

接下来,更新测试以使用 @storybook/testing-react 中的 composeStories 实用程序。 它返回故事的 1:1 映射,其中所有装饰器都已应用于它们。 瞧,我们的测试通过了!

// InboxScreen.test.js
import React from 'react';
import '@testing-library/jest-dom/extend-expect';
import { render, waitFor, cleanup } from '@testing-library/react';
import { composeStories } from '@storybook/testing-react';
import { getWorker } from 'msw-storybook-addon';
import * as stories from './InboxScreen.stories';
 
describe('InboxScreen', () => {
 afterEach(() => {
   cleanup();
 });
 
 // Clean up after all tests are done, preventing this
 // interception layer from affecting irrelevant tests
 afterAll(() => getWorker().close());
 
 const { Default } = composeStories(stories);
 
 it('should pin a task', async () => {
   const { queryByText, getByRole } = render(<Default />);
 
   await waitFor(() => {
     expect(queryByText('You have no tasks')).not.toBeInTheDocument();
   });
 
   const getTask = () => getByRole('listitem', { name: 'Export logo' });
 
   const pinButton = within(getTask()).getByRole('button', { name: 'pin' });
 
   fireEvent.click(pinButton);
 
   const unpinButton = within(getTask()).getByRole('button', {
     name: 'unpin',
   });
 
   expect(unpinButton).toBeInTheDocument();
 });
});

我们已成功编写了一个测试,该测试加载故事并使用 Testing Library 渲染它。 然后,它应用模拟的用户行为并检查组件状态是否已准确更新。

使用相同的模式,我们还可以为存档和编辑场景添加测试。

 it('should archive a task', async () => {
   const { queryByText, getByRole } = render(<Default />);
 
   await waitFor(() => {
     expect(queryByText('You have no tasks')).not.toBeInTheDocument();
   });
 
   const task = getByRole('listitem', { name: 'QA dropdown' });
   const archiveCheckbox = within(task).getByRole('checkbox');
   expect(archiveCheckbox.checked).toBe(false);
 
   fireEvent.click(archiveCheckbox);
   expect(archiveCheckbox.checked).toBe(true);
 });
 
 it('should edit a task', async () => {
   const { queryByText, getByRole } = render(<Default />);
 
   await waitFor(() => {
     expect(queryByText('You have no tasks')).not.toBeInTheDocument();
   });
 
   const task = getByRole('listitem', {
     name: 'Fix bug in input error state',
   });
   const taskInput = within(task).getByRole('textbox');
 
   const updatedTaskName = 'Fix bug in the textarea error state';
 
   fireEvent.change(taskInput, {
     target: { value: 'Fix bug in the textarea error state' },
   });
   expect(taskInput.value).toBe(updatedTaskName);
 });

总而言之,设置代码位于故事文件中,而操作和断言位于测试文件中。 通过 Testing Library,我们以用户会使用的方式与 UI 进行交互。 将来,如果组件实现发生更改,则只有在输出或行为被修改时,测试才会中断。

故事是所有类型测试的起点

组件不是静态的。 用户可以与 UI 交互并触发状态更新。 为了验证这些功能特性,你需要编写模拟用户行为的测试。 交互测试检查组件之间的连接,即事件和数据是否正在流动。 以及底层逻辑是否正确。

将测试用例编写为故事意味着你只需要进行一次棘手的设置:隔离组件,模拟它们的依赖项并捕获它们的用例。 然后,所有这些设置都可以导入到其他测试框架中,从而节省你的时间和麻烦。

更重要的是,故事的可移植性还简化了可访问性测试。 当你确保你的 UI 对每个用户都可用时,你将影响业务财务并满足法律要求。 这是一种双赢。

但是可访问性似乎需要做很多工作。 下一篇文章将分解如何在开发和质量保证 (QA) 期间自动化可访问性检查。 这使你能够尽早发现违规行为,最终节省你的时间和精力。

加入 Storybook 邮件列表

获取最新的新闻、更新和发布

6,730位开发者和计数中

我们正在招聘!

加入 Storybook 和 Chromatic 背后的团队。 构建被数十万开发者在生产环境中使用的工具。 远程优先。

查看职位

热门文章

使用 Storybook 进行可访问性测试

通过集成工具实现快速反馈
loading
Varun Vachhar

交互测试抢先看

使用 Storybook 的 play function 测试连接的组件
loading
Dominic Nguyen

测试复合组件

防止小改动变成重大回归
loading
Varun Vachhar
加入社区
6,730位开发者和计数中
为什么为什么选择 Storybook组件驱动的 UI
文档指南教程更新日志遥测
社区插件参与进来博客
案例展示探索项目组件术语表
开源软件
Storybook - Storybook 中文

特别感谢 Netlify CircleCI