Testing Library 组件测试:以用户为中心的断言
Testing Library 组件测试:以用户为中心的断言
Testing Library 的核心理念是“你的测试越像你的用户使用你的应用,它们就越能给你信心”。因此,无论是查询 DOM 元素还是编写断言,我们都必须站在最终用户的角度思考。本教程将带你深入以用户为中心的断言实践,确保你的组件测试不仅健壮,而且易于维护。
为什么需要以用户为中心的断言
传统的断言可能依赖组件内部状态、实例方法或 CSS 类名,这些细节用户根本接触不到。一旦重构组件而保持行为不变,这类测试就会产生大量误报。以用户为中心的断言只验证用户能看到或能交互的结果,例如:
- 页面上是否显示了正确的文本
- 输入框是否有正确的值
- 按钮是否处于可用/禁用状态
- 错误提示是否出现在预期位置
这样做会让你的测试更可靠,更贴近真实使用场景。
核心断言工具:jest-dom
Testing Library 与 @testing-library/jest-dom 扩展搭配使用,提供了许多语义化的自定义匹配器,让你可以直接对 DOM 节点的状态进行断言。常见的匹配器包括:
| 匹配器 | 用途 | 用户视角 |
|---|---|---|
toBeVisible() |
检查元素是否对用户可见(非 display: none 或 visibility: hidden) |
用户能否看到该元素 |
toHaveTextContent() |
检查元素是否包含指定文本 | 用户读到了什么内容 |
toHaveValue() |
检查输入框的值 | 用户在输入框中看到了什么 |
toBeDisabled() |
检查元素是否处于禁用状态 | 用户能否点击该按钮 |
toBeChecked() |
检查复选框/单选框是否选中 | 用户看到的选中状态 |
toHaveAttribute() |
检查属性值 | 辅助技术是否获取到正确的 aria 属性 |
接下来我们通过具体示例掌握这些断言的使用方法。
上手实践:测试一个登录表单
假设你有一个简单的登录组件,包含用户名输入框、密码输入框、登录按钮,以及未填写完整时的错误提示。我们将一步步构建以用户为中心的测试。
1. 测试组件在初始状态下正确渲染
import { render, screen } from '@testing-library/react';
import LoginForm from './LoginForm';
test('初始状态:用户名、密码为空,登录按钮可用', () => {
render(<LoginForm />);
// 用户看到两个输入框
expect(screen.getByLabelText(/用户名/i)).toBeVisible();
expect(screen.getByLabelText(/密码/i)).toBeVisible();
// 输入框内容为空 - 使用 toHaveValue 断言
expect(screen.getByLabelText(/用户名/i)).toHaveValue('');
expect(screen.getByLabelText(/密码/i)).toHaveValue('');
// 登录按钮存在且未被禁用
const loginButton = screen.getByRole('button', { name: /登录/i });
expect(loginButton).toBeVisible();
expect(loginButton).not.toBeDisabled();
});
要点:我们使用 getByLabelText 和 getByRole 从无障碍角度查询元素,这与屏幕阅读器用户的体验一致。断言全部检查用户可见的信息。
2. 测试交互行为:输入与提交
用户输入凭据并点击登录后,系统应调用登录接口并显示加载状态。
import userEvent from '@testing-library/user-event';
test('填写凭据后点击登录,按钮进入加载状态并调用接口', async () => {
const mockLogin = jest.fn().mockResolvedValueOnce({ success: true });
render(<LoginForm onLogin={mockLogin} />);
const usernameInput = screen.getByLabelText(/用户名/i);
const passwordInput = screen.getByLabelText(/密码/i);
const loginButton = screen.getByRole('button', { name: /登录/i });
// 模拟用户输入
await userEvent.type(usernameInput, 'john');
await userEvent.type(passwordInput, 'secret123');
// 断言输入框的值
expect(usernameInput).toHaveValue('john');
expect(passwordInput).toHaveValue('secret123');
// 点击登录
await userEvent.click(loginButton);
// 提交后按钮应显示“登录中...”且不可用(用户无法重复提交)
expect(loginButton).toHaveTextContent(/登录中/i);
expect(loginButton).toBeDisabled();
// 验证接口调用
expect(mockLogin).toHaveBeenCalledWith({
username: 'john',
password: 'secret123'
});
});
这里使用了 userEvent(推荐用 @testing-library/user-event 而非 fireEvent)模拟真实用户操作。断言按钮的文本内容和禁用状态完全反映用户所见。
3. 测试错误提示与可访问性
当登录失败时,组件应显示错误消息并关联到相关字段,这直接影响所有用户,尤其是依赖辅助技术的用户。
test('登录失败时显示错误信息,并通过 aria 关联到用户名', async () => {
const errorMessage = '用户名或密码错误';
const mockLogin = jest.fn().mockRejectedValueOnce(new Error(errorMessage));
render(<LoginForm onLogin={mockLogin} />);
await userEvent.type(screen.getByLabelText(/用户名/i), 'john');
await userEvent.type(screen.getByLabelText(/密码/i), 'wrong');
await userEvent.click(screen.getByRole('button', { name: /登录/i }));
// 错误提示出现在页面上
const errorAlert = await screen.findByRole('alert');
expect(errorAlert).toBeVisible();
expect(errorAlert).toHaveTextContent(errorMessage);
// 确保输入框通过 aria-describedby 关联到错误信息
const usernameInput = screen.getByLabelText(/用户名/i);
expect(usernameInput).toHaveAttribute('aria-invalid', 'true');
expect(usernameInput).toHaveAttribute(
'aria-describedby',
errorAlert.getAttribute('id')
);
});
我们利用 findByRole('alert') 等待错误提示出现,因为它是异步的。断言元素具有正确的 ARIA 属性,保证屏幕阅读器能告知用户错误上下文。
常见以用户为中心的断言模式
验证元素不存在于 DOM 中
使用 queryBy... 而非 getBy... 查询,然后用 toBeNull() 或 not.toBeInTheDocument() 断言。
expect(screen.queryByText(/欢迎回来/i)).not.toBeInTheDocument();
验证列表项数量
用户看到的是一个列表,你应该断言列表项的数量或内容,而不是组件的内部状态。
const items = screen.getAllByRole('listitem');
expect(items).toHaveLength(3);
expect(items[0]).toHaveTextContent('购买牛奶');
验证模态框的打开与关闭
用户期望模态框出现时带有正确的标题,关闭后消失。
await userEvent.click(screen.getByRole('button', { name: /打开设置/i }));
const dialog = screen.getByRole('dialog');
expect(dialog).toBeVisible();
expect(screen.getByRole('heading', { name: /设置/i })).toBeVisible();
await userEvent.click(screen.getByRole('button', { name: /关闭/i }));
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
避免的非用户中心断言
下表对比了应避免的做法和推荐的替代方案:
| 避免(依赖实现细节) | 推荐(用户视角) |
|---|---|
expect(wrapper.state('isLoading')).toBe(true) |
expect(button).toHaveTextContent('登录中...') |
expect(componentInstance.method).toHaveBeenCalled() |
expect(mockFunction).toHaveBeenCalledWith(...) |
expect(wrapper.find('.error').exists()).toBe(true) |
expect(screen.getByRole('alert')).toBeVisible() |
expect(input.props().disabled).toBe(true) |
expect(input).toBeDisabled() |
坚持使用 Testing Library 提供的查询和 jest-dom 匹配器,你将天然远离实现细节。
总结
以用户为中心的断言让你的测试真正反映软件质量。遵循以下原则:
- 只断言用户能感知的结果(视觉内容、交互状态、可访问性属性)
- 优先使用
screen和语义化查询(getByRole、getByLabelText) - 用
@testing-library/jest-dom的匹配器表达用户可见的行为 - 避免直接检查组件内部状态或类名
现在,你可以尝试为你自己的组件编写测试,始终问自己:“这个断言是在验证用户看到或能做到的事情吗?” 如果不是,换一种方式。