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