测试React组件 #

一、测试概述 #

1.1 测试类型 #

类型 说明
单元测试 测试独立函数或组件
集成测试 测试组件间交互
E2E测试 测试完整用户流程

1.2 测试工具 #

工具 用途
Jest 测试运行器
React Testing Library React组件测试
Vitest Vite生态测试工具

1.3 安装 #

bash
# Jest
npm install -D jest @testing-library/react @testing-library/jest-dom @testing-library/user-event jest-environment-jsdom

# Vitest
npm install -D vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom

二、配置测试环境 #

2.1 Jest配置 #

javascript
// jest.config.js
module.exports = {
  testEnvironment: 'jsdom',
  setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
  moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/src/$1',
    '\\.(css|less|scss|sass)$': 'identity-obj-proxy'
  },
  transform: {
    '^.+\\.(js|jsx|ts|tsx)$': ['babel-jest', { presets: ['@babel/preset-env', '@babel/preset-react'] }]
  }
};

// jest.setup.js
import '@testing-library/jest-dom';

2.2 Vitest配置 #

javascript
// vitest.config.js
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  test: {
    environment: 'jsdom',
    setupFiles: './src/setupTests.js',
    globals: true
  }
});

// setupTests.js
import '@testing-library/jest-dom';

三、基础测试 #

3.1 渲染测试 #

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

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

  test('renders with variant class', () => {
    render(<Button variant="primary">Primary</Button>);
    
    const button = screen.getByRole('button');
    expect(button).toHaveClass('btn-primary');
  });

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

3.2 交互测试 #

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

describe('Counter', () => {
  test('increments count when button clicked', async () => {
    const user = userEvent.setup();
    render(<Counter />);
    
    const button = screen.getByRole('button', { name: /increment/i });
    
    await user.click(button);
    await user.click(button);
    
    expect(screen.getByText('2')).toBeInTheDocument();
  });

  test('calls onClick handler', 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);
  });
});

3.3 表单测试 #

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

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

  test('shows validation error for empty fields', async () => {
    const user = userEvent.setup();
    render(<LoginForm />);
    
    await user.click(screen.getByRole('button', { name: /login/i }));
    
    expect(await screen.findByText(/email is required/i)).toBeInTheDocument();
    expect(screen.getByText(/password is required/i)).toBeInTheDocument();
  });
});

四、查询方法 #

4.1 查询类型 #

类型 说明 失败时
getBy 获取元素 抛出错误
queryBy 获取元素 返回null
findBy 异步获取 抛出错误

4.2 常用查询 #

javascript
// 按角色查询(推荐)
screen.getByRole('button');
screen.getByRole('textbox', { name: /email/i });
screen.getByRole('heading', { level: 1 });

// 按文本查询
screen.getByText('Submit');
screen.getByText(/submit/i);

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

// 按测试ID查询
screen.getByTestId('submit-button');

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

4.3 查询优先级 #

text
1. getByRole        - 最推荐,反映可访问性
2. getByLabelText   - 表单元素
3. getByPlaceholderText
4. getByText        - 非表单元素
5. getByDisplayValue
6. getByAltText     - 图片
7. getByTitle
8. getByTestId      - 最后选择

五、异步测试 #

5.1 等待元素 #

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

describe('UserList', () => {
  test('displays users after loading', async () => {
    render(<UserList />);
    
    // 等待加载完成
    expect(screen.getByText(/loading/i)).toBeInTheDocument();
    
    // 等待数据加载
    await waitFor(() => {
      expect(screen.getByText('Alice')).toBeInTheDocument();
    });
    
    // 或使用findBy
    expect(await screen.findByText('Alice')).toBeInTheDocument();
  });
});

5.2 模拟API #

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

const server = setupServer(
  rest.get('/api/users', (req, res, ctx) => {
    return res(
      ctx.json([
        { id: 1, name: 'Alice' },
        { id: 2, name: 'Bob' }
      ])
    );
  })
);

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

describe('UserList', () => {
  test('fetches and displays users', async () => {
    render(<UserList />);
    
    await waitFor(() => {
      expect(screen.getByText('Alice')).toBeInTheDocument();
      expect(screen.getByText('Bob')).toBeInTheDocument();
    });
  });

  test('handles API error', async () => {
    server.use(
      rest.get('/api/users', (req, res, ctx) => {
        return res(ctx.status(500));
      })
    );
    
    render(<UserList />);
    
    await waitFor(() => {
      expect(screen.getByText(/error/i)).toBeInTheDocument();
    });
  });
});

六、Mock和Spy #

6.1 函数Mock #

javascript
test('calls callback with correct arguments', () => {
  const mockCallback = jest.fn();
  
  render(<Button onClick={mockCallback}>Click</Button>);
  
  fireEvent.click(screen.getByRole('button'));
  
  expect(mockCallback).toHaveBeenCalled();
  expect(mockCallback).toHaveBeenCalledWith({ id: 1 });
});

test('mock return value', () => {
  const mockFn = jest.fn();
  mockFn.mockReturnValue('mocked value');
  
  expect(mockFn()).toBe('mocked value');
});

test('mock implementation', () => {
  const mockFn = jest.fn((x) => x * 2);
  
  expect(mockFn(5)).toBe(10);
});

6.2 模块Mock #

javascript
// 模拟整个模块
jest.mock('../api/users', () => ({
  fetchUsers: jest.fn(() => Promise.resolve([{ id: 1, name: 'Alice' }]))
}));

// 模拟部分模块
jest.mock('../api/users', () => ({
  ...jest.requireActual('../api/users'),
  fetchUsers: jest.fn()
}));

6.3 Timer Mock #

javascript
describe('Timer', () => {
  beforeEach(() => {
    jest.useFakeTimers();
  });

  afterEach(() => {
    jest.useRealTimers();
  });

  test('calls callback after delay', () => {
    const callback = jest.fn();
    
    render(<Timer onTimeout={callback} delay={1000} />);
    
    expect(callback).not.toHaveBeenCalled();
    
    jest.advanceTimersByTime(1000);
    
    expect(callback).toHaveBeenCalled();
  });
});

七、Context测试 #

7.1 测试Provider #

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

function TestComponent() {
  const { theme } = useTheme();
  return <div data-testid="theme">{theme}</div>;
}

describe('ThemeProvider', () => {
  test('provides theme value', () => {
    render(
      <ThemeProvider initialTheme="dark">
        <TestComponent />
      </ThemeProvider>
    );
    
    expect(screen.getByTestId('theme')).toHaveTextContent('dark');
  });
});

7.2 测试Consumer #

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

describe('ThemeToggle', () => {
  test('toggles theme', async () => {
    const mockToggle = jest.fn();
    const user = userEvent.setup();
    
    render(
      <ThemeContext.Provider value={{ theme: 'light', toggleTheme: mockToggle }}>
        <ThemeToggle />
      </ThemeContext.Provider>
    );
    
    await user.click(screen.getByRole('button'));
    
    expect(mockToggle).toHaveBeenCalled();
  });
});

八、快照测试 #

8.1 基本快照 #

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

describe('Button', () => {
  test('matches snapshot', () => {
    const { asFragment } = render(<Button>Click me</Button>);
    
    expect(asFragment()).toMatchSnapshot();
  });

  test('matches inline snapshot', () => {
    const { asFragment } = render(<Button>Click me</Button>);
    
    expect(asFragment()).toMatchInlineSnapshot(`
      <DocumentFragment>
        <button class="btn btn-primary">
          Click me
        </button>
      </DocumentFragment>
    `);
  });
});

九、测试最佳实践 #

9.1 测试用户行为 #

javascript
// ❌ 测试实现细节
test('sets state on click', () => {
  const { result } = renderHook(() => useState(false));
  act(() => result.current[1](true));
  expect(result.current[0]).toBe(true);
});

// ✅ 测试用户可见行为
test('toggles menu visibility', async () => {
  const user = userEvent.setup();
  render(<Menu />);
  
  expect(screen.queryByRole('menu')).not.toBeInTheDocument();
  
  await user.click(screen.getByRole('button', { name: /menu/i }));
  
  expect(screen.getByRole('menu')).toBeInTheDocument();
});

9.2 避免实现细节 #

javascript
// ❌ 测试组件状态
test('component state', () => {
  const wrapper = mount(<Counter />);
  expect(wrapper.state('count')).toBe(0);
});

// ✅ 测试渲染结果
test('displays initial count', () => {
  render(<Counter />);
  expect(screen.getByText('0')).toBeInTheDocument();
});

9.3 使用可访问性查询 #

javascript
// ❌ 使用测试ID
screen.getByTestId('submit-button');

// ✅ 使用角色查询
screen.getByRole('button', { name: /submit/i });

十、总结 #

要点 说明
查询优先级 role > text > testId
用户行为 测试用户可见的行为
异步测试 使用waitFor和findBy
Mock 合理使用mock隔离依赖

核心原则:

  • 测试用户行为,而非实现细节
  • 使用可访问性查询
  • 合理使用Mock
  • 保持测试简单明了
最后更新:2026-03-26