
如何测试组件交互
了解如何模拟用户行为并运行功能检查

你拨动开关,灯却没有亮。可能是灯泡烧坏了,也可能是线路故障。开关和灯泡通过墙壁内的电线连接在一起。
应用程序也是如此。表面是用户看到并与之交互的 UI。在底层,UI 连接起来以促进数据和事件的流动。
随着你构建更复杂的 UI(如页面),组件不仅要负责渲染 UI,还要负责更多。它们获取数据并管理状态。本文将介绍如何测试交互式组件。你将学习如何使用计算机来模拟和验证用户交互。

该组件真的能正常工作吗?
组件的主要任务是根据一组属性渲染 UI 的一部分。更复杂的组件还会跟踪应用程序状态并将行为传递到组件树中。
例如,一个组件将从初始状态开始。当用户在输入字段中键入内容或单击按钮时,它会在应用程序内触发一个事件。组件响应此事件更新状态。这些状态更改随后更新渲染的 UI。这就是交互的完整周期。
考虑一下我在之前的文章中介绍的 Taskbox 应用程序。在 InboxScreen
上,用户可以单击星标图标来置顶任务。或者单击复选框来存档它。可视化测试确保组件在所有这些状态下看起来都是正确的。我们还需要确保 UI 对这些交互做出正确的响应。

以下是交互测试工作流程的样子
- 📝 设置: 隔离组件并为初始状态提供适当的属性。
- 🤖 操作: 渲染组件并模拟交互。
- ✅ 运行断言 以验证状态是否已正确更新。
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 (@storybookjs) 2021 年 7 月 21 日
测试组件功能至关重要,但也很棘手。 你必须模拟用户行为并评估渲染的输出。
这篇文章向你展示了如何使用 @fbjest 和 @TestingLib 来验证用户交互: https://#/CViSSIUwyc pic.twitter.com/jGYsuYkz8e