Enzyme 最佳实践 #
测试原则 #
核心原则 #
text
┌─────────────────────────────────────────────────────────────┐
│ 测试核心原则 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 1. 测试行为,而非实现 │
│ 关注用户可见的行为,而非内部实现细节 │
│ │
│ 2. 测试应该独立 │
│ 每个测试应该独立运行,不依赖其他测试 │
│ │
│ 3. 测试应该可重复 │
│ 相同的输入应该产生相同的输出 │
│ │
│ 4. 测试应该快速 │
│ 快速的测试反馈循环提高开发效率 │
│ │
│ 5. 测试应该有意义 │
│ 测试应该验证有价值的行为 │
│ │
└─────────────────────────────────────────────────────────────┘
测试组织 #
文件结构 #
text
src/
├── components/
│ ├── Button/
│ │ ├── Button.jsx
│ │ ├── Button.styles.js
│ │ ├── Button.test.jsx
│ │ └── index.js
│ ├── Form/
│ │ ├── Form.jsx
│ │ ├── Form.test.jsx
│ │ ├── Input.jsx
│ │ └── Input.test.jsx
│ └── index.js
├── hooks/
│ ├── useCounter.js
│ └── useCounter.test.js
├── utils/
│ ├── helpers.js
│ └── helpers.test.js
└── __tests__/
├── integration/
│ └── user-flow.test.js
└── setup/
└── setupTests.js
测试文件模板 #
javascript
import React from 'react';
import { shallow, mount } from 'enzyme';
import Component from './Component';
describe('Component', () => {
let wrapper;
let defaultProps;
beforeEach(() => {
defaultProps = {
prop1: 'value1',
prop2: 'value2'
};
});
afterEach(() => {
if (wrapper) {
wrapper.unmount();
wrapper = null;
}
});
describe('rendering', () => {
it('renders correctly', () => {
wrapper = shallow(<Component {...defaultProps} />);
expect(wrapper.exists()).toBe(true);
});
it('renders with default props', () => {
wrapper = shallow(<Component />);
expect(wrapper.exists()).toBe(true);
});
});
describe('props', () => {
it('receives props correctly', () => {
wrapper = shallow(<Component {...defaultProps} />);
expect(wrapper.prop('prop1')).toBe('value1');
});
});
describe('interactions', () => {
it('handles click event', () => {
const onClick = jest.fn();
wrapper = shallow(<Component {...defaultProps} onClick={onClick} />);
wrapper.find('button').simulate('click');
expect(onClick).toHaveBeenCalled();
});
});
describe('state', () => {
it('updates state correctly', () => {
wrapper = shallow(<Component {...defaultProps} />);
wrapper.find('button').simulate('click');
expect(wrapper.state('count')).toBe(1);
});
});
});
渲染方式选择 #
选择指南 #
javascript
// 决策树
function chooseRenderMethod(testType, needs) {
// 单元测试 - 隔离测试单个组件
if (testType === 'unit') {
return 'shallow';
}
// 需要真实 DOM 交互
if (needs.domInteraction || needs.eventPropagation) {
return 'mount';
}
// 需要测试生命周期
if (needs.lifecycle) {
return 'mount';
}
// 需要测试 Context
if (needs.context) {
return 'mount';
}
// 需要测试 Refs
if (needs.refs) {
return 'mount';
}
// 只需要验证 HTML 结构
if (needs.htmlStructure) {
return 'render';
}
// 默认使用 shallow
return 'shallow';
}
使用示例 #
javascript
// ✅ 单元测试 - 使用 shallow
describe('Button unit test', () => {
it('renders with text', () => {
const wrapper = shallow(<Button text="Click" />);
expect(wrapper.text()).toBe('Click');
});
});
// ✅ 集成测试 - 使用 mount
describe('Form integration test', () => {
it('submits form data', () => {
const onSubmit = jest.fn();
const wrapper = mount(<Form onSubmit={onSubmit} />);
wrapper.find('input').simulate('change', { target: { value: 'test' } });
wrapper.find('form').simulate('submit', { preventDefault: jest.fn() });
expect(onSubmit).toHaveBeenCalled();
wrapper.unmount();
});
});
// ✅ HTML 结构验证 - 使用 render
describe('HTML structure', () => {
it('has correct structure', () => {
const wrapper = render(<Card title="Test" />);
expect(wrapper.find('.card-title').text()).toBe('Test');
});
});
选择器最佳实践 #
选择器优先级 #
javascript
// 优先级从高到低
// 1. data-testid(最稳定)
wrapper.find('[data-testid="submit-button"]');
// 2. 语义化属性
wrapper.find('button[type="submit"]');
wrapper.find('input[name="email"]');
// 3. 组件选择器
wrapper.find(MyComponent);
// 4. 语义化类名
wrapper.find('.submit-button');
// 5. 标签选择器
wrapper.find('button');
避免脆弱选择器 #
javascript
// ❌ 脆弱的选择器
wrapper.find('div > div > button'); // 依赖嵌套结构
wrapper.find('.css-1a2b3c'); // 自动生成的类名
wrapper.find('#root > div:nth-child(3)'); // 依赖位置
// ✅ 稳健的选择器
wrapper.find('[data-testid="submit-button"]');
wrapper.find('button[type="submit"]');
wrapper.find('.submit-button');
事件模拟最佳实践 #
模拟最小必要数据 #
javascript
// ✅ 好的做法 - 只提供必要数据
wrapper.find('input').simulate('change', {
target: { value: 'test' }
});
// ❌ 避免 - 过度模拟
wrapper.find('input').simulate('change', {
target: {
value: 'test',
name: 'input',
id: 'input-1',
type: 'text',
// ...很多不必要的属性
},
bubbles: true,
cancelable: true,
// ...很多不必要的属性
});
使用辅助函数 #
javascript
// 创建事件辅助函数
const events = {
change: (value, name = '') => ({
target: { value, name }
}),
click: () => ({
preventDefault: jest.fn(),
stopPropagation: jest.fn()
}),
keyDown: (key, modifiers = {}) => ({
key,
ctrlKey: modifiers.ctrl || false,
shiftKey: modifiers.shift || false,
altKey: modifiers.alt || false,
preventDefault: jest.fn()
})
};
// 使用
wrapper.find('input').simulate('change', events.change('test'));
wrapper.find('form').simulate('submit', events.click());
wrapper.find('input').simulate('keydown', events.keyDown('Enter', { ctrl: true }));
状态测试最佳实践 #
优先测试渲染结果 #
javascript
// ✅ 好的做法 - 测试渲染结果
it('displays updated count', () => {
const wrapper = shallow(<Counter />);
wrapper.find('button').simulate('click');
expect(wrapper.find('.count').text()).toBe('1');
});
// ⚠️ 谨慎使用 - 测试内部状态
it('updates state', () => {
const wrapper = shallow(<Counter />);
wrapper.find('button').simulate('click');
expect(wrapper.state('count')).toBe(1); // 实现细节
});
测试状态转换 #
javascript
describe('State transitions', () => {
it('transitions through states correctly', () => {
const wrapper = shallow(<Form />);
// 初始状态
expect(wrapper.state('step')).toBe(1);
// 下一步
wrapper.find('.next').simulate('click');
expect(wrapper.state('step')).toBe(2);
// 上一步
wrapper.find('.prev').simulate('click');
expect(wrapper.state('step')).toBe(1);
});
});
异步测试最佳实践 #
使用 async/await #
javascript
// ✅ 好的做法
it('fetches data', async () => {
const mockFetch = jest.fn().mockResolvedValue('data');
const wrapper = mount(<Component fetchData={mockFetch} />);
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 0));
});
wrapper.update();
expect(wrapper.text()).toBe('data');
wrapper.unmount();
});
// ❌ 避免 - 不等待异步完成
it('fetches data', () => {
const wrapper = mount(<Component />);
// 不等待,断言可能失败
expect(wrapper.text()).toBe('data');
});
使用 Jest 定时器 #
javascript
describe('Timer tests', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it('updates after delay', () => {
const wrapper = mount(<Timer />);
act(() => {
jest.advanceTimersByTime(1000);
});
wrapper.update();
expect(wrapper.text()).toBe('1');
wrapper.unmount();
});
});
Mock 最佳实践 #
Mock 外部依赖 #
javascript
// Mock API 调用
jest.mock('../api', () => ({
fetchUser: jest.fn().mockResolvedValue({ name: 'John' })
}));
// Mock localStorage
const localStorageMock = {
getItem: jest.fn(),
setItem: jest.fn(),
clear: jest.fn()
};
global.localStorage = localStorageMock;
// Mock window
global.window = {
...global.window,
scrollTo: jest.fn()
};
清理 Mock #
javascript
describe('With mocks', () => {
let mockFetch;
beforeEach(() => {
mockFetch = jest.fn().mockResolvedValue('data');
});
afterEach(() => {
jest.clearAllMocks();
});
it('calls fetch', () => {
// 测试代码
});
});
快照测试 #
使用 enzyme-to-json #
javascript
import { createSerializer } from 'enzyme-to-json';
expect.addSnapshotSerializer(createSerializer({ mode: 'deep' }));
describe('Snapshots', () => {
it('matches snapshot', () => {
const wrapper = shallow(<Button text="Click" />);
expect(wrapper).toMatchSnapshot();
});
});
快照最佳实践 #
javascript
// ✅ 好的做法 - 有意义的快照
it('renders correctly', () => {
const wrapper = shallow(<Button text="Click" />);
expect(wrapper).toMatchSnapshot();
});
// ❌ 避免 - 过大的快照
it('renders huge component', () => {
const wrapper = shallow(<HugeComponent />);
expect(wrapper).toMatchSnapshot(); // 快照太大,难以审查
});
// ✅ 分解测试
it('renders header correctly', () => {
const wrapper = shallow(<HugeComponent />);
expect(wrapper.find('Header')).toMatchSnapshot();
});
it('renders content correctly', () => {
const wrapper = shallow(<HugeComponent />);
expect(wrapper.find('Content')).toMatchSnapshot();
});
性能优化 #
减少不必要的渲染 #
javascript
// ❌ 每个测试都 mount
describe('Bad', () => {
it('test 1', () => {
const wrapper = mount(<Component />);
// 测试
wrapper.unmount();
});
it('test 2', () => {
const wrapper = mount(<Component />);
// 测试
wrapper.unmount();
});
});
// ✅ 共享 wrapper
describe('Good', () => {
let wrapper;
beforeEach(() => {
wrapper = shallow(<Component />);
});
afterEach(() => {
wrapper.unmount();
});
it('test 1', () => {
// 测试
});
it('test 2', () => {
// 测试
});
});
使用浅层渲染 #
javascript
// ✅ 单元测试使用 shallow
describe('Unit tests', () => {
it('renders correctly', () => {
const wrapper = shallow(<Component />);
expect(wrapper.exists()).toBe(true);
});
});
// ✅ 只在必要时使用 mount
describe('Integration tests', () => {
it('handles DOM interaction', () => {
const wrapper = mount(<Component />);
// 需要 DOM 交互
wrapper.unmount();
});
});
测试覆盖常见场景 #
表单测试 #
javascript
describe('Form', () => {
it('validates required fields', () => {
const wrapper = shallow(<Form />);
wrapper.find('form').simulate('submit', { preventDefault: jest.fn() });
expect(wrapper.find('.error').length).toBeGreaterThan(0);
});
it('submits with valid data', () => {
const onSubmit = jest.fn();
const wrapper = shallow(<Form onSubmit={onSubmit} />);
wrapper.find('input[name="email"]').simulate('change', {
target: { name: 'email', value: 'test@example.com' }
});
wrapper.find('form').simulate('submit', { preventDefault: jest.fn() });
expect(onSubmit).toHaveBeenCalledWith({
email: 'test@example.com'
});
});
});
列表测试 #
javascript
describe('List', () => {
const items = [
{ id: 1, name: 'Item 1' },
{ id: 2, name: 'Item 2' },
{ id: 3, name: 'Item 3' }
];
it('renders all items', () => {
const wrapper = shallow(<List items={items} />);
expect(wrapper.find('.item').length).toBe(3);
});
it('renders empty state', () => {
const wrapper = shallow(<List items={[]} />);
expect(wrapper.find('.empty').exists()).toBe(true);
});
it('handles item click', () => {
const onItemClick = jest.fn();
const wrapper = shallow(<List items={items} onItemClick={onItemClick} />);
wrapper.find('.item').at(0).simulate('click');
expect(onItemClick).toHaveBeenCalledWith(items[0]);
});
});
模态框测试 #
javascript
describe('Modal', () => {
it('is hidden by default', () => {
const wrapper = shallow(<Modal>Content</Modal>);
expect(wrapper.find('.modal').hasClass('hidden')).toBe(true);
});
it('shows when isOpen is true', () => {
const wrapper = shallow(<Modal isOpen>Content</Modal>);
expect(wrapper.find('.modal').hasClass('hidden')).toBe(false);
});
it('calls onClose when backdrop is clicked', () => {
const onClose = jest.fn();
const wrapper = shallow(<Modal isOpen onClose={onClose}>Content</Modal>);
wrapper.find('.backdrop').simulate('click');
expect(onClose).toHaveBeenCalled();
});
});
迁移到 React Testing Library #
对比示例 #
javascript
// Enzyme 风格
describe('Counter with Enzyme', () => {
it('increments count', () => {
const wrapper = shallow(<Counter />);
wrapper.find('button').simulate('click');
expect(wrapper.state('count')).toBe(1);
expect(wrapper.find('.count').text()).toBe('1');
});
});
// React Testing Library 风格
describe('Counter with RTL', () => {
it('increments count', () => {
render(<Counter />);
const button = screen.getByRole('button', { name: /increment/i });
const count = screen.getByTestId('count');
fireEvent.click(button);
expect(count).toHaveTextContent('1');
});
});
迁移建议 #
javascript
// 逐步迁移策略
// 1. 新测试使用 RTL
// 新组件的测试使用 React Testing Library
// 2. 保留现有 Enzyme 测试
// 不必立即重写所有测试
// 3. 重构时迁移
// 当修改组件时,顺便迁移测试
// 4. 关键路径优先
// 优先迁移核心功能的测试
总结 #
关键要点 #
- 选择正确的渲染方式:单元测试用 shallow,集成测试用 mount
- 使用稳定的选择器:优先使用 data-testid
- 测试用户行为:关注用户可见的行为
- 保持测试独立:每个测试应该独立运行
- 及时清理资源:使用 afterEach 清理 wrapper
- 合理使用 Mock:Mock 外部依赖,但要适度
- 测试边界情况:测试正常和异常情况
- 保持测试简洁:每个测试只验证一个行为
检查清单 #
text
□ 测试是否独立运行?
□ 测试是否可重复?
□ 是否使用了正确的渲染方式?
□ 选择器是否稳定?
□ 是否清理了资源?
□ 是否测试了边界情况?
□ 测试名称是否清晰?
□ 测试是否快速?
参考资源 #
最后更新:2026-03-28