Jest DOM 测试 #
DOM 测试概述 #
DOM 测试是前端测试的核心,用于验证 UI 组件的渲染、交互和行为。
text
┌─────────────────────────────────────────────────────────────┐
│ DOM 测试工具链 │
├─────────────────────────────────────────────────────────────┤
│ Jest - 测试运行器和断言 │
│ jsdom - 浏览器环境模拟 │
│ Testing Library - DOM 查询和交互 │
│ jest-dom - DOM 专用断言 │
└─────────────────────────────────────────────────────────────┘
环境配置 #
安装依赖 #
bash
npm install --save-dev jest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom
配置 Jest #
javascript
// jest.config.js
module.exports = {
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
};
// jest.setup.js
import '@testing-library/jest-dom';
DOM 查询 #
查询类型 #
Testing Library 提供三种查询类型:
| 类型 | 查询方法 | 没找到时 |
|---|---|---|
| get | getBy… | 抛出错误 |
| query | queryBy… | 返回 null |
| find | findBy… | Promise reject |
查询方式 #
javascript
import { render, screen } from '@testing-library/react';
test('query methods', () => {
render(<Component />);
// getBy - 获取单个元素
screen.getByText('Hello');
screen.getByRole('button');
screen.getByLabelText('Email');
// queryBy - 查询元素(可能不存在)
screen.queryByText('Not found');
// findBy - 异步查询
await screen.findByText('Loading complete');
// getAllBy - 获取多个元素
screen.getAllByRole('listitem');
});
常用查询方法 #
javascript
test('common queries', () => {
render(<Form />);
// 按角色查询(推荐)
screen.getByRole('button', { name: 'Submit' });
screen.getByRole('textbox', { name: 'Email' });
screen.getByRole('checkbox', { name: 'Remember me' });
// 按文本查询
screen.getByText('Welcome');
screen.getByText(/welcome/i);
// 按标签查询
screen.getByLabelText('Username');
// 按占位符查询
screen.getByPlaceholderText('Enter your name');
// 按测试 ID 查询
screen.getByTestId('submit-button');
// 按标题查询
screen.getByTitle('Close');
screen.getByAltText('Logo');
});
查询优先级 #
text
1. getByRole - 最推荐,反映可访问性
2. getByLabelText - 表单元素
3. getByPlaceholderText
4. getByText - 非表单元素
5. getByDisplayValue
6. getByAltText
7. getByTitle
8. getByTestId - 最后选择
jest-dom 断言 #
基本断言 #
javascript
import '@testing-library/jest-dom';
test('basic assertions', () => {
render(<Component />);
// 存在性
expect(screen.getByText('Hello')).toBeInTheDocument();
expect(screen.queryByText('Not found')).not.toBeInTheDocument();
// 可见性
expect(screen.getByText('Hello')).toBeVisible();
expect(screen.getByText('Hidden')).not.toBeVisible();
// 启用/禁用
expect(screen.getByRole('button')).toBeEnabled();
expect(screen.getByRole('button')).toBeDisabled();
// 选中状态
expect(screen.getByRole('checkbox')).toBeChecked();
expect(screen.getByRole('radio')).toBeChecked();
});
文本内容断言 #
javascript
test('text content assertions', () => {
render(<Component />);
// 包含文本
expect(screen.getByRole('heading')).toHaveTextContent('Welcome');
expect(screen.getByRole('heading')).toHaveTextContent(/welcome/i);
// 空内容
expect(screen.getByRole('textbox')).toBeEmptyDOMElement();
});
属性断言 #
javascript
test('attribute assertions', () => {
render(<Component />);
// 属性
expect(screen.getByRole('link')).toHaveAttribute('href', '/home');
expect(screen.getByRole('link')).toHaveAttribute('target', '_blank');
// 类名
expect(screen.getByRole('button')).toHaveClass('btn-primary');
expect(screen.getByRole('button')).toHaveClass('btn', 'btn-primary');
// 样式
expect(screen.getByRole('button')).toHaveStyle({ color: 'red' });
});
表单断言 #
javascript
test('form assertions', () => {
render(<Form />);
// 值
expect(screen.getByLabelText('Email')).toHaveValue('test@example.com');
expect(screen.getByLabelText('Count')).toHaveValue(5);
// 显示值
expect(screen.getByLabelText('Country')).toHaveDisplayValue('USA');
// 焦点
expect(screen.getByLabelText('Email')).toHaveFocus();
});
表单验证断言 #
javascript
test('validation assertions', () => {
render(<Form />);
// 必填
expect(screen.getByLabelText('Email')).toBeRequired();
// 无效
expect(screen.getByLabelText('Email')).toBeInvalid();
expect(screen.getByLabelText('Email')).toBeValid();
});
用户交互 #
fireEvent #
javascript
import { render, screen, fireEvent } from '@testing-library/react';
test('fireEvent example', () => {
render(<Counter />);
const button = screen.getByRole('button', { name: 'Increment' });
fireEvent.click(button);
expect(screen.getByText('Count: 1')).toBeInTheDocument();
});
userEvent(推荐) #
javascript
import userEvent from '@testing-library/user-event';
test('userEvent example', async () => {
const user = userEvent.setup();
render(<Counter />);
const button = screen.getByRole('button', { name: 'Increment' });
await user.click(button);
expect(screen.getByText('Count: 1')).toBeInTheDocument();
});
点击事件 #
javascript
test('click events', async () => {
const user = userEvent.setup();
render(<Component />);
// 单击
await user.click(screen.getByRole('button'));
// 双击
await user.dblClick(screen.getByRole('button'));
// 右键点击
await user.pointer({
target: screen.getByRole('button'),
keys: '[MouseRight]',
});
});
键盘事件 #
javascript
test('keyboard events', async () => {
const user = userEvent.setup();
render(<Input />);
const input = screen.getByRole('textbox');
// 输入文本
await user.type(input, 'Hello World');
// 按键
await user.keyboard('{Enter}');
await user.keyboard('{Escape}');
await user.keyboard('{Tab}');
// 组合键
await user.keyboard('{Control>}a{/Control}'); // Ctrl+A
await user.keyboard('{Meta>}c{/Meta}'); // Cmd+C
});
表单交互 #
javascript
test('form interactions', async () => {
const user = userEvent.setup();
render(<Form />);
// 输入文本
await user.type(screen.getByLabelText('Username'), 'john');
// 清除输入
await user.clear(screen.getByLabelText('Username'));
// 选择选项
await user.selectOptions(
screen.getByLabelText('Country'),
'usa'
);
// 复选框
await user.click(screen.getByLabelText('Remember me'));
// 单选框
await user.click(screen.getByLabelText('Male'));
// 文件上传
const file = new File(['hello'], 'hello.txt', { type: 'text/plain' });
await user.upload(screen.getByLabelText('Upload'), file);
});
剪贴板操作 #
javascript
test('clipboard operations', async () => {
const user = userEvent.setup();
render(<Editor />);
const input = screen.getByRole('textbox');
await user.type(input, 'Hello');
await user.tripleClick(input); // 全选
await user.copy(); // 复制
await user.paste(); // 粘贴
});
异步操作 #
waitFor #
javascript
import { waitFor } from '@testing-library/react';
test('waitFor example', async () => {
render(<Component />);
await waitFor(() => {
expect(screen.getByText('Loaded')).toBeInTheDocument();
});
});
findBy #
javascript
test('findBy example', async () => {
render(<Component />);
// findBy 自动等待
const element = await screen.findByText('Loaded');
expect(element).toBeInTheDocument();
});
waitForElementToBeRemoved #
javascript
import { waitForElementToBeRemoved } from '@testing-library/react';
test('element removed', async () => {
render(<Component />);
const loading = screen.getByText('Loading...');
await waitForElementToBeRemoved(loading);
expect(screen.getByText('Content')).toBeInTheDocument();
});
异步表单提交 #
javascript
test('async form submission', async () => {
const user = userEvent.setup();
render(<LoginForm />);
await user.type(screen.getByLabelText('Email'), 'test@example.com');
await user.type(screen.getByLabelText('Password'), 'password123');
await user.click(screen.getByRole('button', { name: 'Login' }));
// 等待加载完成
await screen.findByText('Welcome!');
// 验证状态
expect(screen.getByText('Dashboard')).toBeInTheDocument();
});
实际示例 #
测试计数器 #
javascript
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Counter from './Counter';
describe('Counter', () => {
test('renders initial count', () => {
render(<Counter initialCount={0} />);
expect(screen.getByText('Count: 0')).toBeInTheDocument();
});
test('increments count', async () => {
const user = userEvent.setup();
render(<Counter initialCount={0} />);
await user.click(screen.getByRole('button', { name: 'Increment' }));
expect(screen.getByText('Count: 1')).toBeInTheDocument();
});
test('decrements count', async () => {
const user = userEvent.setup();
render(<Counter initialCount={5} />);
await user.click(screen.getByRole('button', { name: 'Decrement' }));
expect(screen.getByText('Count: 4')).toBeInTheDocument();
});
test('resets count', async () => {
const user = userEvent.setup();
render(<Counter initialCount={10} />);
await user.click(screen.getByRole('button', { name: 'Reset' }));
expect(screen.getByText('Count: 0')).toBeInTheDocument();
});
});
测试表单 #
javascript
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import LoginForm from './LoginForm';
describe('LoginForm', () => {
test('renders form elements', () => {
render(<LoginForm />);
expect(screen.getByLabelText('Email')).toBeInTheDocument();
expect(screen.getByLabelText('Password')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Login' })).toBeInTheDocument();
});
test('validates required fields', async () => {
const user = userEvent.setup();
render(<LoginForm />);
await user.click(screen.getByRole('button', { name: 'Login' }));
expect(screen.getByText('Email is required')).toBeInTheDocument();
expect(screen.getByText('Password is required')).toBeInTheDocument();
});
test('validates email format', async () => {
const user = userEvent.setup();
render(<LoginForm />);
await user.type(screen.getByLabelText('Email'), 'invalid-email');
await user.click(screen.getByRole('button', { name: 'Login' }));
expect(screen.getByText('Invalid email format')).toBeInTheDocument();
});
test('submits form with valid data', async () => {
const user = userEvent.setup();
const onSubmit = jest.fn();
render(<LoginForm onSubmit={onSubmit} />);
await user.type(screen.getByLabelText('Email'), 'test@example.com');
await user.type(screen.getByLabelText('Password'), 'password123');
await user.click(screen.getByRole('button', { name: 'Login' }));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith({
email: 'test@example.com',
password: 'password123',
});
});
});
});
测试模态框 #
javascript
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Modal from './Modal';
describe('Modal', () => {
test('does not show modal initially', () => {
render(<Modal>Modal content</Modal>);
expect(screen.queryByText('Modal content')).not.toBeInTheDocument();
});
test('shows modal when triggered', async () => {
const user = userEvent.setup();
render(
<>
<button>Open Modal</button>
<Modal isOpen={true}>Modal content</Modal>
</>
);
expect(screen.getByText('Modal content')).toBeInTheDocument();
});
test('closes modal on close button', async () => {
const user = userEvent.setup();
const onClose = jest.fn();
render(
<Modal isOpen={true} onClose={onClose}>
Modal content
</Modal>
);
await user.click(screen.getByRole('button', { name: 'Close' }));
expect(onClose).toHaveBeenCalled();
});
test('closes modal on escape key', async () => {
const user = userEvent.setup();
const onClose = jest.fn();
render(
<Modal isOpen={true} onClose={onClose}>
Modal content
</Modal>
);
await user.keyboard('{Escape}');
expect(onClose).toHaveBeenCalled();
});
});
测试列表 #
javascript
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import TodoList from './TodoList';
describe('TodoList', () => {
test('renders empty state', () => {
render(<TodoList items={[]} />);
expect(screen.getByText('No items')).toBeInTheDocument();
});
test('renders list items', () => {
const items = [
{ id: 1, text: 'Buy milk' },
{ id: 2, text: 'Walk dog' },
];
render(<TodoList items={items} />);
expect(screen.getByText('Buy milk')).toBeInTheDocument();
expect(screen.getByText('Walk dog')).toBeInTheDocument();
});
test('adds new item', async () => {
const user = userEvent.setup();
const onAdd = jest.fn();
render(<TodoList items={[]} onAdd={onAdd} />);
await user.type(screen.getByPlaceholderText('Add item'), 'New item');
await user.click(screen.getByRole('button', { name: 'Add' }));
expect(onAdd).toHaveBeenCalledWith('New item');
});
test('deletes item', async () => {
const user = userEvent.setup();
const onDelete = jest.fn();
const items = [{ id: 1, text: 'Buy milk' }];
render(<TodoList items={items} onDelete={onDelete} />);
await user.click(screen.getByRole('button', { name: 'Delete' }));
expect(onDelete).toHaveBeenCalledWith(1);
});
test('toggles item completion', async () => {
const user = userEvent.setup();
const onToggle = jest.fn();
const items = [{ id: 1, text: 'Buy milk', completed: false }];
render(<TodoList items={items} onToggle={onToggle} />);
await user.click(screen.getByRole('checkbox'));
expect(onToggle).toHaveBeenCalledWith(1);
});
});
最佳实践 #
1. 使用用户视角的查询 #
javascript
// ❌ 实现细节
expect(container.querySelector('.btn-primary')).toBeInTheDocument();
// ✅ 用户视角
expect(screen.getByRole('button', { name: 'Submit' })).toBeInTheDocument();
2. 测试行为而非实现 #
javascript
// ❌ 测试状态
expect(component.state.count).toBe(1);
// ✅ 测试行为
expect(screen.getByText('Count: 1')).toBeInTheDocument();
3. 使用正确的查询优先级 #
javascript
// ❌ 使用 test-id
screen.getByTestId('submit-button');
// ✅ 使用角色
screen.getByRole('button', { name: 'Submit' });
4. 测试可访问性 #
javascript
test('accessibility', () => {
render(<Form />);
// 标签关联
expect(screen.getByLabelText('Email')).toBeInTheDocument();
// 按钮
expect(screen.getByRole('button')).toBeInTheDocument();
// 图片替代文本
expect(screen.getByAltText('Logo')).toBeInTheDocument();
});
下一步 #
现在你已经掌握了 Jest DOM 测试,接下来学习 代码覆盖率 了解如何衡量测试质量!
最后更新:2026-03-28