
一句话:React 测试的核心思想是「像用户一样测试」——用 Testing Library 按「角色/文本」查元素、用
userEvent模拟真实交互、断言「用户能看到什么」,而不是断言「组件内部的实现细节」。
一、测试金字塔
▲ 少
╱ ╲ E2E(Playwright/Cypress)
╱───╲ · 跑真实浏览器、真实后端(或 Mock)
╱ ╲ · 覆盖「关键业务路径」(登录、下单、支付)
╱ ╲ · 慢、脆、但置信度最高
╱─────────╲
╱ ╲ 集成/组件测试(Vitest + Testing Library)
╱ ╲ · 渲染组件、模拟交互、断言 UI
╱───────────────╲· 数量最多(性价比最高)
╱ ╲
╱───────────────────╲ 单元测试(纯函数)
· 工具函数、reducer、自定义 Hook 逻辑
▼ 多 · 快、稳定、但覆盖不到 UI
⭐ 实践建议:
· 纯逻辑(utils / reducer / 格式化)→ 单元测试(多写)
· 组件行为(渲染 + 交互 + 状态)→ 组件测试(主力)
· 关键流程(登录/下单)→ E2E(少而精)
· 不要追求「100% 覆盖率」——要覆盖「关键路径与易错分支」
二、Testing Library 的核心思想
⭐ 一句话:「测试应该越接近用户使用软件的方式,就越有信心。」
因此它提倡:
① 用「用户能感知的东西」定位元素 —— 角色(role)、标签(label)、文本(text)
❌ 不用「类名、id、组件内部 state」定位
② 用「用户能做的操作」交互 —— 点击、输入、Tab、回车
❌ 不直接调用「组件内部方法」或「setState」
③ 断言「用户能看到的结果」 —— 文本出现、元素可见、无障碍属性
❌ 不断言「内部实现」(某个 state 是几、某个方法被调了几次)
好处:
· 重构组件内部实现时,测试不会大面积失败(降低维护成本)
· 测试能发现「真正的用户可见 bug」(而不是「实现细节变了」)
· 天然推动无障碍(用 role/label 查询 → 会引导你写正确的语义标签)
// ✅ 好的测试:像用户一样
test('点击按钮后显示欢迎语', async () => {
const user = userEvent.setup();
render(<Greeting name="张三" />);
await user.click(screen.getByRole('button', { name: /打招呼/ }));
expect(screen.getByText('你好,张三!')).toBeInTheDocument();
});
// ❌ 不好的测试:断言实现细节
test('bad', () => {
const { container } = render(<Greeting name="张三" />);
container.querySelector('.greeting__inner')!.dispatchEvent(new MouseEvent('click'));
expect(container.querySelector('.msg')!.textContent).toBe('你好,张三!');
});
三、查询方式的优先级
⭐ 从「最像用户」到「最像实现」的推荐顺序:
① getByRole(首选)
screen.getByRole('button', { name: '提交' })
→ 既像用户(看到「按钮」),又验证了无障碍语义
② getByLabelText(表单元素首选)
screen.getByLabelText('用户名')
→ 验证了 label 与 input 的关联(无障碍)
③ getByPlaceholderText / getByText
screen.getByPlaceholderText('请输入关键词')
screen.getByText('暂无数据')
④ getByDisplayValue / getByAltText / getByTitle
screen.getByAltText('用户头像')
⑤ getByTestId(最后手段)
screen.getByTestId('custom-widget')
→ 只在「无法用前几种方式稳定定位」时用
| 查询 | 何时用 |
|---|---|
getByRole |
首选(按钮、输入框、链接、标题、列表) |
getByLabelText |
表单字段(验证 a11y 关联) |
getByText |
展示性文本 |
getByPlaceholderText |
有 placeholder 的输入框 |
getByAltText |
图片 |
getByTestId |
最后手段(无合适语义时) |
// 三种变体的区别(重要)
getByText('x'); // 找不到就【抛错】(同步)
queryByText('x'); // 找不到返回 null(用于「断言不存在」)
findByText('x'); // 返回 Promise(等到出现,默认 1s 超时)→ 用于异步
// ✅ 「断言不存在」必须用 queryBy
expect(screen.queryByText('加载中')).not.toBeInTheDocument();
// ✅ 「等某个东西出现」用 findBy
expect(await screen.findByText('张三')).toBeInTheDocument();
四、Vitest 配置
npm i -D vitest @testing-library/react @testing-library/user-event @testing-library/jest-dom \
jsdom @vitejs/plugin-react
// vitest.config.ts(或写在 vite.config.ts 里)
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom', // ⭐ 组件测试需要 DOM 环境
globals: true, // 用全局的 describe/it/expect
setupFiles: ['./src/test/setup.ts'], // 全局 setup
css: false, // 测试里不处理 CSS(更快)
coverage: {
provider: 'v8',
reporter: ['text', 'html'],
exclude: ['**/*.d.ts', '**/test/**', '**/*.stories.tsx'],
},
},
});
// src/test/setup.ts
import '@testing-library/jest-dom/vitest'; // ⭐ 扩展 expect(toBeInTheDocument 等)
import { cleanup } from '@testing-library/react';
import { afterEach } from 'vitest';
afterEach(() => cleanup()); // ⭐ 每个用例后清理 DOM
// package.json
{
"scripts": {
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage"
}
}
五、组件测试的完整示例
// TodoList.tsx
export function TodoList({ initial = [] }: { initial?: Todo[] }) {
const [todos, setTodos] = useState(initial);
const [text, setText] = useState('');
const add = () => {
if (!text.trim()) return;
setTodos((prev) => [...prev, { id: nanoid(), text, done: false }]);
setText('');
};
return (
<div>
<label htmlFor="new-todo">新任务</label>
<input
id="new-todo"
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="输入任务"
/>
<button onClick={add} disabled={!text.trim()}>添加</button>
{todos.length === 0 ? (
暂无任务
) : (
<ul>
{todos.map((t) => (
<li key={t.id}>
<input
type="checkbox"
checked={t.done}
onChange={() => setTodos((prev) => prev.map((x) => (x.id === t.id ? { ...x, done: !x.done } : x)))}
aria-label={`标记「${t.text}」为完成`}
/>
{t.text}
</li>
))}
</ul>
)}
</div>
);
}
// TodoList.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect } from 'vitest';
import { TodoList } from './TodoList';
describe('TodoList', () => {
it('空列表时显示空状态', () => {
render(<TodoList />);
expect(screen.getByText('暂无任务')).toBeInTheDocument();
expect(screen.queryByRole('list')).not.toBeInTheDocument(); // ⭐ 断言不存在用 queryBy
});
it('输入为空时「添加」按钮禁用', async () => {
const user = userEvent.setup();
render(<TodoList />);
expect(screen.getByRole('button', { name: '添加' })).toBeDisabled();
await user.type(screen.getByLabelText('新任务'), '学习 React');
expect(screen.getByRole('button', { name: '添加' })).toBeEnabled();
});
it('可以添加任务并清空输入框', async () => {
const user = userEvent.setup();
render(<TodoList />);
const input = screen.getByLabelText('新任务');
await user.type(input, '学习 React');
await user.click(screen.getByRole('button', { name: '添加' }));
expect(screen.getByText('学习 React')).toBeInTheDocument();
expect(input).toHaveValue(''); // ⭐ 输入框被清空
expect(screen.queryByText('暂无任务')).not.toBeInTheDocument();
});
it('可以勾选任务', async () => {
const user = userEvent.setup();
render(<TodoList initial={[{ id: '1', text: '读书', done: false }]} />);
const checkbox = screen.getByRole('checkbox', { name: /标记「读书」为完成/ });
expect(checkbox).not.toBeChecked();
await user.click(checkbox);
expect(checkbox).toBeChecked();
});
});
// ✅ 异步测试(数据加载)
it('加载完成后显示列表', async () => {
vi.mocked(api.getUsers).mockResolvedValue([{ id: '1', name: '张三' }]);
render(<UserList />);
expect(screen.getByText('加载中…')).toBeInTheDocument(); // 初始状态
expect(await screen.findByText('张三')).toBeInTheDocument(); // ⭐ 等异步结果
expect(screen.queryByText('加载中…')).not.toBeInTheDocument(); // 加载态消失
});
六、userEvent vs fireEvent
| 维度 | fireEvent |
userEvent |
|---|---|---|
| 模拟程度 | 「直接派发一个事件」 | 「模拟用户的一整套行为」 |
| 输入 | fireEvent.change(input, { target: { value: 'x' } })(一次给全值) |
await user.type(input, 'x')(逐字符 + 焦点 + 中间事件) |
| 点击 | fireEvent.click(el) |
await user.click(el)(含 pointer 事件、焦点变化) |
| 是否要 await | ❌ | ✅(都是异步) |
| 推荐 | 做「特定底层事件」时用 | 默认用它 |
// ✅ 推荐 userEvent:更接近真实用户(能发现「只有真实交互才暴露的 bug」)
const user = userEvent.setup();
await user.type(input, 'hello'); // 逐个字符输入
await user.click(button); // 完整的点击流程
await user.tab(); // Tab 键切换焦点
await user.keyboard('{Enter}'); // 键盘
await user.selectOptions(select, 'bj'); // 选择
await user.upload(fileInput, file); // 上传
await user.hover(el); // 悬停
七、Mock
// ① Mock 函数(vi.fn)
const handleClick = vi.fn();
render(<Button onClick={handleClick}>点我</Button>);
await userEvent.click(screen.getByRole('button'));
expect(handleClick).toHaveBeenCalledTimes(1);
expect(handleClick).toHaveBeenCalledWith(expect.any(Object)); // 参数是事件对象
// ② Mock 模块(vi.mock)
vi.mock('@/api/user', () => ({
getUsers: vi.fn().mockResolvedValue([{ id: '1', name: '张三' }]),
}));
// ③ Mock 定时器(避免真的等 3 秒)
vi.useFakeTimers();
render(<DebouncedSearch />);
await userEvent.type(screen.getByRole('textbox'), 'react');
vi.advanceTimersByTime(300); // ⭐ 快进
expect(api.search).toHaveBeenCalledWith('react');
vi.useRealTimers();
// ④ 用 MSW(Mock Service Worker)拦截「网络请求」——最接近真实
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';
const server = setupServer(
http.get('/api/users', () => HttpResponse.json([{ id: '1', name: '张三' }]))
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
⭐ Mock 的三条原则:
① 【优先 Mock「网络边界」】(MSW)而不是「Mock 掉整个模块」
→ 因为组件内部的逻辑仍被真实执行(测试更有价值)
② 【只 Mock「慢/不稳定/外部」的东西】—— 定时器、网络、随机、时间和日期
③ 【不要 Mock「被测组件本身」】—— 那样等于没测
八、E2E(Playwright)
// e2e/login.spec.ts
import { test, expect } from '@playwright/test';
test('用户可以登录并看到仪表盘', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('邮箱').fill('user@example.com');
await page.getByLabel('密码').fill('password123');
await page.getByRole('button', { name: '登录' }).click();
await expect(page.getByRole('heading', { name: '仪表盘' })).toBeVisible();
await expect(page).toHaveURL('/dashboard');
});
⭐ E2E 的取舍:
✅ 优点:覆盖「真实浏览器 + 真实后端」→ 置信度最高(能发现集成问题)
❌ 缺点:慢(分钟级)、脆(受网络/环境影响)、维护成本高
✅ 实践:
· 只覆盖「关键业务路径」(登录、核心流程、支付)
· 用「数据准备 API」而非「UI 操作」来构造前置状态(更快更稳)
· 在 CI 里跑(本地只在需要时跑)
· 用「测试专用账号/环境」避免污染真实数据
九、测什么、不测什么
✅ 值得测:
① 关键业务逻辑(utils、reducer、计算、格式化)
② 组件的「用户可见行为」(渲染、交互、状态变化、错误态)
③ 边界与异常(空数据、加载中、错误、超长文本)
④ 无障碍(用 role/label 查询天然覆盖了一部分)
⑤ 自定义 Hook 的逻辑(用 renderHook)
❌ 不值得测:
① 「内部实现」(某个 state 是几、私有方法被调了几次)
② 第三方库的行为(那是库的测试职责)
③ 「样式细节」(颜色/间距 —— 用视觉回归测试更合适)
④ 「大量组合的穷举」(选关键组合)
⑤ 覆盖率数字本身(不要为了覆盖率写无意义的断言)
// ✅ 测试自定义 Hook(用 renderHook)
import { renderHook, act } from '@testing-library/react';
import { useCounter } from './useCounter';
test('useCounter 可以增减', () => {
const { result } = renderHook(() => useCounter(0));
act(() => result.current.increment());
expect(result.current.count).toBe(1);
act(() => result.current.decrement());
expect(result.current.count).toBe(0);
});
十、常见坑
// ① 用容器查询(container.querySelector)定位元素
container.querySelector('.btn')!.click(); // ❌ 脆弱、测了实现
screen.getByRole('button', { name: '提交' }); // ✅
// ② 忘记 await userEvent 的操作(都是异步)
await user.click(button); // ✅
user.click(button); // ⚠️ 可能断言在操作前执行
// ③ 「断言不存在」用了 getBy(会抛错而不是返回 null)
expect(screen.getByText('加载中')).not.toBeInTheDocument(); // ❌ 找不到就抛错
expect(screen.queryByText('加载中')).not.toBeInTheDocument(); // ✅
// ④ 用「固定延迟」等待异步(不稳定的根源)
await new Promise((r) => setTimeout(r, 1000)); // ❌
expect(screen.getByText('张三')).toBeInTheDocument();
expect(await screen.findByText('张三')).toBeInTheDocument(); // ✅
// ⑤ 忘了 cleanup(多个用例互相污染 DOM)
// ⑥ 每个用例都「重新创建 store/QueryClient」(否则状态泄漏)
const renderWithProviders = (ui: React.ReactElement) =>
render(
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
{ui}
</QueryClientProvider>
);
// ⭐ 注意 retry: false(否则失败用例会等重试超时)
// ⑦ Mock 了「被测组件」自身(等于没测)
// ⑧ 测试「实现细节」(重构就全红 → 测试成为负担)
// ⑨ 不用 userEvent 而用 fireEvent(遗漏真实交互的中间事件)
// ⑩ 用 testid 定位一切(失去了「像用户一样测试」的价值)
// ⑪ 测试里用真实定时器(慢且不稳定)→ 用 fake timers
// ⑫ 断言「CSS 类名」(样式重构就红)→ 应该断言「可见性/无障碍属性」
// ⑬ 没有测「错误态 / 空态 / 加载态」(最常见的线上问题)
// ⑭ 用 MSW 之外的方式「Mock 整个请求模块」(组件内部逻辑没被真实执行)
// ⑮ 在测试里「依赖执行顺序」(用例之间应互相独立)
// ⑯ E2E 里用 UI 操作构造数据(慢且脆)→ 用 API 准备
// ⑰ 认为「覆盖率 100% 就等于没问题」(覆盖率只是「行被执行过」)
// ⑱ 测试跑在 CI 里但没有「失败就阻断」的门禁
十一、面试延伸
- 「React 组件怎么测试?」
用 Vitest + Testing Library:render渲染组件、用screen.getByRole/getByLabelText/getByText定位元素、用userEvent模拟真实交互(点击、输入、Tab)、用expect断言「用户能看到的结果」(文本出现、元素可见、toBeChecked/toHaveValue等)。异步用findBy*(等元素出现)或waitFor。核心思想是「像用户一样测试」——按语义角色查询、按用户操作交互、断言可见结果,而不是断言内部实现。 -
「为什么推荐 Testing Library 而不是 Enzyme?」
因为它的哲学不同:Enzyme 鼓励「测试实现细节」(查 state、调用内部方法、按 CSS 选择器找节点),而 Testing Library 鼓励「测试用户可见行为」(按 role/label/text 查询、模拟真实交互)。后果是:① Enzyme 的测试在重构后会大面积失败(因为实现变了),维护成本高;② Testing Library 的测试能发现真正的用户可见 bug;③ 用 role/label 查询会自然推动无障碍(迫使你写正确的语义标签)。一句话:Enzyme 测「组件怎么实现」,Testing Library 测「用户能不能用」。 -
「查询元素的优先级是什么?」
从「最像用户」到「最像实现」:getByRole(首选,同时验证无障碍语义)→getByLabelText(表单)→getByPlaceholderText/getByText→getByDisplayValue/getByAltText/getByTitle→getByTestId(最后手段)。getByTestId应该少用——只有当「没有合适的语义/文本可查」时(如「一个没有可见文本的自定义控件」)才用它,并且应该给data-testid起有意义的名字。 -
「
getBy/queryBy/findBy有什么区别?」
getBy——同步查找,找不到就抛错(用于「元素一定存在」的断言);queryBy——同步查找,找不到返回null(用于「断言不存在」:expect(queryByText('x')).not.toBeInTheDocument());findBy——异步(返回 Promise),会等元素出现(默认 1 秒超时,可用waitFor定制),用于「异步渲染后才出现的元素」。三个变体都有All版本(getAllBy/queryAllBy/findAllBy)用于「多个匹配」。 -
「
userEvent和fireEvent有什么区别?」
fireEvent是「直接派发一个事件」(如fireEvent.change(input, { target: { value: 'x' } })一次性把值设好),userEvent是「模拟用户的一整套行为」——如user.type(input, 'x')会「聚焦、逐字符输入、派发中间的keydown/keypress/input事件、处理光标位置」。因此:①userEvent更接近真实用户,能发现「只有真实交互才暴露的 bug」(如「输入每个字符都触发请求」的性能问题);②userEvent是异步的(必须await);③ 默认用userEvent,只有需要「派发特定底层事件」(如某些浏览器的特殊事件)时才用fireEvent。 -
「测试里怎么 Mock 网络请求?」
推荐用 MSW(Mock Service Worker)——它在「网络层」拦截请求(浏览器用 Service Worker、Node 用msw/node的拦截器),组件内部的逻辑(含 request 封装、状态管理、缓存)都会真实执行。相比之下「把整个 API 模块vi.mock掉」会「跳过封装层」,测试的置信度更低。MSW 的用法是「setupServer定义 handlers →beforeAll(server.listen)→afterEach(server.resetHandlers)→afterAll(server.close)」,并在具体用例里用server.use(...)覆盖特定接口。 -
「测什么、不测什么?」
值得测:① 关键业务逻辑(utils、reducer、计算、格式化);② 组件的用户可见行为(渲染、交互、状态变化、错误态/空态/加载态);③ 边界与异常(空数据、超长文本、权限不足);④ 自定义 Hook 的逻辑(renderHook)。不值得测:① 内部实现(某个 state 是几、私有方法被调几次);② 第三方库的行为;③ 样式细节(应该用视觉回归测试);④ 组合的穷举;⑤ 覆盖率数字本身(覆盖率只表示「行被执行过」,不代表「逻辑正确」)。 -
「测试如何避免『脆弱』(重构就大面积失败)?」
五条:① 断言「用户可见行为」而不是实现(用getByRole/getByText而不是querySelector('.cls')、不断言 CSS 类名);② 按「角色/标签」查询(元素结构变了仍能找到);③ 等待用findBy*/waitFor而不是固定延时(避免时序脆弱);④ Mock 边界而不是 Mock 一切(MSW 拦截网络,组件内部保持真实);⑤ 测试之间互相独立(每个用例重新渲染、重建 store/QueryClient,afterEach(cleanup))。判断标准是「组件内部重构后,测试是否还能通过」——能通过说明测的是「行为」,不能通过说明测的是「实现」。
一句话速记
用 Vitest + Testing Library(像用户一样测试):定位用
getByRole(首选)/getByLabelText/getByText,getByTestId是最后手段;三个变体是「getBy找不到就抛错、queryBy找不到返回 null(用于断言不存在)、findBy异步等待(await)」;交互用userEvent(异步、更真实,比fireEvent好);Mock 优先用 MSW 拦截网络边界(或用vi.fn/vi.mock/假定时器),别 Mock 被测组件自身;断言「用户能看到的结果」而不是实现细节(否则重构就全红);必测「错误态/空态/加载态」;不用固定延时等待;E2E 只覆盖关键路径(Playwright);不用追求 100% 覆盖率。



最新评论
读过书不知道欧·亨利的人少。教科书上选文有
这小生活不错呀
不错,必须顶一下!
看着你还在坚持,很好
看来忙了也没时间更新博客了
NIce。学习了。。。。
网站不错!!!!
简洁实用,好文章!