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

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

那个组件真的起作用了吗?
组件的主要任务是根据一组 props 渲染 UI 的一部分。更复杂的组件还会跟踪应用状态并将行为向下传递给组件树。
例如,一个组件会从初始状态开始。当用户在输入框中输入内容或点击按钮时,会在应用内触发一个事件。组件会响应这个事件更新状态。这些状态变化随后更新渲染的 UI。这就是一个交互的完整周期。
考虑一下我在上一篇文章中介绍的 Taskbox 应用。在 InboxScreen
上,用户可以点击星标图标来固定任务。或者点击复选框来归档它。可视化测试确保组件在所有这些状态下看起来都是正确的。我们还需要确保 UI 正确响应这些交互。

以下是交互测试的工作流程:
- 📝 设置:隔离组件并提供初始状态所需的 props。
- 🤖 动作:渲染组件并模拟交互。
- ✅ 运行断言以验证状态是否正确更新。
Taskbox 应用是使用 Create React App 引导创建的,它预配置了 Jest。这就是我们将用来编写和运行测试的工具。
测试组件的功能,而不是它的实现方式

与单元测试类似,我们希望避免测试组件的内部工作原理。这会使测试变得脆弱,因为无论输出是否改变,任何时候重构代码都会破坏测试。这反过来会减慢你的速度。
这就是为什么 Adobe、Twilio、Gatsby 和许多其他团队使用 Testing-Library 的原因。它允许你评估渲染的输出。它的工作原理是将组件挂载到虚拟浏览器 (JSDOM) 中,并提供模拟用户交互的实用工具。
我们可以编写模拟真实世界用法的测试,而不是访问组件的内部状态和方法。并且从用户的角度编写测试,能让我们对代码的可用性更有信心。
让我们深入代码,看看这个过程是如何实际进行的。这次我们的起点是 composition-testing 分支。
将故事作为交互测试用例重复使用
我们首先编写一个测试用例。之前,我们将 InboxScreen
组件的所有用例编目到了 InboxScreen.stories.js
文件中。这使我们能够在开发期间抽查外观,并通过可视化测试捕获回归问题。这些故事现在也将支持我们的交互测试。
// 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 使用装饰器(decorators)来包裹故事并提供这样的功能。要导入故事及其所有配置,我们将使用 @storybook/testing-react 库。
这通常是一个两步过程。首先,我们需要注册所有全局装饰器。在我们的例子中,我们有两个:一个提供 Chakra UI 主题的装饰器,另一个是 MSW 插件的装饰器。我们之前在 .storybook/preview
中配置了这些。
Jest 提供了一个全局 setup 文件 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);
});
总之,设置代码位于 stories 文件中,而动作和断言位于测试文件中。使用 Testing Library,我们以用户的方式与 UI 进行了交互。将来,如果组件实现发生变化,只有当输出或行为被修改时,测试才会失败。

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

将测试用例编写为故事意味着你只需要做一次复杂的设置:隔离组件、模拟它们的依赖项并捕获它们的用例。然后可以将所有这些设置导入到其他测试框架中,这为你节省了时间和精力。
更重要的是,故事的可移植性还简化了可访问性测试。当你确保你的 UI 对每个用户都可用时,你会影响业务财务并满足法律要求。这是双赢的。
但可访问性可能看起来要做很多工作。下一篇文章将分解如何在开发和 QA 期间自动化可访问性检查。这使你能够及早发现违规,从而节省时间和精力。
你点击购买按钮,但什么也没发生 😰
— Storybook (@storybookjs) 2021年7月21日
测试组件功能至关重要,但也很棘手。你需要模拟用户行为并评估渲染的输出。
这篇文章展示了如何使用 @fbjest 和 @TestingLib 来验证用户交互:https://#/CViSSIUwyc pic.twitter.com/jGYsuYkz8e