React 测试
React 组件测试实战:Vitest + Testing Library 技术栈、按角色查询的行为测试法、异步与网络 mock(MSW)、常见反模式与调试技巧。
1. 一句话理解
React 测试的当代共识是一句话:像用户一样测试,而不是像程序员一样测试。用户不关心你组件里叫 isOpen 还是 visible,他们看按钮文字、按键盘、等加载结束——所以断言应落在”角色、文本、行为”上。技术栈在 2025-2026 已收敛为四件套:Vitest(跑测试,Jest 的现代替代)、@testing-library/react(渲染与查询)、@testing-library/user-event(模拟真实交互)、@testing-library/jest-dom(增强断言);网络请求交给 MSW(在 service worker 层拦截)。
npm i -D vitest @testing-library/react @testing-library/user-event @testing-library/jest-dom jsdom
2. 核心心法:测行为,不测实现
对比同一需求的三种写法,前两种都是反模式:
// 反模式一:断言实现细节(内部 state)——重构改名即挂,用户毫无感知
expect(component.state.count).toBe(1);
// 反模式二:断言实现细节(props 传递)——同上
expect(onClickMock).toHaveBeenCalledWith('add');
// 正确:断言用户能看到、能操作的结果
render(<Counter />);
await userEvent.click(screen.getByRole('button', { name: '加一' }));
expect(screen.getByText('1')).toBeInTheDocument();
判别标准:把组件整个重写(换掉 useState、换掉类库),测试若仍全绿,说明测的是行为;若挂了一片,说明绑死了实现。
3. 查询优先级:为什么是 getByRole
Testing Library 对查询方法有明确的推荐顺序,本质是可访问性优先:
getByRole('button', { name: '提交' })——可访问性树,最能代表用户视角;查不到往往说明组件本身无障碍有问题getByLabelText('邮箱')——表单字段getByPlaceholderText/getByText/getByAltText/getByTitlegetByTestId——最后手段,与用户感知无关的兜底
getBy 变体规则:getBy 找不到立刻抛错;queryBy 找不到返回 null(专用于断言”不存在”);findBy 返回 Promise 等待出现(专用于异步)。
4. 完整示例:购物车条目组件
//CartItem.tsx
import { useState } from 'react';
interface Props {
name: string;
price: number;
onRemove: (name: string) => void;
}
export function CartItem({ name, price, onRemove }: Props) {
const [qty, setQty] = useState(1);
const total = qty * price;
return (
<div>
<span>{name}</span>
<span>单价 {price} 元</span>
<button onClick={() => setQty((q) => q + 1)}>增加数量</button>
<output>共 {total} 元</output>
<button onClick={() => onRemove(name)}>移除商品</button>
</div>
);
}
//CartItem.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import { CartItem } from './CartItem';
describe('CartItem', () => {
it('默认数量为 1,显示单价', () => {
render(<CartItem name="钢笔" price={10} onRemove={() => {}} />);
// toBeInTheDocument 来自 @testing-library/jest-dom
expect(screen.getByText('钢笔')).toBeInTheDocument();
expect(screen.getByText('共 10 元')).toBeInTheDocument();
});
it('点击"增加数量"后金额按数量累加', async () => {
const user = userEvent.setup(); // 必须先 setup,模拟真实事件序列
render(<CartItem name="钢笔" price={10} onRemove={() => {}} />);
await user.click(screen.getByRole('button', { name: '增加数量' }));
await user.click(screen.getByRole('button', { name: '增加数量' }));
expect(screen.getByText('共 30 元')).toBeInTheDocument(); // 1 -> 3 件
});
it('移除时把商品名回传给父组件', async () => {
const onRemove = vi.fn(); // Vitest 的 mock 函数(Jest 里是 jest.fn)
const user = userEvent.setup();
render(<CartItem name="钢笔" price={10} onRemove={onRemove} />);
await user.click(screen.getByRole('button', { name: '移除商品' }));
expect(onRemove).toHaveBeenCalledExactlyOnceWith('钢笔');
});
});
预期运行结果:3 个用例全部通过。注意第二条用例断言的是界面上的”共 30 元”而非内部 qty === 3——这就是行为测试。
5. 异步与网络:findBy 与 MSW
异步 UI(加载态、请求结果)是组件测试的主要难点。组件内 await 的渲染结果用 findBy* 等待:
it('加载完成后显示用户名', async () => {
render(<UserProfile id={1} />);
// findByText 会轮询等待元素出现(默认 1 秒超时)
expect(await screen.findByText('张三')).toBeInTheDocument();
});
it('加载失败显示错误提示', async () => {
render(<UserProfile id={-1} />);
expect(await screen.findByRole('alert')).toHaveTextContent('加载失败');
});
请求本身用 MSW 拦截——不 mock 你的代码(不替换 fetch 函数),而是 mock 网络,测试代码与生产代码走完全一样的路径:
// test/handlers.ts
import { http, HttpResponse } from 'msw';
export const handlers = [
http.get('/api/user/:id', ({ params }) =>
params.id === '-1'
? HttpResponse.json({ message: 'not found' }, { status: 404 })
: HttpResponse.json({ id: params.id, name: '张三' }),
),
];
// 在 vitest 的 setupFiles 中:setupServer(...handlers).listen()
6. Mock 与定时器
import { afterEach, vi } from 'vitest';
// 替换整个模块(如工具函数、第三方 SDK)
vi.mock('@/api/user', () => ({
fetchUser: vi.fn().mockResolvedValue({ id: 1, name: '张三' }),
}));
// 只替换对象上的某个方法,并防止测试期间真的打日志
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
afterEach(() => spy.mockRestore()); // 必须 restore,否则污染其他用例
// 假定时器:测试防抖/节流不真的等 300ms
vi.useFakeTimers();
// ...渲染带防抖的组件
vi.advanceTimersByTime(300); // 时间快进
vi.useRealTimers();
不需要手动 cleanup:Testing Library 会在每个用例后自动卸载组件(Vitest/Jest 均已内置)。
7. 测试金字塔怎么落地
- 单测(多数):纯函数、自定义 Hook(用
renderHook)、组件交互——毫秒级,随提交跑 - 集成(少量):整页 + 路由 + MSW,覆盖关键用户流程(登录、下单)
- E2E(极少):Playwright 跑冒烟路径,见React 与 CI/CD
不值得测的东西也要想清楚:第三方库的内部(<Link> 怎么渲染)、样式类名、纯展示的静态文本——这些交给类型检查、Lint 与视觉回归工具。
8. 常见陷阱
userEvent.click忘了await:事件处理与状态更新是异步的,漏 await 会读到旧 UI;也不要混用fireEvent与userEvent(后者是前者的高级封装,含焦点/键盘序列)。- act 警告:几乎总是”状态更新发生在测试的 await 之外”——用
findBy*/waitFor等待,而不是手动包act()。 - 断言”不存在”用 getBy:
getByText('x')找不到直接抛错,断言不存在必须用expect(queryByText('x')).not.toBeInTheDocument()。 - 遮罩/动画导致 userEvent 失败:pointer-events 检查报错时,通常是元素被遮挡或正在动画;修测试环境样式而非加
force。 - 多个匹配元素抛错:
getByText('删除')命中多个按钮时用getAllBy*或用更精确的within(row).getByRole(...)收窄范围。 - console.error 被吞:为了输出干净 mock 掉
console.error却忘了mockRestore,会掩盖真实错误(包括 React 的 key 警告)。 - 快照滥用:整组件 snapshot 测试”永远绿”或”永远红”,失去回归价值;只对小而稳定的 DOM 片段使用。
9. 小结
初学者要点:
- 四件套:Vitest + @testing-library/react + user-event + jest-dom;网络 mock 用 MSW。
- 查询优先
getByRole,断言”不存在”用queryBy,等异步用findBy/waitFor。 - 测用户看到的行为(文本、角色、交互结果),不断言 state/props 这类实现细节。
进阶注意:
userEvent.setup()后所有交互都要await;假定时器测试防抖时与userEvent组合需advanceTimersByTime。- MSW 在网络层拦截,测试与生产代码同路径;handlers 可按用例覆写(
server.use)。 - 自定义 Hook 用
renderHook测试;组件级无障碍与文案问题顺带由getByRole与 jest-axe 兜住。
速查
jest-dom 常用断言
expect(el).toBeInTheDocument();
expect(el).toHaveTextContent('文本');
expect(el).toHaveAttribute('href', '/a');
expect(el).toHaveClass('active');
expect(screen.getByRole('button')).toBeDisabled();
expect(screen.getByRole('button')).toBeEnabled();
expect(screen.getByText('visible')).toBeVisible();
query 三兄弟
screen.getByRole('button', { name: '提交' }); // 立即取,找不到抛错
screen.queryByText('加载中'); // 取不到返回 null(断言不存在)
await screen.findByText('完成'); // 等待出现(异步)
Mock 工具(Vitest / Jest 对照)
vi.mock('@/api/user', () => ({ fetchUser: vi.fn().mockResolvedValue({ id: 1 }) }));
// Jest 等价:jest.mock('@/api/user', () => ({ fetchUser: jest.fn()... }))
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
afterEach(() => spy.mockRestore());
const mockFn = vi.fn();
mockFn.mockImplementation((id: string) => ({ id }));
mockFn.mockResolvedValue({ ok: true });
mockFn.mockRejectedValue(new Error('fail'));
自定义 Hook 测试
import { renderHook, act } from '@testing-library/react';
const { result } = renderHook(() => useCounter());
act(() => result.current.increment());
expect(result.current.count).toBe(1);