Enzyme 交互模拟 #

交互模拟概述 #

Enzyme 提供了 simulate 方法来模拟用户交互事件,让测试可以验证组件对用户操作的响应。

simulate 方法 #

javascript
wrapper.find(selector).simulate(eventName, [eventData]);

支持的事件类型 #

text
┌─────────────────────────────────────────────────────────────┐
│                    支持的事件类型                            │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  鼠标事件                                                    │
│  ├── click          单击                                    │
│  ├── doubleclick    双击                                    │
│  ├── mousedown      鼠标按下                                │
│  ├── mouseup        鼠标释放                                │
│  ├── mouseover      鼠标移入                                │
│  ├── mouseout       鼠标移出                                │
│  └── mouseenter     鼠标进入                                │
│                                                             │
│  表单事件                                                    │
│  ├── change         值改变                                  │
│  ├── input          输入                                    │
│  ├── submit         表单提交                                │
│  ├── focus          获得焦点                                │
│  ├── blur           失去焦点                                │
│  └── select         选择                                    │
│                                                             │
│  键盘事件                                                    │
│  ├── keydown        按键按下                                │
│  ├── keyup          按键释放                                │
│  └── keypress       按键按下(已废弃)                       │
│                                                             │
│  其他事件                                                    │
│  ├── copy           复制                                    │
│  ├── cut            剪切                                    │
│  ├── paste          粘贴                                    │
│  ├── scroll         滚动                                    │
│  └── contextmenu    右键菜单                                │
│                                                             │
└─────────────────────────────────────────────────────────────┘

点击事件 #

基本点击 #

javascript
function Button({ onClick, text }) {
  return (
    <button onClick={onClick}>{text}</button>
  );
}

describe('Button click', () => {
  it('handles click event', () => {
    const mockClick = jest.fn();
    const wrapper = shallow(<Button onClick={mockClick} text="Click me" />);
    
    wrapper.find('button').simulate('click');
    
    expect(mockClick).toHaveBeenCalledTimes(1);
  });
  
  it('handles click with event data', () => {
    const mockClick = jest.fn();
    const wrapper = shallow(<Button onClick={mockClick} text="Click" />);
    
    wrapper.find('button').simulate('click', { 
      target: { value: 'test' } 
    });
    
    expect(mockClick).toHaveBeenCalledWith(
      expect.objectContaining({ target: { value: 'test' } })
    );
  });
});

点击计数器 #

javascript
function Counter() {
  const [count, setCount] = useState(0);
  
  return (
    <div>
      <span className="count">{count}</span>
      <button 
        className="increment"
        onClick={() => setCount(count + 1)}
      >
        +
      </button>
      <button 
        className="decrement"
        onClick={() => setCount(count - 1)}
      >
        -
      </button>
    </div>
  );
}

describe('Counter', () => {
  it('increments count', () => {
    const wrapper = shallow(<Counter />);
    
    wrapper.find('.increment').simulate('click');
    
    expect(wrapper.find('.count').text()).toBe('1');
  });
  
  it('decrements count', () => {
    const wrapper = shallow(<Counter />);
    
    wrapper.find('.decrement').simulate('click');
    
    expect(wrapper.find('.count').text()).toBe('-1');
  });
  
  it('handles multiple clicks', () => {
    const wrapper = shallow(<Counter />);
    
    wrapper.find('.increment').simulate('click');
    wrapper.find('.increment').simulate('click');
    wrapper.find('.increment').simulate('click');
    
    expect(wrapper.find('.count').text()).toBe('3');
  });
});

阻止默认行为 #

javascript
function Link({ href, onClick }) {
  return (
    <a 
      href={href} 
      onClick={(e) => {
        e.preventDefault();
        onClick();
      }}
    >
      Click me
    </a>
  );
}

describe('Link', () => {
  it('prevents default and calls handler', () => {
    const mockClick = jest.fn();
    const wrapper = shallow(<Link href="/test" onClick={mockClick} />);
    
    const mockEvent = { preventDefault: jest.fn() };
    wrapper.find('a').simulate('click', mockEvent);
    
    expect(mockEvent.preventDefault).toHaveBeenCalled();
    expect(mockClick).toHaveBeenCalled();
  });
});

表单事件 #

输入变化 #

javascript
function SearchInput({ onSearch }) {
  const [value, setValue] = useState('');
  
  const handleChange = (e) => {
    setValue(e.target.value);
  };
  
  const handleSubmit = () => {
    onSearch(value);
  };
  
  return (
    <div>
      <input 
        value={value}
        onChange={handleChange}
        placeholder="Search..."
      />
      <button onClick={handleSubmit}>Search</button>
    </div>
  );
}

describe('SearchInput', () => {
  it('updates value on change', () => {
    const wrapper = shallow(<SearchInput onSearch={jest.fn()} />);
    
    wrapper.find('input').simulate('change', {
      target: { value: 'test query' }
    });
    
    expect(wrapper.find('input').prop('value')).toBe('test query');
  });
  
  it('calls onSearch with input value', () => {
    const mockSearch = jest.fn();
    const wrapper = shallow(<SearchInput onSearch={mockSearch} />);
    
    wrapper.find('input').simulate('change', {
      target: { value: 'test query' }
    });
    wrapper.find('button').simulate('click');
    
    expect(mockSearch).toHaveBeenCalledWith('test query');
  });
});

表单提交 #

javascript
function LoginForm({ onSubmit }) {
  const [formData, setFormData] = useState({ username: '', password: '' });
  
  const handleChange = (e) => {
    setFormData({
      ...formData,
      [e.target.name]: e.target.value
    });
  };
  
  const handleSubmit = (e) => {
    e.preventDefault();
    onSubmit(formData);
  };
  
  return (
    <form onSubmit={handleSubmit}>
      <input 
        name="username"
        value={formData.username}
        onChange={handleChange}
      />
      <input 
        name="password"
        type="password"
        value={formData.password}
        onChange={handleChange}
      />
      <button type="submit">Login</button>
    </form>
  );
}

describe('LoginForm', () => {
  it('submits form data', () => {
    const mockSubmit = jest.fn();
    const wrapper = shallow(<LoginForm onSubmit={mockSubmit} />);
    
    // 填写用户名
    wrapper.find('input[name="username"]').simulate('change', {
      target: { name: 'username', value: 'testuser' }
    });
    
    // 填写密码
    wrapper.find('input[name="password"]').simulate('change', {
      target: { name: 'password', value: 'testpass' }
    });
    
    // 提交表单
    wrapper.find('form').simulate('submit', { preventDefault: jest.fn() });
    
    expect(mockSubmit).toHaveBeenCalledWith({
      username: 'testuser',
      password: 'testpass'
    });
  });
});

复选框和单选框 #

javascript
function Preferences({ onChange }) {
  const [preferences, setPreferences] = useState({
    newsletter: false,
    notifications: true,
    theme: 'light'
  });
  
  const handleCheckbox = (e) => {
    const newPrefs = {
      ...preferences,
      [e.target.name]: e.target.checked
    };
    setPreferences(newPrefs);
    onChange(newPrefs);
  };
  
  const handleRadio = (e) => {
    const newPrefs = {
      ...preferences,
      theme: e.target.value
    };
    setPreferences(newPrefs);
    onChange(newPrefs);
  };
  
  return (
    <div>
      <label>
        <input 
          type="checkbox"
          name="newsletter"
          checked={preferences.newsletter}
          onChange={handleCheckbox}
        />
        Newsletter
      </label>
      <label>
        <input 
          type="checkbox"
          name="notifications"
          checked={preferences.notifications}
          onChange={handleCheckbox}
        />
        Notifications
      </label>
      <div>
        <label>
          <input 
            type="radio"
            name="theme"
            value="light"
            checked={preferences.theme === 'light'}
            onChange={handleRadio}
          />
          Light
        </label>
        <label>
          <input 
            type="radio"
            name="theme"
            value="dark"
            checked={preferences.theme === 'dark'}
            onChange={handleRadio}
          />
          Dark
        </label>
      </div>
    </div>
  );
}

describe('Preferences', () => {
  it('handles checkbox change', () => {
    const mockChange = jest.fn();
    const wrapper = shallow(<Preferences onChange={mockChange} />);
    
    wrapper.find('input[name="newsletter"]').simulate('change', {
      target: { name: 'newsletter', checked: true }
    });
    
    expect(mockChange).toHaveBeenCalledWith(
      expect.objectContaining({ newsletter: true })
    );
  });
  
  it('handles radio change', () => {
    const mockChange = jest.fn();
    const wrapper = shallow(<Preferences onChange={mockChange} />);
    
    wrapper.find('input[value="dark"]').simulate('change', {
      target: { name: 'theme', value: 'dark' }
    });
    
    expect(mockChange).toHaveBeenCalledWith(
      expect.objectContaining({ theme: 'dark' })
    );
  });
});

下拉选择 #

javascript
function CountrySelect({ onChange }) {
  const [country, setCountry] = useState('');
  
  const handleChange = (e) => {
    setCountry(e.target.value);
    onChange(e.target.value);
  };
  
  return (
    <select value={country} onChange={handleChange}>
      <option value="">Select a country</option>
      <option value="us">United States</option>
      <option value="uk">United Kingdom</option>
      <option value="cn">China</option>
    </select>
  );
}

describe('CountrySelect', () => {
  it('handles selection change', () => {
    const mockChange = jest.fn();
    const wrapper = shallow(<CountrySelect onChange={mockChange} />);
    
    wrapper.find('select').simulate('change', {
      target: { value: 'cn' }
    });
    
    expect(mockChange).toHaveBeenCalledWith('cn');
    expect(wrapper.find('select').prop('value')).toBe('cn');
  });
});

键盘事件 #

基本键盘事件 #

javascript
function InputWithEnter({ onSubmit }) {
  const [value, setValue] = useState('');
  
  const handleKeyDown = (e) => {
    if (e.key === 'Enter') {
      onSubmit(value);
    }
  };
  
  return (
    <input 
      value={value}
      onChange={(e) => setValue(e.target.value)}
      onKeyDown={handleKeyDown}
    />
  );
}

describe('InputWithEnter', () => {
  it('submits on Enter key', () => {
    const mockSubmit = jest.fn();
    const wrapper = shallow(<InputWithEnter onSubmit={mockSubmit} />);
    
    // 输入值
    wrapper.find('input').simulate('change', {
      target: { value: 'test' }
    });
    
    // 按 Enter
    wrapper.find('input').simulate('keydown', { key: 'Enter' });
    
    expect(mockSubmit).toHaveBeenCalledWith('test');
  });
  
  it('does not submit on other keys', () => {
    const mockSubmit = jest.fn();
    const wrapper = shallow(<InputWithEnter onSubmit={mockSubmit} />);
    
    wrapper.find('input').simulate('change', {
      target: { value: 'test' }
    });
    
    wrapper.find('input').simulate('keydown', { key: 'Escape' });
    
    expect(mockSubmit).not.toHaveBeenCalled();
  });
});

快捷键组合 #

javascript
function ShortcutHandler({ onShortcut }) {
  const handleKeyDown = (e) => {
    if (e.ctrlKey && e.key === 's') {
      e.preventDefault();
      onShortcut('save');
    }
    if (e.ctrlKey && e.key === 'z') {
      e.preventDefault();
      onShortcut('undo');
    }
  };
  
  return <div tabIndex={0} onKeyDown={handleKeyDown} />;
}

describe('ShortcutHandler', () => {
  it('handles Ctrl+S', () => {
    const mockShortcut = jest.fn();
    const wrapper = shallow(<ShortcutHandler onShortcut={mockShortcut} />);
    
    wrapper.find('div').simulate('keydown', {
      ctrlKey: true,
      key: 's',
      preventDefault: jest.fn()
    });
    
    expect(mockShortcut).toHaveBeenCalledWith('save');
  });
  
  it('handles Ctrl+Z', () => {
    const mockShortcut = jest.fn();
    const wrapper = shallow(<ShortcutHandler onShortcut={mockShortcut} />);
    
    wrapper.find('div').simulate('keydown', {
      ctrlKey: true,
      key: 'z',
      preventDefault: jest.fn()
    });
    
    expect(mockShortcut).toHaveBeenCalledWith('undo');
  });
});

特殊按键 #

javascript
function ArrowNavigation({ onNavigate }) {
  const handleKeyDown = (e) => {
    switch (e.key) {
      case 'ArrowUp':
        onNavigate('up');
        break;
      case 'ArrowDown':
        onNavigate('down');
        break;
      case 'ArrowLeft':
        onNavigate('left');
        break;
      case 'ArrowRight':
        onNavigate('right');
        break;
      default:
        break;
    }
  };
  
  return <div onKeyDown={handleKeyDown} />;
}

describe('ArrowNavigation', () => {
  it('handles arrow keys', () => {
    const mockNavigate = jest.fn();
    const wrapper = shallow(<ArrowNavigation onNavigate={mockNavigate} />);
    
    wrapper.find('div').simulate('keydown', { key: 'ArrowUp' });
    expect(mockNavigate).toHaveBeenCalledWith('up');
    
    wrapper.find('div').simulate('keydown', { key: 'ArrowDown' });
    expect(mockNavigate).toHaveBeenCalledWith('down');
    
    wrapper.find('div').simulate('keydown', { key: 'ArrowLeft' });
    expect(mockNavigate).toHaveBeenCalledWith('left');
    
    wrapper.find('div').simulate('keydown', { key: 'ArrowRight' });
    expect(mockNavigate).toHaveBeenCalledWith('right');
  });
});

焦点事件 #

focus 和 blur #

javascript
function InputWithValidation({ validate }) {
  const [value, setValue] = useState('');
  const [error, setError] = useState('');
  const [focused, setFocused] = useState(false);
  
  const handleFocus = () => {
    setFocused(true);
    setError('');
  };
  
  const handleBlur = () => {
    setFocused(false);
    const validationError = validate(value);
    if (validationError) {
      setError(validationError);
    }
  };
  
  return (
    <div>
      <input 
        value={value}
        onChange={(e) => setValue(e.target.value)}
        onFocus={handleFocus}
        onBlur={handleBlur}
      />
      {error && <span className="error">{error}</span>}
      {focused && <span className="hint">Enter a valid value</span>}
    </div>
  );
}

describe('InputWithValidation', () => {
  it('shows hint on focus', () => {
    const wrapper = shallow(
      <InputWithValidation validate={jest.fn()} />
    );
    
    wrapper.find('input').simulate('focus');
    
    expect(wrapper.find('.hint').exists()).toBe(true);
  });
  
  it('validates on blur', () => {
    const mockValidate = jest.fn().mockReturnValue('Invalid input');
    const wrapper = shallow(
      <InputWithValidation validate={mockValidate} />
    );
    
    wrapper.find('input').simulate('change', {
      target: { value: 'test' }
    });
    wrapper.find('input').simulate('blur');
    
    expect(mockValidate).toHaveBeenCalledWith('test');
    expect(wrapper.find('.error').text()).toBe('Invalid input');
  });
  
  it('clears error on focus', () => {
    const mockValidate = jest.fn().mockReturnValue('Error');
    const wrapper = shallow(
      <InputWithValidation validate={mockValidate} />
    );
    
    wrapper.find('input').simulate('change', {
      target: { value: 'test' }
    });
    wrapper.find('input').simulate('blur');
    expect(wrapper.find('.error').exists()).toBe(true);
    
    wrapper.find('input').simulate('focus');
    expect(wrapper.find('.error').exists()).toBe(false);
  });
});

鼠标事件 #

悬停效果 #

javascript
function TooltipButton({ tooltip, children }) {
  const [showTooltip, setShowTooltip] = useState(false);
  
  return (
    <div className="tooltip-container">
      <button 
        onMouseEnter={() => setShowTooltip(true)}
        onMouseLeave={() => setShowTooltip(false)}
      >
        {children}
      </button>
      {showTooltip && <span className="tooltip">{tooltip}</span>}
    </div>
  );
}

describe('TooltipButton', () => {
  it('shows tooltip on mouse enter', () => {
    const wrapper = shallow(
      <TooltipButton tooltip="Click me!">Button</TooltipButton>
    );
    
    expect(wrapper.find('.tooltip').exists()).toBe(false);
    
    wrapper.find('button').simulate('mouseEnter');
    
    expect(wrapper.find('.tooltip').exists()).toBe(true);
    expect(wrapper.find('.tooltip').text()).toBe('Click me!');
  });
  
  it('hides tooltip on mouse leave', () => {
    const wrapper = shallow(
      <TooltipButton tooltip="Click me!">Button</TooltipButton>
    );
    
    wrapper.find('button').simulate('mouseEnter');
    expect(wrapper.find('.tooltip').exists()).toBe(true);
    
    wrapper.find('button').simulate('mouseLeave');
    expect(wrapper.find('.tooltip').exists()).toBe(false);
  });
});

拖拽模拟 #

javascript
function Draggable({ onDragStart, onDragEnd }) {
  return (
    <div 
      draggable
      onDragStart={(e) => onDragStart(e.target.id)}
      onDragEnd={(e) => onDragEnd(e.target.id)}
    >
      Drag me
    </div>
  );
}

describe('Draggable', () => {
  it('handles drag start', () => {
    const mockDragStart = jest.fn();
    const wrapper = shallow(
      <Draggable onDragStart={mockDragStart} onDragEnd={jest.fn()} />
    );
    
    wrapper.find('div').simulate('dragStart', { target: { id: 'item-1' } });
    
    expect(mockDragStart).toHaveBeenCalledWith('item-1');
  });
  
  it('handles drag end', () => {
    const mockDragEnd = jest.fn();
    const wrapper = shallow(
      <Draggable onDragStart={jest.fn()} onDragEnd={mockDragEnd} />
    );
    
    wrapper.find('div').simulate('dragEnd', { target: { id: 'item-1' } });
    
    expect(mockDragEnd).toHaveBeenCalledWith('item-1');
  });
});

高级交互 #

模拟事件对象 #

javascript
function ComplexForm() {
  const handleSubmit = (e) => {
    e.preventDefault();
    e.stopPropagation();
    // 处理提交
  };
  
  return <form onSubmit={handleSubmit}>...</form>;
}

describe('ComplexForm', () => {
  it('handles complex event', () => {
    const wrapper = shallow(<ComplexForm />);
    
    const mockEvent = {
      preventDefault: jest.fn(),
      stopPropagation: jest.fn()
    };
    
    wrapper.find('form').simulate('submit', mockEvent);
    
    expect(mockEvent.preventDefault).toHaveBeenCalled();
    expect(mockEvent.stopPropagation).toHaveBeenCalled();
  });
});

直接调用 props 方法 #

javascript
function Button({ onClick }) {
  return <button onClick={onClick}>Click</button>;
}

describe('Button', () => {
  it('can call onClick directly', () => {
    const mockClick = jest.fn();
    const wrapper = shallow(<Button onClick={mockClick} />);
    
    // 方式 1: simulate
    wrapper.find('button').simulate('click');
    
    // 方式 2: 直接调用 prop
    wrapper.find('button').props().onClick();
    
    expect(mockClick).toHaveBeenCalledTimes(2);
  });
});

调用实例方法 #

javascript
class Counter extends React.Component {
  constructor(props) {
    super(props);
    this.state = { count: 0 };
  }
  
  increment() {
    this.setState({ count: this.state.count + 1 });
  }
  
  decrement() {
    this.setState({ count: this.state.count - 1 });
  }
  
  render() {
    return <div>{this.state.count}</div>;
  }
}

describe('Counter instance methods', () => {
  it('can call instance methods directly', () => {
    const wrapper = shallow(<Counter />);
    
    wrapper.instance().increment();
    expect(wrapper.state('count')).toBe(1);
    
    wrapper.instance().decrement();
    expect(wrapper.state('count')).toBe(0);
  });
});

simulate 的局限性 #

simulate 不是真实事件 #

javascript
// simulate 只是调用事件处理函数
// 它不会触发真正的事件传播

function Parent() {
  return (
    <div onClick={() => console.log('parent clicked')}>
      <button onClick={(e) => e.stopPropagation()}>
        Click
      </button>
    </div>
  );
}

describe('Event propagation', () => {
  it('does not test real event propagation', () => {
    const wrapper = shallow(<Parent />);
    
    // 在 shallow 渲染中,事件传播不会发生
    // simulate 只是调用按钮的 onClick
    wrapper.find('button').simulate('click');
    
    // 如果需要测试事件传播,应该使用 mount
  });
});

何时使用 mount #

javascript
// 需要真实 DOM 交互时使用 mount

describe('Real DOM interactions', () => {
  it('tests event propagation with mount', () => {
    const parentClick = jest.fn();
    const childClick = jest.fn();
    
    const wrapper = mount(
      <div onClick={parentClick}>
        <button onClick={childClick}>Click</button>
      </div>
    );
    
    wrapper.find('button').simulate('click');
    
    // 在 mount 中,事件会传播
    expect(childClick).toHaveBeenCalled();
    
    wrapper.unmount();
  });
});

最佳实践 #

1. 模拟最小必要的事件数据 #

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,
  // ... 很多不必要的属性
});

2. 使用辅助函数 #

javascript
// 创建事件模拟辅助函数
const createChangeEvent = (value, name = '') => ({
  target: { value, name }
});

const createClickEvent = () => ({
  preventDefault: jest.fn(),
  stopPropagation: jest.fn()
});

// 使用
wrapper.find('input').simulate('change', createChangeEvent('test'));
wrapper.find('form').simulate('submit', createClickEvent());

3. 测试用户行为 #

javascript
// ✅ 好的做法 - 测试用户可见的行为
it('shows error when input is empty', () => {
  const wrapper = shallow(<Form />);
  wrapper.find('button').simulate('click');
  expect(wrapper.find('.error').text()).toBe('Field is required');
});

// ❌ 避免 - 测试实现细节
it('sets error state', () => {
  const wrapper = shallow(<Form />);
  wrapper.find('button').simulate('click');
  expect(wrapper.state('error')).toBe(true); // 实现细节
});

下一步 #

现在你已经掌握了交互模拟的使用方法,接下来学习 状态与属性测试 了解如何测试组件的状态和属性!

最后更新:2026-03-28