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. 关键路径优先
// 优先迁移核心功能的测试

总结 #

关键要点 #

  1. 选择正确的渲染方式:单元测试用 shallow,集成测试用 mount
  2. 使用稳定的选择器:优先使用 data-testid
  3. 测试用户行为:关注用户可见的行为
  4. 保持测试独立:每个测试应该独立运行
  5. 及时清理资源:使用 afterEach 清理 wrapper
  6. 合理使用 Mock:Mock 外部依赖,但要适度
  7. 测试边界情况:测试正常和异常情况
  8. 保持测试简洁:每个测试只验证一个行为

检查清单 #

text
□ 测试是否独立运行?
□ 测试是否可重复?
□ 是否使用了正确的渲染方式?
□ 选择器是否稳定?
□ 是否清理了资源?
□ 是否测试了边界情况?
□ 测试名称是否清晰?
□ 测试是否快速?

参考资源 #

最后更新:2026-03-28