前端组件测试应该围绕哪些用户行为?
以表单组件为例,用用户可见的名称、输入、提交和错误状态组织测试,区分组件契约与实现细节,并说明异步更新、Mock 和无障碍断言的边界。
简答
- 组件测试应验证组件对用户提供的契约,而不是 class、内部函数和 state。
- 按用户能感知的方式操作:找字段、输入、提交、看加载和错误、重试。
- 实现细节只有本身是公共契约时才直接断言。
- 这样的测试在重构时更稳定,也更能发现真实回归。
组件测试最容易写成“找一个 class,调用一个内部函数,再断言 state”。这种测试可能在实现重构后大量失败,却没有告诉你用户是否还能完成任务。
更稳定的目标是组件对用户提供的契约:能否找到字段、输入内容、点击提交、看到加载状态、收到错误提示、重新尝试,以及键盘和可访问名称是否正常。实现细节只有在它本身是公共契约时才值得直接断言。
先把组件当成用户可操作的边界
下面是一个简化的搜索组件,接收 search 函数,失败时显示错误,成功时显示结果:
import { useState } from 'react';
type Props = {
search: (keyword: string) => Promise<string[]>;
};
export function SearchBox({ search }: Props) {
const [keyword, setKeyword] = useState('');
const [state, setState] = useState<{ kind: 'idle' | 'loading' | 'success' | 'error'; items?: string[] }>({ kind: 'idle' });
async function submit(event: React.FormEvent) {
event.preventDefault();
setState({ kind: 'loading' });
try {
const items = await search(keyword);
setState({ kind: 'success', items });
} catch {
setState({ kind: 'error' });
}
}
return (
<form onSubmit={submit}>
<label htmlFor="search-keyword">关键词</label>
<input id="search-keyword" value={keyword} onChange={(event) => setKeyword(event.target.value)} />
<button type="submit" disabled={state.kind === 'loading'}>搜索</button>
{state.kind === 'loading' && <p role="status">搜索中</p>}
{state.kind === 'error' && <p role="alert">搜索失败,请重试。</p>}
{state.kind === 'success' && <ul>{state.items?.map((item) => <li key={item}>{item}</li>)}</ul>}
</form>
);
}
测试应该通过 label、button、status、alert 和列表内容与它交互,而不是依赖 SearchBox 里面用了几个 state 或某个 CSS 类。
用可访问查询表达用户行为
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { expect, test, vi } from 'vitest';
test('提交关键词后显示结果', async () => {
const user = userEvent.setup();
const search = vi.fn().mockResolvedValue(['事件循环']);
render(<SearchBox search={search} />);
await user.type(screen.getByRole('textbox', { name: '关键词' }), '事件');
await user.click(screen.getByRole('button', { name: '搜索' }));
expect(search).toHaveBeenCalledWith('事件');
expect(await screen.findByText('事件循环')).toBeVisible();
});
getByRole 和可访问名称同时验证了用户能否找到控件。ARIA 规定 listitem 的名称不从内容计算,所以结果用 findByText 查询;写成 findByRole('listitem', { name }) 会一直等到超时。findBy... 等待异步结果,避免用不确定的固定 sleep。userEvent 通常比直接调用 fireEvent.change 更接近真实输入和键盘事件,但也会让测试暴露组件没有正确处理焦点或提交行为的问题。
查询优先级见“Testing Library 查询方式为什么优先按用户可见语义?”。
Loading、失败和重试要有独立用例
test('搜索失败后可以重试并恢复', async () => {
const user = userEvent.setup();
const search = vi.fn()
.mockRejectedValueOnce(new Error('暂时失败'))
.mockResolvedValueOnce(['闭包']);
render(<SearchBox search={search} />);
const input = screen.getByRole('textbox', { name: '关键词' });
await user.type(input, '闭包');
await user.click(screen.getByRole('button', { name: '搜索' }));
expect(await screen.findByRole('alert')).toHaveTextContent('搜索失败');
await user.click(screen.getByRole('button', { name: '搜索' }));
expect(await screen.findByText('闭包')).toBeVisible();
expect(search).toHaveBeenCalledTimes(2);
});
这个测试不依赖错误对象的具体文本,因为用户看到的是组件定义的稳定文案;它验证了失败后仍保留输入、按钮可再次操作,并且第二次成功能恢复正常状态。若产品要求在请求失败后保留旧结果,同时显示错误,则应把该状态写进契约并单独测试,不要为了让测试简单而把旧内容清空。
异步测试要等待真正的可观察结果,并在测试超时时检查 Promise 是否被正确解决或拒绝。不要用 await new Promise(resolve => setTimeout(resolve, 100)) 猜测渲染完成,也不要在测试中使用永远 pending 的 Mock。
Mock 外部依赖,但保留组件自己的契约
组件测试通常把网络请求、时钟、路由或存储通过 props、Context 或模块 Mock 替换成受控依赖。Mock 应模拟真实边界的成功、错误和数据结构,不能为了让断言简单而返回组件实际上不会收到的对象。
如果组件本身负责请求,Mock fetch 时要检查 URL、方法和请求体;如果请求由数据层负责,组件测试可以传入一个 search 函数,把网络契约留给数据层的集成或契约测试。让测试依赖越少,失败原因越容易定位。
不要过度断言渲染实现
下面这些断言通常脆弱:
- 组件内部 state 的字段名字;
- 具体 HTML 层级和无意义的 class 名;
useEffect被调用了几次;- 某个私有函数被调用,而用户结果没有变化;
- React/Vue 内部实例或框架生成的属性。
如果一个 DOM 结构本身是可访问性或布局契约,可以断言它;否则应优先断言用户能看到的文本、控件状态、焦点和可操作结果。重构状态机、拆分子组件或换样式工具时,行为测试不应全部失效。
组件测试仍不能替代更高层验证
组件测试适合快速覆盖状态和交互组合,但不能证明真实路由、后端认证、CORS、浏览器缓存、字体布局和多个组件组合正确。关键流程还需要集成或端到端测试;复杂控件要结合键盘、无障碍和视觉回归测试。
测试数量也不等于覆盖质量。一个成功用例不能证明错误、空数据、取消、重复点击和权限失败都能恢复。围绕组件契约列出状态和用户任务,再选择最少但有意义的测试,通常比复制大量 DOM 快照更容易长期维护。