组件测试 (React Testing Library)
概述
React Testing Library (RTL) 是 React 组件测试的标准工具,遵循 "测试用户行为而非实现细节" 的理念。
一、核心理念
测试原则
"你的测试越像用户使用软件的方式,它们就越能给你信心。"
—— Kent C. Dodds| 传统测试 | RTL 测试 |
|---|---|
| 测试组件状态 | 测试 DOM 输出 |
| 测试生命周期 | 测试用户交互 |
| 浅渲染 | 完整渲染 |
| 实现细节 | 用户行为 |
二、安装与配置
bash
npm install --save-dev @testing-library/react @testing-library/jest-dom @testing-library/user-eventSetup 文件
javascript
// jest.setup.js 或 vitest.setup.ts
import '@testing-library/jest-dom';
// 可选:全局清理
import { cleanup } from '@testing-library/react';
afterEach(() => {
cleanup();
});三、查询方法
查询优先级 (推荐顺序)
javascript
// 1. getByRole - 最推荐,符合可访问性
getByRole('button', { name: /submit/i })
getByRole('textbox', { name: /email/i })
getByRole('heading', { level: 1 })
// 2. getByLabelText - 表单元素首选
getByLabelText(/username/i)
// 3. getByPlaceholderText - 没有 label 时
getByPlaceholderText(/enter email/i)
// 4. getByText - 非交互元素
getByText(/welcome/i)
// 5. getByDisplayValue - 表单当前值
getByDisplayValue(/john@example.com/i)
// 6. getByAltText - 图片
getByAltText(/company logo/i)
// 7. getByTitle - title 属性
getByTitle(/close/i)
// 8. getByTestId - 最后手段
getByTestId('custom-element')查询变体
| 前缀 | 无匹配 | 多个匹配 | 返回 | 异步 |
|---|---|---|---|---|
| getBy | 抛错 | 抛错 | 元素 | 否 |
| queryBy | null | 抛错 | 元素/null | 否 |
| findBy | 抛错 | 抛错 | Promise | 是 |
| getAllBy | 抛错 | ✅ | 数组 | 否 |
| queryAllBy | [] | ✅ | 数组 | 否 |
| findAllBy | 抛错 | ✅ | Promise | 是 |
使用场景
javascript
// 元素必须存在
const button = screen.getByRole('button');
// 元素可能不存在
const error = screen.queryByText(/error/i);
expect(error).not.toBeInTheDocument();
// 等待元素出现 (异步)
const data = await screen.findByText(/loaded/i);
// 多个元素
const items = screen.getAllByRole('listitem');
expect(items).toHaveLength(3);四、用户事件
user-event vs fireEvent
javascript
import userEvent from '@testing-library/user-event';
import { fireEvent } from '@testing-library/react';
// ❌ fireEvent - 直接触发 DOM 事件
fireEvent.click(button);
fireEvent.change(input, { target: { value: 'test' } });
// ✅ userEvent - 模拟真实用户行为
const user = userEvent.setup();
await user.click(button);
await user.type(input, 'test');常用交互
javascript
const user = userEvent.setup();
// 点击
await user.click(element);
await user.dblClick(element);
await user.tripleClick(element); // 选中文本
// 键盘输入
await user.type(input, 'Hello World');
await user.type(input, '{enter}');
await user.type(input, '{backspace}');
// 清空并输入
await user.clear(input);
await user.type(input, 'new value');
// 键盘操作
await user.keyboard('{Shift>}A{/Shift}'); // Shift+A
await user.keyboard('[ControlLeft>][KeyC][/ControlLeft]'); // Ctrl+C
// Tab 导航
await user.tab();
await user.tab({ shift: true }); // Shift+Tab
// 选择
await user.selectOptions(select, ['option1', 'option2']);
await user.deselectOptions(select, 'option1');
// 悬停
await user.hover(element);
await user.unhover(element);
// 上传文件
const file = new File(['content'], 'file.txt', { type: 'text/plain' });
await user.upload(input, file);
// 剪贴板
await user.copy();
await user.cut();
await user.paste();五、异步测试
waitFor
javascript
import { waitFor } from '@testing-library/react';
// 等待条件满足
await waitFor(() => {
expect(screen.getByText(/success/i)).toBeInTheDocument();
});
// 配置选项
await waitFor(
() => expect(mockFn).toHaveBeenCalled(),
{
timeout: 3000, // 超时时间
interval: 100, // 检查间隔
onTimeout: (error) => { /* 超时回调 */ }
}
);findBy (推荐)
javascript
// findBy = getBy + waitFor
const element = await screen.findByText(/loaded/i);
// 等同于
await waitFor(() => screen.getByText(/loaded/i));waitForElementToBeRemoved
javascript
import { waitForElementToBeRemoved } from '@testing-library/react';
// 等待元素消失
await waitForElementToBeRemoved(() => screen.queryByText(/loading/i));六、渲染与重渲染
基础渲染
javascript
import { render, screen } from '@testing-library/react';
test('renders component', () => {
render(<Button>Click me</Button>);
expect(screen.getByRole('button')).toHaveTextContent('Click me');
});带 Provider 渲染
javascript
// test-utils.js
import { render } from '@testing-library/react';
import { ThemeProvider } from 'styled-components';
import { Provider } from 'react-redux';
import { BrowserRouter } from 'react-router-dom';
const AllProviders = ({ children }) => {
return (
<Provider store={store}>
<ThemeProvider theme={theme}>
<BrowserRouter>
{children}
</BrowserRouter>
</ThemeProvider>
</Provider>
);
};
const customRender = (ui, options) =>
render(ui, { wrapper: AllProviders, ...options });
export * from '@testing-library/react';
export { customRender as render };重渲染
javascript
test('re-renders with new props', () => {
const { rerender } = render(<Counter count={0} />);
expect(screen.getByText('0')).toBeInTheDocument();
rerender(<Counter count={1} />);
expect(screen.getByText('1')).toBeInTheDocument();
});七、常见测试模式
表单测试
javascript
test('submits form with valid data', async () => {
const handleSubmit = jest.fn();
const user = userEvent.setup();
render(<LoginForm onSubmit={handleSubmit} />);
// 填写表单
await user.type(screen.getByLabelText(/email/i), 'test@example.com');
await user.type(screen.getByLabelText(/password/i), 'password123');
// 提交
await user.click(screen.getByRole('button', { name: /submit/i }));
// 验证
expect(handleSubmit).toHaveBeenCalledWith({
email: 'test@example.com',
password: 'password123'
});
});条件渲染测试
javascript
test('shows error when loading fails', async () => {
// Mock API 失败
server.use(
rest.get('/api/user', (req, res, ctx) => {
return res(ctx.status(500));
})
);
render(<UserProfile />);
// 等待错误显示
expect(await screen.findByRole('alert')).toHaveTextContent(/error/i);
// 确认用户信息未显示
expect(screen.queryByText(/john/i)).not.toBeInTheDocument();
});模态框测试
javascript
test('opens and closes modal', async () => {
const user = userEvent.setup();
render(<App />);
// 打开模态框
await user.click(screen.getByRole('button', { name: /open modal/i }));
// 验证模态框内容
expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(screen.getByText(/modal content/i)).toBeInTheDocument();
// 关闭模态框
await user.click(screen.getByRole('button', { name: /close/i }));
// 验证模态框已关闭
await waitForElementToBeRemoved(() => screen.queryByRole('dialog'));
});API Mock (MSW)
javascript
import { rest } from 'msw';
import { setupServer } from 'msw/node';
const server = setupServer(
rest.get('/api/users', (req, res, ctx) => {
return res(ctx.json([
{ id: 1, name: 'John' },
{ id: 2, name: 'Jane' }
]));
})
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
test('loads and displays users', async () => {
render(<UserList />);
// 等待数据加载
expect(await screen.findByText('John')).toBeInTheDocument();
expect(screen.getByText('Jane')).toBeInTheDocument();
});八、自定义 Matcher
jest-dom 扩展
javascript
import '@testing-library/jest-dom';
// 可见性
expect(element).toBeVisible();
expect(element).not.toBeVisible();
// 存在性
expect(element).toBeInTheDocument();
expect(element).toBeEmptyDOMElement();
// 属性
expect(element).toHaveAttribute('href', '/home');
expect(element).toHaveClass('active');
expect(element).toHaveStyle({ color: 'red' });
// 表单状态
expect(input).toBeDisabled();
expect(input).toBeEnabled();
expect(input).toBeRequired();
expect(checkbox).toBeChecked();
expect(input).toHaveValue('test');
expect(input).toBeInvalid();
// 焦点
expect(input).toHaveFocus();
// 文本
expect(element).toHaveTextContent(/hello/i);
expect(element).toHaveDisplayValue('selected option');
// 可访问性
expect(element).toHaveAccessibleName('Submit');
expect(element).toHaveAccessibleDescription('Submit the form');九、调试技巧
javascript
import { screen, prettyDOM } from '@testing-library/react';
// 打印 DOM
screen.debug(); // 打印整个 DOM
screen.debug(element); // 打印特定元素
// 获取 DOM 字符串
console.log(prettyDOM(element));
// 打印可访问性树
screen.logTestingPlaygroundURL(); // 生成 Playground URL
// 查看所有可用查询
import { screen } from '@testing-library/dom';
screen.getByRole(''); // 报错时会展示可用 role面试高频题
Q1: 为什么推荐 getByRole 而不是 getByTestId?
答:
getByRole测试可访问性,确保组件对屏幕阅读器友好- 更接近用户实际使用方式
- 降低测试与实现的耦合
getByTestId是最后手段,因为用户看不到 test-id
Q2: 什么时候用 queryBy vs getBy?
答:
getBy: 元素必须存在,否则抛错queryBy: 断言元素不存在时使用
javascript
expect(screen.queryByText(/error/i)).not.toBeInTheDocument();Q3: userEvent 和 fireEvent 的区别?
答:
fireEvent: 直接触发 DOM 事件userEvent: 模拟完整的用户交互(如 type 会触发 focus、keydown、input、change 等一系列事件)
Q4: 如何测试自定义 Hook?
答: 使用 @testing-library/react-hooks 的 renderHook
javascript
import { renderHook, act } from '@testing-library/react';
test('useCounter', () => {
const { result } = renderHook(() => useCounter());
expect(result.current.count).toBe(0);
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});