文章

组件测试深度解析

组件测试的核心哲学:像用户一样测试,而不是测试实现细节。查询优先级、异步测试、事件触发。

组件测试深度解析

一句话概括

组件测试验证的是”组件渲染出来是什么、用户交互后会发生什么“。它站在用户视角而不是实现视角——不关心组件内部 state 怎么变、用了什么 class,只关心屏幕上呈现的内容和对交互的响应。React 生态的事实标准是 React Testing Library(RTL),Vue 生态是 Vue Test Utils。核心哲学一句话:测试越像用户使用方式,越能给你信心;越贴近实现细节,重构时越容易一碰就碎。

核心知识点

1. 像用户一样查询,而不是查实现细节

RTL 最核心的一条原则是查询优先级——优先用用户”看得见”的方式找元素:

1
2
3
4
5
6
7
8
9
10
11
12
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

test('提交表单显示成功提示', async () => {
  render(<LoginForm />);

  // ✅ 用户视角:按角色/文本/标签查找
  await userEvent.type(screen.getByRole('textbox', { name: /用户名/ }), 'zhangsan');
  await userEvent.click(screen.getByRole('button', { name: /登录/ }));

  expect(await screen.findByText('登录成功')).toBeInTheDocument();
});

查询优先级从高到低:getByRole(无障碍角色)→ getByLabelText(表单标签)→ getByPlaceholderText → getByText → getByTestId(最后手段)。getByTestId 之所以垫底,是因为它跟用户毫无关系——用户看不到 data-testid,只看到按钮、输入框和文字。多用它等于测试退化成实现细节测试。

2. 用 userEvent 而不是 fireEvent

fireEvent 触发的是底层 DOM 事件(fireEvent.click 只发一个 click),而真实用户的一次点击会触发一整串事件(pointerdown → mousedown → focus → mouseup → click)。用错会漏掉关键行为:

1
2
3
4
5
6
// ❌ fireEvent 不模拟真实交互序列,可能漏掉 focus/keydown 等副作用
fireEvent.change(input, { target: { value: 'abc' } });

// ✅ userEvent 模拟真实用户:完整事件序列 + 更符合直觉的 API
await userEvent.type(input, 'abc');
await userEvent.click(button);

凡是能改用 userEvent 的地方都用它——它更接近真实浏览器行为,能抓住”只绑定 click 但忘了 focus 逻辑”这类测试盲区。

3. 异步更新:findBy 会等,getBy 不等

组件里普遍有 useEffect 拉数据、防抖、Promise 更新,这些是异步的:

1
2
3
4
5
6
7
8
9
10
11
12
test('异步加载后渲染列表', async () => {
  render(<UserList />);

  // ❌ getBy 立即查询,此时数据还没回来 → 报错
  // screen.getByText('张三');

  // ✅ findBy 会重试等待(默认最多 1000ms),直到元素出现
  expect(await screen.findByText('张三')).toBeInTheDocument();

  // 等待某元素消失(如 loading 转圈)
  await waitForElementToBeRemoved(() => screen.queryByText('加载中...'));
});

记一条:同步断言用 getBy,异步等待用 findBy,判断”不存在”用 queryBy(getBy 找不到会直接 throw,queryBy 返回 null)。异步更新没等就用 getBy,是组件测试里最常见的报错。

4. 触发交互并断言状态变化

组件测试的精髓是”交互 → 断言”闭环:

1
2
3
4
5
6
7
8
9
10
11
test('点击按钮切换开关', async () => {
  render(<Toggle />);

  const btn = screen.getByRole('button', { name: /开关/ });

  await userEvent.click(btn);
  expect(screen.getByText('已开启')).toBeInTheDocument();

  await userEvent.click(btn);
  expect(screen.getByText('已关闭')).toBeInTheDocument();
});

关注”用户看到的结果变化”,而不是 expect(component.state.isOn).toBe(true) 这种窥探内部状态的断言——后者重构(改名 state、换实现)时立刻全红。

5. Mock 外部依赖,让组件”假戏真做”

组件常依赖 API、路由、Context,测试时要隔离:

1
2
3
4
5
6
7
8
9
// Mock fetch 模块
jest.mock('../api');
import { fetchUser } from '../api';

test('渲染用户信息', async () => {
  fetchUser.mockResolvedValue({ id: 1, name: '张三' });
  render(<UserCard id={1} />);
  expect(await screen.findByText('张三')).toBeInTheDocument();
});

其实你每天都在用

  • 改组件后跑测试看有没有”炸”:你重构一个 Button 组件,跑一遍组件测试确认所有用到它的页面行为没变,这就是组件测试在兜底
  • UI 组件库(Ant Design / Element)的示例:每个组件的在线文档示例,本质都是可交互的组件测试,你点一下看效果就是在”手动组件测试”
  • Mock 掉登录态测试不同状态:测试”未登录显示登录按钮、已登录显示头像”时,Mock 掉 auth 返回不同状态,不用真登录
  • Storybook 的 stories:给组件写 story 展示各种状态(loading、空、错误),和组件测试是同一批”组件状态”素材的两种用法
  • 找按钮却找不到报错:测试里 getByRole('button') 找不到元素,往往暴露了组件无障碍语义缺失(div 冒充按钮没加 role),等于顺带做了 a11y 检查

常见误解(FAQ)

❌ 误区一:”组件测试要测内部 state 和 props 的传递”

测 state、props 是实现细节测试——组件内部怎么组织状态、用 useState 还是 useReducer,用户根本感知不到。测了它们,一重构就全红,测试变成”阻碍重构”而不是”保护行为”。正确做法是断言用户可见的结果:渲染出了什么、点完变成了什么。

❌ 误区二:”getByTestId 最方便,多用它准没错”

data-testid 用户看不见,用它等于把测试绑死在了 DOM 结构上。而且它掩盖了组件缺少语义的问题。只有在确实无法用角色/文本定位(比如 canvas、图表)时,才退而求其次用 testid。把它当首选,你的测试会越来越像”验收 DOM 结构”而不是”验收用户体验”。

❌ 误区三:”fireEvent 和 userEvent 区别不大”

fireEvent.change 只触发单个事件,不会触发 focus、input 的完整序列,也不会模拟 keydown 逐字符输入。很多”测试过了但真实浏览器却报错”的 bug,根源就是 fireEvent 把真实交互简化得太狠。userEvent 模拟的是真实用户操作序列,是更接近真机的选择。

❌ 误区四:”异步组件测试,sleep 一下等数据就行了”

写 await new Promise(r => setTimeout(r, 500)) 是竞态炸弹——CI 慢一点就超时、快一点就误判,测试变得 flaky。正确做法是 findBy* 自动重试等待,或 waitFor 等待具体条件成立,让测试”等到条件满足”而不是”睡够固定时间”。

一句话总结

组件测试的成败不在用了多少断言,而在视角是否站在用户这一边——用角色查询、用 userEvent 交互、断言看得见的结果,你的测试才会在重构时保护你,而不是拖住你。

本文由作者按照 CC BY 4.0 进行授权