Testing Library 最佳实践 #
核心原则 #
Testing Library 的核心原则是"测试越接近用户使用软件的方式,测试就越可靠":
text
┌─────────────────────────────────────────────────────────────┐
│ 测试核心原则 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 1. 用户视角 │
│ ├── 测试用户看到的内容 │
│ ├── 测试用户交互的行为 │
│ └── 避免测试实现细节 │
│ │
│ 2. 可访问性优先 │
│ ├── 使用语义化查询 │
│ ├── 关注无障碍性 │
│ └── 提高代码可访问性 │
│ │
│ 3. 可维护性 │
│ ├── 测试应该稳定 │
│ ├── 重构不应破坏测试 │
│ └── 测试代码要清晰 │
│ │
└─────────────────────────────────────────────────────────────┘
查询优先级 #
推荐的查询顺序 #
jsx
// ✅ 1. getByRole - 最推荐
screen.getByRole('button', { name: /submit/i });
screen.getByRole('textbox', { name: /email/i });
screen.getByRole('heading', { name: /welcome/i });
// ✅ 2. getByLabelText - 表单元素
screen.getByLabelText(/email/i);
screen.getByLabelText(/password/i);
// ✅ 3. getByPlaceholderText - 输入框
screen.getByPlaceholderText('Search...');
// ✅ 4. getByText - 文本内容
screen.getByText('Hello World');
// ✅ 5. getByDisplayValue - 当前值
screen.getByDisplayValue('Selected option');
// ⚠️ 6. getByAltText - 图片
screen.getByAltText('Product image');
// ⚠️ 7. getByTitle - 标题属性
screen.getByTitle('Close');
// ❌ 8. getByTestId - 最后手段
screen.getByTestId('submit-button');
查询选择指南 #
| 元素类型 | 推荐查询 |
|---|---|
| 按钮 | getByRole('button', { name }) |
| 链接 | getByRole('link', { name }) |
| 输入框 | getByLabelText() |
| 标题 | getByRole('heading', { name }) |
| 图片 | getByRole('img', { name }) 或 getByAltText() |
| 列表项 | getByRole('listitem') |
| 对话框 | getByRole('dialog') |
| 表单 | getByRole('form') |
测试命名规范 #
好的测试命名 #
jsx
// ✅ 描述用户行为和预期结果
test('should display welcome message when user logs in', () => {});
test('should show error when password is incorrect', () => {});
test('should add item to cart when add button is clicked', () => {});
test('should disable submit button when form is invalid', () => {});
// ✅ 使用中文描述(团队约定)
test('用户登录成功后应该显示欢迎消息', () => {});
test('密码错误时应该显示错误提示', () => {});
不好的测试命名 #
jsx
// ❌ 太模糊
test('works', () => {});
test('test1', () => {});
// ❌ 描述技术细节
test('calls setState', () => {});
test('renders component', () => {});
// ❌ 太长
test('should display the welcome message when the user successfully logs in to the application', () => {});
测试组织 #
使用 describe 分组 #
jsx
describe('LoginForm', () => {
describe('rendering', () => {
test('should render email input', () => {});
test('should render password input', () => {});
test('should render submit button', () => {});
});
describe('validation', () => {
test('should show error for invalid email', () => {});
test('should show error for short password', () => {});
test('should show errors for empty fields', () => {});
});
describe('submission', () => {
test('should submit with valid credentials', () => {});
test('should show error on failed login', () => {});
});
});
AAA 模式 #
jsx
test('should add item to cart', async () => {
// Arrange - 准备
const user = userEvent.setup();
const product = { id: 1, name: 'Product', price: 100 };
render(<ProductCard product={product} />);
// Act - 执行
await user.click(screen.getByRole('button', { name: /add to cart/i }));
// Assert - 断言
expect(screen.getByText('Added to cart')).toBeInTheDocument();
});
测试文件结构 #
text
src/
├── components/
│ ├── Button/
│ │ ├── Button.jsx
│ │ ├── Button.test.jsx
│ │ └── Button.styles.js
│ └── Form/
│ ├── Form.jsx
│ └── Form.test.jsx
├── hooks/
│ ├── useCounter.js
│ └── useCounter.test.js
└── utils/
├── test-utils.jsx # 自定义渲染器
└── helpers.js
避免测试实现细节 #
不好的做法 #
jsx
// ❌ 测试组件状态
test('counter state', () => {
const wrapper = shallow(<Counter />);
expect(wrapper.state('count')).toBe(0);
wrapper.instance().increment();
expect(wrapper.state('count')).toBe(1);
});
// ❌ 测试组件方法
test('calls handleClick', () => {
const wrapper = shallow(<Button />);
const instance = wrapper.instance();
jest.spyOn(instance, 'handleClick');
wrapper.find('button').simulate('click');
expect(instance.handleClick).toHaveBeenCalled();
});
// ❌ 测试 DOM 结构
test('has correct structure', () => {
const wrapper = mount(<Card />);
expect(wrapper.find('.card-header').length).toBe(1);
expect(wrapper.find('.card-body').length).toBe(1);
});
好的做法 #
jsx
// ✅ 测试用户看到的内容
test('counter displays correct count', async () => {
const user = userEvent.setup();
render(<Counter />);
expect(screen.getByText('Count: 0')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /increment/i }));
expect(screen.getByText('Count: 1')).toBeInTheDocument();
});
// ✅ 测试用户交互结果
test('button click triggers action', async () => {
const onClick = jest.fn();
const user = userEvent.setup();
render(<Button onClick={onClick}>Click me</Button>);
await user.click(screen.getByRole('button', { name: /click me/i }));
expect(onClick).toHaveBeenCalled();
});
// ✅ 测试可见内容
test('card displays content', () => {
render(<Card title="Title" content="Content" />);
expect(screen.getByRole('heading', { name: /title/i })).toBeInTheDocument();
expect(screen.getByText('Content')).toBeInTheDocument();
});
用户交互最佳实践 #
使用 user-event #
jsx
// ✅ 推荐:使用 user-event
import userEvent from '@testing-library/user-event';
test('form submission', async () => {
const user = userEvent.setup();
render(<LoginForm />);
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: /login/i }));
expect(screen.getByText('Welcome')).toBeInTheDocument();
});
// ❌ 不推荐:使用 fireEvent
import { fireEvent } from '@testing-library/react';
test('form submission', () => {
render(<LoginForm />);
fireEvent.change(screen.getByLabelText(/email/i), {
target: { value: 'test@example.com' },
});
fireEvent.click(screen.getByRole('button', { name: /login/i }));
});
正确处理异步 #
jsx
// ✅ 使用 findBy 等待元素
test('loads data', async () => {
render(<DataComponent />);
const data = await screen.findByText('Loaded data');
expect(data).toBeInTheDocument();
});
// ✅ 使用 waitFor 复杂条件
test('multiple async updates', async () => {
render(<AsyncComponent />);
await waitFor(() => {
expect(screen.getByText('Step 1')).toBeInTheDocument();
expect(screen.getByText('Step 2')).toBeInTheDocument();
});
});
// ❌ 不推荐:使用固定等待
test('loads data', async () => {
render(<DataComponent />);
await new Promise(resolve => setTimeout(resolve, 1000));
expect(screen.getByText('Loaded data')).toBeInTheDocument();
});
测试隔离 #
每个测试独立 #
jsx
// ✅ 好的做法:每个测试独立
describe('Counter', () => {
beforeEach(() => {
render(<Counter />);
});
test('starts at 0', () => {
expect(screen.getByText('Count: 0')).toBeInTheDocument();
});
test('increments correctly', async () => {
const user = userEvent.setup();
await user.click(screen.getByRole('button', { name: '+' }));
expect(screen.getByText('Count: 1')).toBeInTheDocument();
});
});
// ❌ 不好的做法:测试之间有依赖
describe('Counter', () => {
let count = 0;
test('starts at 0', () => {
expect(count).toBe(0);
});
test('increments', () => {
count++; // 依赖上一个测试
expect(count).toBe(1);
});
});
清理副作用 #
jsx
// ✅ 清理定时器
afterEach(() => {
jest.useRealTimers();
jest.clearAllMocks();
});
// ✅ 清理 DOM
afterEach(() => {
cleanup();
});
// ✅ 重置模块
beforeEach(() => {
jest.resetModules();
});
Mock 最佳实践 #
合理使用 Mock #
jsx
// ✅ Mock 外部依赖
jest.mock('./api', () => ({
fetchUser: jest.fn(() => Promise.resolve({ name: 'John' })),
}));
// ✅ Mock 浏览器 API
beforeAll(() => {
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: jest.fn().mockImplementation(query => ({
matches: false,
media: query,
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
})),
});
});
// ❌ 过度 Mock
jest.mock('react', () => ({
...jest.requireActual('react'),
useState: jest.fn(), // 不要 Mock React
}));
Mock 清理 #
jsx
// ✅ 清理 Mock
afterEach(() => {
jest.clearAllMocks();
});
// ✅ 重置 Mock
beforeEach(() => {
fetchUser.mockReset();
});
// ✅ 恢复原始实现
afterAll(() => {
jest.restoreAllMocks();
});
测试覆盖边界情况 #
测试正常和异常情况 #
jsx
describe('divide function', () => {
test('divides positive numbers', () => {
expect(divide(10, 2)).toBe(5);
});
test('divides negative numbers', () => {
expect(divide(-10, 2)).toBe(-5);
});
test('handles zero dividend', () => {
expect(divide(0, 5)).toBe(0);
});
test('throws error for division by zero', () => {
expect(() => divide(10, 0)).toThrow('Division by zero');
});
});
测试边界值 #
jsx
describe('age validation', () => {
test('accepts minimum age', () => {
expect(isValidAge(18)).toBe(true);
});
test('rejects under minimum age', () => {
expect(isValidAge(17)).toBe(false);
});
test('accepts maximum age', () => {
expect(isValidAge(120)).toBe(true);
});
test('rejects over maximum age', () => {
expect(isValidAge(121)).toBe(false);
});
test('rejects negative age', () => {
expect(isValidAge(-1)).toBe(false);
});
});
性能优化 #
避免不必要的渲染 #
jsx
// ✅ 使用 rerender 测试 props 变化
test('updates on prop change', () => {
const { rerender } = render(<Counter initialCount={0} />);
expect(screen.getByText('Count: 0')).toBeInTheDocument();
rerender(<Counter initialCount={10} />);
expect(screen.getByText('Count: 10')).toBeInTheDocument();
});
并行测试 #
jsx
// ✅ 测试应该可以并行运行
describe('Component A', () => {
test('test 1', () => {});
test('test 2', () => {});
});
describe('Component B', () => {
test('test 1', () => {});
test('test 2', () => {});
});
常见陷阱 #
陷阱 1:使用 container.querySelector #
jsx
// ❌ 避免
const element = container.querySelector('.my-class');
// ✅ 推荐
const element = screen.getByRole('button');
陷阱 2:忘记 await #
jsx
// ❌ 忘记 await
test('async test', () => {
render(<AsyncComponent />);
screen.findByText('Loaded'); // 缺少 await
});
// ✅ 正确使用
test('async test', async () => {
render(<AsyncComponent />);
await screen.findByText('Loaded');
});
陷阱 3:使用不稳定的查询 #
jsx
// ❌ 使用索引
const button = screen.getAllByRole('button')[0];
// ✅ 使用具体特征
const button = screen.getByRole('button', { name: /submit/i });
陷阱 4:测试外部库的行为 #
jsx
// ❌ 测试第三方库
test('datepicker opens', () => {
render(<DatePicker />);
// 测试 datepicker 库的行为
});
// ✅ 测试你的代码
test('form submits selected date', async () => {
const onSubmit = jest.fn();
render(<DateForm onSubmit={onSubmit} />);
// 选择日期...
await user.click(screen.getByRole('button', { name: /submit/i }));
expect(onSubmit).toHaveBeenCalledWith(expect.any(Date));
});
测试工具函数 #
创建自定义渲染器 #
jsx
const AllProviders = ({ children }) => {
return (
<ThemeProvider>
<AuthProvider>
<Router>
{children}
</Router>
</AuthProvider>
</ThemeProvider>
);
};
function customRender(ui, options = {}) {
return render(ui, { wrapper: AllProviders, ...options });
}
export * from '@testing-library/react';
export { customRender as render };
创建测试数据生成器 #
jsx
function createMockUser(overrides = {}) {
return {
id: 1,
name: 'John Doe',
email: 'john@example.com',
...overrides,
};
}
function createMockProduct(overrides = {}) {
return {
id: 1,
name: 'Product',
price: 100,
...overrides,
};
}
test('user profile', () => {
const user = createMockUser({ name: 'Jane' });
render(<UserProfile user={user} />);
expect(screen.getByText('Jane')).toBeInTheDocument();
});
检查清单 #
编写测试前 #
- [ ] 理解组件的用户交互
- [ ] 确定测试的关键场景
- [ ] 准备测试数据
编写测试时 #
- [ ] 使用语义化查询
- [ ] 测试用户可见的行为
- [ ] 正确处理异步操作
- [ ] 测试边界情况
编写测试后 #
- [ ] 测试可以独立运行
- [ ] 测试名称清晰描述行为
- [ ] 没有测试实现细节
- [ ] 代码覆盖率合理
总结 #
核心要点 #
- 以用户为中心:测试用户看到的内容和交互行为
- 使用语义化查询:优先使用
getByRole、getByLabelText等 - 避免实现细节:不要测试组件内部状态和方法
- 正确处理异步:使用
findBy和waitFor - 保持测试独立:每个测试应该可以单独运行
持续改进 #
- 定期重构测试代码
- 保持测试与代码同步
- 学习新的测试技巧
- 关注测试性能
参考资源 #
最后更新:2026-03-28