Testing Library 用户交互与事件 #

概述 #

Testing Library 提供了两种模拟用户交互的方式:

text
┌─────────────────────────────────────────────────────────────┐
│                     用户交互方式                             │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  user-event (推荐)                                          │
│  ├── 更接近真实用户行为                                      │
│  ├── 触发完整的事件序列                                      │
│  ├── 支持异步操作                                           │
│  └── 更好的可访问性支持                                      │
│                                                             │
│  fireEvent (底层 API)                                       │
│  ├── 直接触发单个事件                                        │
│  ├── 更底层的控制                                           │
│  └── 用于特殊场景                                           │
│                                                             │
└─────────────────────────────────────────────────────────────┘

user-event 基础 #

安装 #

bash
npm install --save-dev @testing-library/user-event

基本用法 #

jsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

function Counter() {
  const [count, setCount] = useState(0);
  return (
    <div>
      <span>Count: {count}</span>
      <button onClick={() => setCount(c => c + 1)}>Increment</button>
    </div>
  );
}

test('counter increments on click', 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();
});

setup() 方法 #

jsx
test('user setup options', async () => {
  // 创建用户实例
  const user = userEvent.setup();
  
  // 带配置选项
  const userWithOptions = userEvent.setup({
    delay: null,           // 输入延迟
    skipHover: false,      // 跳过 hover 事件
    skipAutoClose: false,  // 跳过自动关闭
  });
});

点击事件 #

click 方法 #

jsx
function ButtonExample() {
  const [clicked, setClicked] = useState(false);
  return (
    <div>
      <button onClick={() => setClicked(true)}>Click Me</button>
      {clicked && <span>Button clicked!</span>}
    </div>
  );
}

test('click triggers onClick', async () => {
  const user = userEvent.setup();
  render(<ButtonExample />);
  
  await user.click(screen.getByRole('button', { name: /click me/i }));
  
  expect(screen.getByText('Button clicked!')).toBeInTheDocument();
});

点击选项 #

jsx
test('click options', async () => {
  const user = userEvent.setup();
  render(<App />);
  
  // 基本点击
  await user.click(element);
  
  // 带选项的点击
  await user.click(element, {
    ctrlKey: true,    // Ctrl 键
    shiftKey: true,   // Shift 键
    altKey: true,     // Alt 键
    metaKey: true,    // Meta 键 (Cmd/Win)
    button: 0,        // 鼠标按钮 (0=左, 1=中, 2=右)
    clickCount: 2,    // 点击次数
  });
});

双击 #

jsx
function DoubleClickExample() {
  const [count, setCount] = useState(0);
  return (
    <div>
      <span>Double clicks: {count}</span>
      <button onDoubleClick={() => setCount(c => c + 1)}>Double Click Me</button>
    </div>
  );
}

test('double click', async () => {
  const user = userEvent.setup();
  render(<DoubleClickExample />);
  
  await user.dblClick(screen.getByRole('button'));
  
  expect(screen.getByText('Double clicks: 1')).toBeInTheDocument();
});

右键点击 #

jsx
function ContextMenu() {
  const [show, setShow] = useState(false);
  return (
    <div>
      <div onContextMenu={() => setShow(true)}>Right click here</div>
      {show && <div>Context Menu</div>}
    </div>
  );
}

test('right click shows context menu', async () => {
  const user = userEvent.setup();
  render(<ContextMenu />);
  
  await user.pointer({
    target: screen.getByText('Right click here'),
    keys: '[MouseRight]',
  });
  
  expect(screen.getByText('Context Menu')).toBeInTheDocument();
});

键盘输入 #

type 方法 #

jsx
function FormExample() {
  const [value, setValue] = useState('');
  return (
    <div>
      <input
        value={value}
        onChange={(e) => setValue(e.target.value)}
        placeholder="Type here"
      />
      <span>Typed: {value}</span>
    </div>
  );
}

test('type in input', async () => {
  const user = userEvent.setup();
  render(<FormExample />);
  
  const input = screen.getByPlaceholderText('Type here');
  await user.type(input, 'Hello World');
  
  expect(input).toHaveValue('Hello World');
  expect(screen.getByText('Typed: Hello World')).toBeInTheDocument();
});

type 选项 #

jsx
test('type options', async () => {
  const user = userEvent.setup();
  render(<FormExample />);
  
  const input = screen.getByPlaceholderText('Type here');
  
  // 带延迟输入
  await user.type(input, 'Hello', { delay: 100 });
  
  // 跳过初始点击
  await user.type(input, 'World', { skipClick: true });
  
  // 跳过自动关闭选择
  await user.type(input, '!', { skipAutoClose: true });
});

清除输入 #

jsx
function ClearableInput() {
  const [value, setValue] = useState('initial');
  return (
    <input
      value={value}
      onChange={(e) => setValue(e.target.value)}
    />
  );
}

test('clear input', async () => {
  const user = userEvent.setup();
  render(<ClearableInput />);
  
  const input = screen.getByRole('textbox');
  expect(input).toHaveValue('initial');
  
  await user.clear(input);
  
  expect(input).toHaveValue('');
});

键盘快捷键 #

jsx
function KeyboardShortcuts() {
  const [saved, setSaved] = useState(false);
  
  useEffect(() => {
    const handleKeyDown = (e) => {
      if ((e.ctrlKey || e.metaKey) && e.key === 's') {
        e.preventDefault();
        setSaved(true);
      }
    };
    
    window.addEventListener('keydown', handleKeyDown);
    return () => window.removeEventListener('keydown', handleKeyDown);
  }, []);
  
  return <div>{saved && <span>Saved!</span>}</div>;
}

test('keyboard shortcut Ctrl+S', async () => {
  const user = userEvent.setup();
  render(<KeyboardShortcuts />);
  
  await user.keyboard('{Control>}s{/Control}');
  
  expect(screen.getByText('Saved!')).toBeInTheDocument();
});

特殊键 #

jsx
test('special keys', async () => {
  const user = userEvent.setup();
  
  // Enter
  await user.keyboard('{Enter}');
  
  // Escape
  await user.keyboard('{Escape}');
  
  // Tab
  await user.keyboard('{Tab}');
  
  // Backspace
  await user.keyboard('{Backspace}');
  
  // Delete
  await user.keyboard('{Delete}');
  
  // Arrow keys
  await user.keyboard('{ArrowLeft}');
  await user.keyboard('{ArrowRight}');
  await user.keyboard('{ArrowUp}');
  await user.keyboard('{ArrowDown}');
  
  // Modifier keys
  await user.keyboard('{Shift}');
  await user.keyboard('{Control}');
  await user.keyboard('{Alt}');
  await user.keyboard('{Meta}');
});

选择操作 #

select 方法 #

jsx
function SelectExample() {
  const [fruit, setFruit] = useState('');
  return (
    <select value={fruit} onChange={(e) => setFruit(e.target.value)}>
      <option value="">Select a fruit</option>
      <option value="apple">Apple</option>
      <option value="banana">Banana</option>
      <option value="orange">Orange</option>
    </select>
  );
}

test('select option', async () => {
  const user = userEvent.setup();
  render(<SelectExample />);
  
  const select = screen.getByRole('combobox');
  
  await user.selectOptions(select, 'apple');
  
  expect(select).toHaveValue('apple');
});

多选 #

jsx
function MultiSelect() {
  const [fruits, setFruits] = useState([]);
  return (
    <select
      multiple
      value={fruits}
      onChange={(e) => setFruits(
        Array.from(e.target.selectedOptions, (option) => option.value)
      )}
    >
      <option value="apple">Apple</option>
      <option value="banana">Banana</option>
      <option value="orange">Orange</option>
    </select>
  );
}

test('select multiple options', async () => {
  const user = userEvent.setup();
  render(<MultiSelect />);
  
  const select = screen.getByRole('listbox');
  
  await user.selectOptions(select, ['apple', 'banana']);
  
  expect(select).toHaveValue(['apple', 'banana']);
});

取消选择 #

jsx
test('deselect options', async () => {
  const user = userEvent.setup();
  render(<MultiSelect />);
  
  const select = screen.getByRole('listbox');
  
  await user.selectOptions(select, ['apple', 'banana']);
  await user.deselectOptions(select, 'apple');
  
  expect(select).toHaveValue(['banana']);
});

复选框和单选按钮 #

复选框 #

jsx
function CheckboxExample() {
  const [checked, setChecked] = useState(false);
  return (
    <label>
      <input
        type="checkbox"
        checked={checked}
        onChange={(e) => setChecked(e.target.checked)}
      />
      Agree to terms
    </label>
  );
}

test('checkbox toggle', async () => {
  const user = userEvent.setup();
  render(<CheckboxExample />);
  
  const checkbox = screen.getByRole('checkbox');
  
  expect(checkbox).not.toBeChecked();
  
  await user.click(checkbox);
  
  expect(checkbox).toBeChecked();
  
  await user.click(checkbox);
  
  expect(checkbox).not.toBeChecked();
});

单选按钮 #

jsx
function RadioGroup() {
  const [color, setColor] = useState('');
  return (
    <div role="radiogroup" aria-label="Color">
      <label>
        <input
          type="radio"
          name="color"
          value="red"
          checked={color === 'red'}
          onChange={(e) => setColor(e.target.value)}
        />
        Red
      </label>
      <label>
        <input
          type="radio"
          name="color"
          value="blue"
          checked={color === 'blue'}
          onChange={(e) => setColor(e.target.value)}
        />
        Blue
      </label>
    </div>
  );
}

test('radio button selection', async () => {
  const user = userEvent.setup();
  render(<RadioGroup />);
  
  const redRadio = screen.getByRole('radio', { name: /red/i });
  const blueRadio = screen.getByRole('radio', { name: /blue/i });
  
  await user.click(redRadio);
  
  expect(redRadio).toBeChecked();
  expect(blueRadio).not.toBeChecked();
  
  await user.click(blueRadio);
  
  expect(redRadio).not.toBeChecked();
  expect(blueRadio).toBeChecked();
});

文件上传 #

上传单个文件 #

jsx
function FileUpload() {
  const [file, setFile] = useState(null);
  return (
    <div>
      <input
        type="file"
        onChange={(e) => setFile(e.target.files[0])}
      />
      {file && <span>Selected: {file.name}</span>}
    </div>
  );
}

test('upload single file', async () => {
  const user = userEvent.setup();
  render(<FileUpload />);
  
  const file = new File(['hello'], 'hello.txt', { type: 'text/plain' });
  const input = screen.getByRole('button', { name: /choose file/i });
  
  await user.upload(input, file);
  
  expect(input.files[0]).toBe(file);
  expect(screen.getByText('Selected: hello.txt')).toBeInTheDocument();
});

上传多个文件 #

jsx
function MultiFileUpload() {
  const [files, setFiles] = useState([]);
  return (
    <input
      type="file"
      multiple
      onChange={(e) => setFiles(Array.from(e.target.files))}
    />
  );
}

test('upload multiple files', async () => {
  const user = userEvent.setup();
  render(<MultiFileUpload />);
  
  const files = [
    new File(['hello'], 'hello.txt', { type: 'text/plain' }),
    new File(['world'], 'world.txt', { type: 'text/plain' }),
  ];
  
  const input = screen.getByRole('button', { name: /choose files/i });
  
  await user.upload(input, files);
  
  expect(input.files).toHaveLength(2);
});

鼠标操作 #

hover 和 unhover #

jsx
function TooltipExample() {
  const [show, setShow] = useState(false);
  return (
    <div>
      <button
        onMouseEnter={() => setShow(true)}
        onMouseLeave={() => setShow(false)}
      >
        Hover me
      </button>
      {show && <div role="tooltip">Tooltip content</div>}
    </div>
  );
}

test('hover shows tooltip', async () => {
  const user = userEvent.setup();
  render(<TooltipExample />);
  
  const button = screen.getByRole('button', { name: /hover me/i });
  
  await user.hover(button);
  
  expect(screen.getByRole('tooltip')).toBeInTheDocument();
  
  await user.unhover(button);
  
  expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
});

pointer API #

jsx
test('pointer API for advanced mouse interactions', async () => {
  const user = userEvent.setup();
  render(<App />);
  
  // 鼠标移动
  await user.pointer({ target: element });
  
  // 鼠标按下
  await user.pointer({ keys: '[MouseLeft]', target: element });
  
  // 鼠标释放
  await user.pointer({ keys: '[/MouseLeft]' });
  
  // 拖拽
  await user.pointer([
    { keys: '[MouseLeft]', target: element1 },
    { coords: { x: 100, y: 100 } },
    { keys: '[/MouseLeft]' },
  ]);
});

拖拽操作 #

drag and drop #

jsx
function DragDropExample() {
  const [items, setItems] = useState(['Item 1', 'Item 2', 'Item 3']);
  const [dropped, setDropped] = useState([]);
  
  return (
    <div>
      <div data-testid="source">
        {items.map((item) => (
          <div
            key={item}
            draggable
            onDragStart={(e) => e.dataTransfer.setData('text', item)}
          >
            {item}
          </div>
        ))}
      </div>
      <div
        data-testid="target"
        onDrop={(e) => {
          e.preventDefault();
          setDropped((prev) => [...prev, e.dataTransfer.getData('text')]);
        }}
        onDragOver={(e) => e.preventDefault()}
      >
        {dropped.map((item) => (
          <div key={item}>{item}</div>
        ))}
      </div>
    </div>
  );
}

test('drag and drop', async () => {
  const user = userEvent.setup();
  render(<DragDropExample />);
  
  const source = screen.getByText('Item 1');
  const target = screen.getByTestId('target');
  
  await user.pointer([
    { keys: '[MouseLeft]', target: source },
    { coords: { x: 100, y: 100 } },
    { keys: '[/MouseLeft]', target },
  ]);
  
  expect(screen.getByTestId('target')).toHaveTextContent('Item 1');
});

粘贴操作 #

jsx
function PasteExample() {
  const [text, setText] = useState('');
  return (
    <input
      value={text}
      onChange={(e) => setText(e.target.value)}
      onPaste={(e) => {
        e.preventDefault();
        setText(e.clipboardData.getData('text'));
      }}
    />
  );
}

test('paste text', async () => {
  const user = userEvent.setup();
  render(<PasteExample />);
  
  const input = screen.getByRole('textbox');
  
  await user.click(input);
  await user.paste('Hello World');
  
  expect(input).toHaveValue('Hello World');
});

fireEvent API #

基本用法 #

jsx
import { render, screen, fireEvent } from '@testing-library/react';

function FireEventExample() {
  const [clicked, setClicked] = useState(false);
  return (
    <button onClick={() => setClicked(true)}>
      {clicked ? 'Clicked' : 'Click Me'}
    </button>
  );
}

test('fireEvent click', () => {
  render(<FireEventExample />);
  
  fireEvent.click(screen.getByRole('button'));
  
  expect(screen.getByText('Clicked')).toBeInTheDocument();
});

常用事件 #

jsx
test('common fireEvent events', () => {
  render(<App />);
  
  // 鼠标事件
  fireEvent.click(element);
  fireEvent.dblClick(element);
  fireEvent.mouseEnter(element);
  fireEvent.mouseLeave(element);
  fireEvent.mouseOver(element);
  fireEvent.mouseOut(element);
  
  // 键盘事件
  fireEvent.keyDown(element, { key: 'Enter' });
  fireEvent.keyUp(element, { key: 'Enter' });
  fireEvent.keyPress(element, { key: 'Enter' });
  
  // 表单事件
  fireEvent.change(input, { target: { value: 'new value' } });
  fireEvent.input(input, { target: { value: 'new value' } });
  fireEvent.submit(form);
  fireEvent.focus(input);
  fireEvent.blur(input);
  
  // 剪贴板事件
  fireEvent.copy(element);
  fireEvent.cut(element);
  fireEvent.paste(element);
});

事件选项 #

jsx
test('fireEvent with options', () => {
  render(<App />);
  
  // 带事件属性
  fireEvent.click(element, {
    ctrlKey: true,
    shiftKey: true,
    altKey: true,
    metaKey: true,
  });
  
  // 键盘事件
  fireEvent.keyDown(element, {
    key: 'Enter',
    code: 'Enter',
    keyCode: 13,
    charCode: 13,
  });
  
  // 输入事件
  fireEvent.change(input, {
    target: {
      value: 'new value',
      checked: true,
    },
  });
});

user-event vs fireEvent #

区别对比 #

jsx
function InputExample() {
  const [value, setValue] = useState('');
  return (
    <input
      value={value}
      onChange={(e) => setValue(e.target.value)}
    />
  );
}

test('user-event vs fireEvent', async () => {
  const user = userEvent.setup();
  render(<InputExample />);
  
  const input = screen.getByRole('textbox');
  
  // fireEvent - 只触发 change 事件
  fireEvent.change(input, { target: { value: 'Hello' } });
  
  // user-event - 触发完整的事件序列
  // focus -> keydown -> keypress -> keyup -> input -> change
  await user.type(input, 'Hello');
});

何时使用 fireEvent #

jsx
test('when to use fireEvent', () => {
  // 1. 当 user-event 不支持的事件
  fireEvent.scroll(element, { target: { scrollY: 100 } });
  
  // 2. 当需要精确控制事件
  fireEvent.keyDown(element, { key: 'Enter', keyCode: 13 });
  
  // 3. 当测试事件处理逻辑而非用户行为
  fireEvent.invalid(input);
});

焦点管理 #

Tab 导航 #

jsx
function FocusExample() {
  return (
    <form>
      <input placeholder="First name" />
      <input placeholder="Last name" />
      <button type="submit">Submit</button>
    </form>
  );
}

test('tab navigation', async () => {
  const user = userEvent.setup();
  render(<FocusExample />);
  
  const firstName = screen.getByPlaceholderText('First name');
  const lastName = screen.getByPlaceholderText('Last name');
  const submit = screen.getByRole('button', { name: /submit/i });
  
  await user.tab();
  expect(firstName).toHaveFocus();
  
  await user.tab();
  expect(lastName).toHaveFocus();
  
  await user.tab();
  expect(submit).toHaveFocus();
});

焦点验证 #

jsx
test('focus assertions', async () => {
  const user = userEvent.setup();
  render(<FocusExample />);
  
  const firstName = screen.getByPlaceholderText('First name');
  
  await user.click(firstName);
  
  expect(firstName).toHaveFocus();
  
  await user.tab();
  
  expect(firstName).not.toHaveFocus();
});

实战示例 #

登录表单测试 #

jsx
function LoginForm({ onSubmit }) {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  
  const handleSubmit = (e) => {
    e.preventDefault();
    onSubmit({ email, password });
  };
  
  return (
    <form onSubmit={handleSubmit}>
      <label>
        Email
        <input
          type="email"
          value={email}
          onChange={(e) => setEmail(e.target.value)}
        />
      </label>
      <label>
        Password
        <input
          type="password"
          value={password}
          onChange={(e) => setPassword(e.target.value)}
        />
      </label>
      <button type="submit">Login</button>
    </form>
  );
}

test('login form submission', async () => {
  const mockSubmit = jest.fn();
  const user = userEvent.setup();
  render(<LoginForm onSubmit={mockSubmit} />);
  
  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(mockSubmit).toHaveBeenCalledWith({
    email: 'test@example.com',
    password: 'password123',
  });
});

搜索表单测试 #

jsx
function SearchForm({ onSearch }) {
  const [query, setQuery] = useState('');
  
  const handleSubmit = (e) => {
    e.preventDefault();
    onSearch(query);
  };
  
  return (
    <form onSubmit={handleSubmit}>
      <input
        placeholder="Search..."
        value={query}
        onChange={(e) => setQuery(e.target.value)}
      />
      <button type="submit">Search</button>
    </form>
  );
}

test('search form with Enter key', async () => {
  const mockSearch = jest.fn();
  const user = userEvent.setup();
  render(<SearchForm onSearch={mockSearch} />);
  
  const input = screen.getByPlaceholderText('Search...');
  
  await user.type(input, 'React testing{enter}');
  
  expect(mockSearch).toHaveBeenCalledWith('React testing');
});

最佳实践 #

推荐做法 #

jsx
// ✅ 使用 user-event
const user = userEvent.setup();
await user.click(button);
await user.type(input, 'text');

// ✅ 使用 async/await
test('async interaction', async () => {
  const user = userEvent.setup();
  await user.click(button);
});

// ✅ 使用语义化查询
await user.click(screen.getByRole('button', { name: /submit/i }));

避免做法 #

jsx
// ❌ 使用 fireEvent 模拟用户行为
fireEvent.click(button);
fireEvent.change(input, { target: { value: 'text' } });

// ❌ 忘记 await
user.click(button); // 缺少 await

// ❌ 使用非语义化查询
await user.click(screen.getByTestId('submit-button'));

下一步 #

现在你已经掌握了用户交互与事件,接下来学习 异步测试 处理异步操作!

最后更新:2026-03-28