Jest DOM 测试 #

DOM 测试概述 #

DOM 测试是前端测试的重要组成部分,用于验证 UI 组件的渲染和交互行为。

text
┌─────────────────────────────────────────────────────────────┐
│                    DOM 测试内容                              │
├─────────────────────────────────────────────────────────────┤
│  1. 元素渲染 - 检查元素是否存在                              │
│  2. 内容验证 - 检查文本和属性                                │
│  3. 样式检查 - 验证 CSS 类和样式                             │
│  4. 用户交互 - 模拟点击、输入等                              │
│  5. 事件处理 - 验证事件回调                                  │
└─────────────────────────────────────────────────────────────┘

环境配置 #

安装依赖 #

bash
# 安装 Jest 和 Testing Library
npm install --save-dev jest jest-environment-jsdom @testing-library/react @testing-library/jest-dom

# Vue 项目
npm install --save-dev @vue/test-utils @testing-library/vue

配置 jsdom #

javascript
// jest.config.js
module.exports = {
  testEnvironment: 'jsdom',
  setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
};
javascript
// jest.setup.js
import '@testing-library/jest-dom';

基本查询 #

查询方法 #

javascript
import { render, screen } from '@testing-library/react';
import App from './App';

test('renders learn react link', () => {
  render(<App />);

  // getBy - 获取单个元素,找不到会报错
  const linkElement = screen.getByText(/learn react/i);
  expect(linkElement).toBeInTheDocument();

  // queryBy - 获取单个元素,找不到返回 null
  const missingElement = screen.queryByText(/missing/i);
  expect(missingElement).not.toBeInTheDocument();

  // findBy - 异步获取元素
  const asyncElement = await screen.findByText(/loaded/i);
  expect(asyncElement).toBeInTheDocument();
});

查询类型 #

javascript
test('query types', () => {
  render(<UserCard user={{ name: 'John', email: 'john@example.com' }} />);

  // 按文本查询
  screen.getByText('John');

  // 按角色查询
  screen.getByRole('button');
  screen.getByRole('heading', { name: /profile/i });

  // 按标签查询
  screen.getByLabelText('Email');

  // 按占位符查询
  screen.getByPlaceholderText('Enter name');

  // 按测试 ID 查询
  screen.getByTestId('user-card');

  // 按显示值查询
  screen.getByDisplayValue('John');
});

断言方法 #

jest-dom 扩展匹配器 #

javascript
import { render, screen } from '@testing-library/react';
import Button from './Button';

test('button assertions', () => {
  render(<Button disabled>Click me</Button>);

  const button = screen.getByRole('button');

  // 存在性
  expect(button).toBeInTheDocument();

  // 可见性
  expect(button).toBeVisible();

  // 禁用状态
  expect(button).toBeDisabled();

  // 启用状态
  expect(button).not.toBeEnabled();

  // 文本内容
  expect(button).toHaveTextContent('Click me');

  // 类名
  expect(button).toHaveClass('btn-primary');

  // 属性
  expect(button).toHaveAttribute('type', 'button');
});

更多断言 #

javascript
test('more assertions', () => {
  render(
    <div>
      <input type="text" value="test" readOnly />
      <a href="https://example.com">Link</a>
      <button style={{ color: 'red' }}>Red Button</button>
      <span className="error">Error message</span>
    </div>
  );

  // 输入值
  const input = screen.getByRole('textbox');
  expect(input).toHaveValue('test');

  // 链接
  const link = screen.getByRole('link');
  expect(link).toHaveAttribute('href', 'https://example.com');

  // 样式
  const button = screen.getByText('Red Button');
  expect(button).toHaveStyle({ color: 'red' });

  // 焦点
  input.focus();
  expect(input).toHaveFocus();

  // 错误消息
  const error = screen.getByText('Error message');
  expect(error).toHaveClass('error');
});

用户交互 #

点击事件 #

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

test('counter increments on click', () => {
  render(<Counter />);

  const button = screen.getByRole('button', { name: /increment/i });
  const count = screen.getByText('0');

  fireEvent.click(button);
  expect(count).toHaveTextContent('1');

  fireEvent.click(button);
  expect(count).toHaveTextContent('2');
});

输入事件 #

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

test('search form input', () => {
  const onSearch = jest.fn();
  render(<SearchForm onSearch={onSearch} />);

  const input = screen.getByPlaceholderText('Search...');

  fireEvent.change(input, { target: { value: 'react' } });
  expect(input).toHaveValue('react');

  const button = screen.getByRole('button', { name: /search/i });
  fireEvent.click(button);

  expect(onSearch).toHaveBeenCalledWith('react');
});

表单提交 #

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

test('form submission', () => {
  const onSubmit = jest.fn();
  render(<LoginForm onSubmit={onSubmit} />);

  fireEvent.change(screen.getByLabelText('Email'), {
    target: { value: 'test@example.com' },
  });

  fireEvent.change(screen.getByLabelText('Password'), {
    target: { value: 'password123' },
  });

  fireEvent.click(screen.getByRole('button', { name: /submit/i }));

  expect(onSubmit).toHaveBeenCalledWith({
    email: 'test@example.com',
    password: 'password123',
  });
});

使用 userEvent #

bash
npm install --save-dev @testing-library/user-event
javascript
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import SearchForm from './SearchForm';

test('search with userEvent', async () => {
  const user = userEvent.setup();
  const onSearch = jest.fn();
  render(<SearchForm onSearch={onSearch} />);

  const input = screen.getByPlaceholderText('Search...');
  const button = screen.getByRole('button', { name: /search/i });

  await user.type(input, 'react');
  await user.click(button);

  expect(onSearch).toHaveBeenCalledWith('react');
});

异步操作 #

waitFor #

javascript
import { render, screen, waitFor } from '@testing-library/react';
import UserProfile from './UserProfile';

test('loads user data', async () => {
  render(<UserProfile userId={1} />);

  // 等待加载完成
  await waitFor(() => {
    expect(screen.getByText('John Doe')).toBeInTheDocument();
  });

  // 或使用 findBy
  const userName = await screen.findByText('John Doe');
  expect(userName).toBeInTheDocument();
});

异步组件 #

javascript
import { render, screen, waitFor } from '@testing-library/react';
import UserList from './UserList';

test('displays user list', async () => {
  render(<UserList />);

  // 等待加载状态消失
  await waitFor(() => {
    expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
  });

  // 验证用户列表
  const users = await screen.findAllByRole('listitem');
  expect(users).toHaveLength(3);
});

Mock API #

javascript
import { render, screen, waitFor } from '@testing-library/react';
import UserProfile from './UserProfile';

jest.mock('./api', () => ({
  fetchUser: jest.fn(() =>
    Promise.resolve({ id: 1, name: 'John Doe' })
  ),
}));

test('fetches and displays user', async () => {
  render(<UserProfile userId={1} />);

  await waitFor(() => {
    expect(screen.getByText('John Doe')).toBeInTheDocument();
  });
});

React 组件测试 #

简单组件 #

javascript
import { render, screen } from '@testing-library/react';
import Greeting from './Greeting';

test('renders greeting', () => {
  render(<Greeting name="John" />);
  expect(screen.getByText('Hello, John!')).toBeInTheDocument();
});

带 Props 的组件 #

javascript
import { render, screen } from '@testing-library/react';
import Button from './Button';

describe('Button', () => {
  test('renders with text', () => {
    render(<Button>Click me</Button>);
    expect(screen.getByRole('button')).toHaveTextContent('Click me');
  });

  test('renders disabled', () => {
    render(<Button disabled>Disabled</Button>);
    expect(screen.getByRole('button')).toBeDisabled();
  });

  test('handles click', async () => {
    const handleClick = jest.fn();
    const user = userEvent.setup();

    render(<Button onClick={handleClick}>Click</Button>);
    await user.click(screen.getByRole('button'));

    expect(handleClick).toHaveBeenCalledTimes(1);
  });
});

带状态的组件 #

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

test('counter functionality', async () => {
  const user = userEvent.setup();
  render(<Counter />);

  const incrementButton = screen.getByRole('button', { name: '+' });
  const decrementButton = screen.getByRole('button', { name: '-' });
  const countDisplay = screen.getByTestId('count');

  expect(countDisplay).toHaveTextContent('0');

  await user.click(incrementButton);
  expect(countDisplay).toHaveTextContent('1');

  await user.click(incrementButton);
  expect(countDisplay).toHaveTextContent('2');

  await user.click(decrementButton);
  expect(countDisplay).toHaveTextContent('1');
});

带表单的组件 #

javascript
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import ContactForm from './ContactForm';

test('form validation and submission', async () => {
  const user = userEvent.setup();
  const onSubmit = jest.fn();
  render(<ContactForm onSubmit={onSubmit} />);

  // 提交空表单
  await user.click(screen.getByRole('button', { name: /submit/i }));

  // 验证错误消息
  expect(screen.getByText('Name is required')).toBeInTheDocument();
  expect(screen.getByText('Email is required')).toBeInTheDocument();

  // 填写表单
  await user.type(screen.getByLabelText('Name'), 'John Doe');
  await user.type(screen.getByLabelText('Email'), 'john@example.com');
  await user.type(screen.getByLabelText('Message'), 'Hello!');

  // 提交表单
  await user.click(screen.getByRole('button', { name: /submit/i }));

  await waitFor(() => {
    expect(onSubmit).toHaveBeenCalledWith({
      name: 'John Doe',
      email: 'john@example.com',
      message: 'Hello!',
    });
  });
});

Vue 组件测试 #

基本测试 #

javascript
import { mount } from '@vue/test-utils';
import Button from './Button.vue';

test('renders button', () => {
  const wrapper = mount(Button, {
    slots: {
      default: 'Click me',
    },
  });

  expect(wrapper.text()).toContain('Click me');
});

Props 测试 #

javascript
import { mount } from '@vue/test-utils';
import UserCard from './UserCard.vue';

test('renders user info', () => {
  const wrapper = mount(UserCard, {
    props: {
      user: {
        id: 1,
        name: 'John Doe',
        email: 'john@example.com',
      },
    },
  });

  expect(wrapper.text()).toContain('John Doe');
  expect(wrapper.text()).toContain('john@example.com');
});

事件测试 #

javascript
import { mount } from '@vue/test-utils';
import Counter from './Counter.vue';

test('counter increments', async () => {
  const wrapper = mount(Counter);

  expect(wrapper.vm.count).toBe(0);

  await wrapper.find('button').trigger('click');

  expect(wrapper.vm.count).toBe(1);
});

最佳实践 #

1. 使用可访问性查询 #

javascript
// ✅ 好的做法 - 使用角色查询
screen.getByRole('button', { name: /submit/i });

// ❌ 不好的做法 - 使用测试 ID
screen.getByTestId('submit-button');

2. 模拟用户行为 #

javascript
// ✅ 好的做法 - 使用 userEvent
await user.click(button);
await user.type(input, 'text');

// ❌ 不好的做法 - 使用 fireEvent
fireEvent.click(button);

3. 测试行为而非实现 #

javascript
// ✅ 好的做法 - 测试用户看到的内容
test('shows error message', async () => {
  render(<Form />);
  await user.click(submitButton);
  expect(screen.getByText('Name is required')).toBeInTheDocument();
});

// ❌ 不好的做法 - 测试内部状态
test('sets error state', async () => {
  const { result } = renderHook(() => useForm());
  expect(result.current.errors.name).toBe('Name is required');
});

4. 避免实现细节 #

javascript
// ✅ 好的做法
expect(screen.getByRole('alert')).toHaveTextContent('Error');

// ❌ 不好的做法
expect(container.querySelector('.error-message span').textContent).toBe('Error');

常见问题 #

元素找不到 #

javascript
// 问题:元素在异步操作后出现
// 解决方案:使用 findBy
const element = await screen.findByText('Loaded');

多个匹配元素 #

javascript
// 问题:getByText 找到多个元素
// 解决方案:使用 getAllBy
const buttons = screen.getAllByRole('button');
expect(buttons).toHaveLength(3);

事件未触发 #

javascript
// 问题:fireEvent 没有触发更新
// 解决方案:使用 userEvent 或等待更新
await user.click(button);
await waitFor(() => {
  expect(screen.getByText('Updated')).toBeInTheDocument();
});

下一步 #

现在你已经掌握了 Jest DOM 测试,接下来学习 代码覆盖率 学习如何衡量测试质量!

最后更新:2026-03-28