React Testing Library
测试理念:以用户视角测试
React Testing Library(RTL)的核心理念是:测试应该像用户使用应用一样,而不是测试组件的内部实现。
这个理念来源一个简单的观察:
「你的代码是因为用户的需求而存在的,不是为了满足测试覆盖率。」
当你测试实现细节时,每次重构都可能破坏测试,即使功能没有任何问题。而当你测试用户能看到、能交互的部分,测试就会跟随真实需求,而不是代码结构。
测试金字塔
/\
/ \ E2E Tests (少)
/----\ Integration Tests (中)
/ \ Unit Tests (多)
/--------\
RTL 主要用于 Integration Tests(集成测试),配合 Jest 的 Unit Tests 和 Cypress/Playwright 的 E2E Tests。
RTL vs Enzyme
核心区别
Enzyme 允许你测试组件的「内部」:
// Enzyme 风格:测试组件内部状态
const wrapper = shallow(<Counter />);
wrapper.setState({ count: 5 });
expect(wrapper.state('count')).toBe(5);
wrapper.find('button').simulate('click');
expect(wrapper.state('count')).toBe(6);
RTL 风格:只测试输入(props)和输出(DOM):
// RTL 风格:只测试用户能看到和交互的部分
render(<Counter />);
expect(screen.getByText('Count: 0')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Increment' }));
expect(screen.getByText('Count: 1')).toBeInTheDocument();
为什么 RTL 更可取
- 不测试实现细节:重构组件不会破坏测试
- 强制正确的测试位置:你必须在正确的层级测试(用户交互)
- 更好的可维护性:测试跟随 UI 变化,而不是代码结构
Query 系统
Query 优先级
RTL 提供了多种 query,优先级从高到低:
| 优先级 | Query 类型 | 示例 |
|---|---|---|
| 1 | getByRole |
getByRole('button', { name: 'Submit' }) |
| 2 | getByLabelText |
getByLabelText('Email') |
| 3 | getByPlaceholderText |
getByPlaceholderText('Enter email') |
| 4 | getByText |
getByText('Submit') |
| 5 | getByDisplayValue |
getByDisplayValue('[email protected]') |
| 6 | getByTestId |
getByTestId('submit-button') |
最佳实践:优先使用高优先级的 query,它们更接近用户的认知方式。
常用 Query 示例
// 按 role 查找(最推荐)
const button = screen.getByRole('button', { name: 'Submit' });
const heading = screen.getByRole('heading', { level: 1 });
const link = screen.getByRole('link', { name: 'Learn more' });
// 按 label 查找表单元素
const emailInput = screen.getByLabelText('Email Address');
const passwordInput = screen.getByLabelText(/password/i); // 支持正则
// 按文本内容查找
const paragraph = screen.getByText('Hello, world!');
const listItems = screen.getAllByText(/item \d+/);
// 按 testId 查找(最后手段)
const submitButton = screen.getByTestId('submit-button');
Async Queries
处理异步操作:
// findBy:等待元素出现(默认超时 1000ms)
const successMessage = await screen.findByText('Saved!');
// waitFor:等待条件满足
await waitFor(() => {
expect(screen.getByText('Hello')).toBeInTheDocument();
});
// waitFor 的超时和 interval 可以配置
await waitFor(
() => expect(screen.getByText('Loaded')).toBeInTheDocument(),
{ timeout: 2000, interval: 100 }
);
Fire Events vs User Events
fireEvent
fireEvent 直接触发 DOM 事件:
import { fireEvent } from '@testing-library/react';
test('handles click', () => {
render(<Button>Click me</Button>);
fireEvent.click(screen.getByRole('button'));
});
问题:fireEvent 不会模拟完整的用户交互(比如 input 事件的 target.value 设置)。
userEvent
@testing-library/user-event 模拟更真实的用户行为:
import userEvent from '@testing-library/user-event';
test('types in input', async () => {
const user = userEvent.setup();
render(<Input />);
await user.type(screen.getByLabelText('Email'), '[email protected]');
expect(screen.getByDisplayValue('[email protected]')).toBeInTheDocument();
});
userEvent 的优势:
- 正确设置
input的value - 触发
change事件 - 处理
blur等相关事件
Setup 和 Cleanup
测试文件的结构
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
// 共享的 setup
const setup = (jsx) => {
return {
user: userEvent.setup(),
...render(jsx),
};
};
test('example', async () => {
const { user } = setup(<Component />);
await user.click(screen.getByRole('button'));
});
在每个测试后 cleanup
默认情况下,render 会自动清理。如果需要手动清理:
import { render, cleanup } from '@testing-library/react';
afterEach(cleanup);
常见测试场景
测试表单提交
test('submits form with correct data', async () => {
const handleSubmit = jest.fn();
const user = userEvent.setup();
render(<LoginForm onSubmit={handleSubmit} />);
await user.type(screen.getByLabelText('Email'), '[email protected]');
await user.type(screen.getByLabelText('Password'), 'password123');
await user.click(screen.getByRole('button', { name: 'Sign in' }));
expect(handleSubmit).toHaveBeenCalledWith({
email: '[email protected]',
password: 'password123',
});
});
测试异步数据加载
test('displays loading state then data', async () => {
render(<UserProfile userId="1" />);
// 初始状态:显示加载中
expect(screen.getByRole('progressbar')).toBeInTheDocument();
// 等待数据加载完成
const heading = await screen.findByRole('heading', { name: 'John Doe' });
expect(heading).toBeInTheDocument();
// 加载指示器消失
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
});
测试错误状态
test('displays error message on failure', async () => {
server.use(
rest.get('/api/user', (req, res, ctx) => {
return res(ctx.status(500), ctx.json({ message: 'Server Error' }));
})
);
render(<UserProfile userId="1" />);
const errorMessage = await screen.findByText('Something went wrong');
expect(errorMessage).toBeInTheDocument();
});
Mock 和 Spy
Mock 函数
test('calls callback with arguments', () => {
const callback = jest.fn();
render(<Button onClick={callback} />);
fireEvent.click(screen.getByRole('button'));
expect(callback).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledWith(expect.objectContaining({
type: 'click'
}));
});
Mock 模块
jest.mock('./api', () => ({
fetchUser: jest.fn()
}));
import { fetchUser } from './api';
test('loads user', async () => {
fetchUser.mockResolvedValue({ name: 'Alice' });
render(<UserProfile userId="1" />);
expect(await screen.findByText('Alice')).toBeInTheDocument();
});
配合其他库
MSW (Mock Service Worker)
import { setupServer } from 'msw/node';
import { render, screen } from '@testing-library/react';
import { handlers } from './mocks/handlers';
const server = setupServer(...handlers);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
test('loads user from API', async () => {
render(<UserProfile userId="1" />);
expect(await screen.findByText('John Doe')).toBeInTheDocument();
});
测试路由
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
render(
<MemoryRouter initialEntries={['/users/1']}>
<Routes>
<Route path="/users/:id" element={<UserProfile />} />
</Routes>
</MemoryRouter>
);
面试中的表达
面试中聊到 Testing Library,通常是在考察你对测试理念和实践的理解:
React Testing Library 的核心理念是「以用户视角测试」。它不鼓励测试组件内部状态,而是测试用户能看到、能交互的部分。这不是偷懒,而是正确的测试哲学——你的测试应该跟随功能,而不是代码结构。
关于 query 的选择,我遵循的原则是:优先使用
getByRole,因为这是最接近用户认知的方式。如果找不到,再考虑getByLabelText、getByText,最后才是getByTestId。关于 fireEvent 和 userEvent,我推荐使用 userEvent。它模拟更真实的用户行为,比如在
user.type中会正确触发input、change等事件,而 fireEvent 只是简单触发事件。
延展阅读
- Testing Library 官方文档 — RTL 完整文档
- Priority of Queries — Query 优先级指南
- Which query should I use? — 如何选择 query
- Kent C. Dodds: Testing Library — 测试金字塔与 Testing Library