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