Testing Library 组件测试:以用户为中心的断言

FreeGuideOnline 最新 2026-06-15

Testing Library 组件测试:以用户为中心的断言

Testing Library 的核心理念是“你的测试越像你的用户使用你的应用,它们就越能给你信心”。因此,无论是查询 DOM 元素还是编写断言,我们都必须站在最终用户的角度思考。本教程将带你深入以用户为中心的断言实践,确保你的组件测试不仅健壮,而且易于维护。

为什么需要以用户为中心的断言

传统的断言可能依赖组件内部状态、实例方法或 CSS 类名,这些细节用户根本接触不到。一旦重构组件而保持行为不变,这类测试就会产生大量误报。以用户为中心的断言只验证用户能看到或能交互的结果,例如:

  • 页面上是否显示了正确的文本
  • 输入框是否有正确的值
  • 按钮是否处于可用/禁用状态
  • 错误提示是否出现在预期位置

这样做会让你的测试更可靠,更贴近真实使用场景。

核心断言工具:jest-dom

Testing Library 与 @testing-library/jest-dom 扩展搭配使用,提供了许多语义化的自定义匹配器,让你可以直接对 DOM 节点的状态进行断言。常见的匹配器包括:

匹配器 用途 用户视角
toBeVisible() 检查元素是否对用户可见(非 display: nonevisibility: 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();
});

要点:我们使用 getByLabelTextgetByRole 从无障碍角度查询元素,这与屏幕阅读器用户的体验一致。断言全部检查用户可见的信息。

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 和语义化查询(getByRolegetByLabelText
  • @testing-library/jest-dom 的匹配器表达用户可见的行为
  • 避免直接检查组件内部状态或类名

现在,你可以尝试为你自己的组件编写测试,始终问自己:“这个断言是在验证用户看到或能做到的事情吗?” 如果不是,换一种方式。