Jest React 测试 #

React 测试概述 #

React 组件测试是前端测试的重要组成部分,Testing Library 是 React 官方推荐的测试工具。

text
┌─────────────────────────────────────────────────────────────┐
│                    React 测试内容                            │
├─────────────────────────────────────────────────────────────┤
│  1. 组件渲染 - 验证组件正确渲染                              │
│  2. 用户交互 - 测试点击、输入等操作                          │
│  3. 状态管理 - 测试 state 和 props 变化                     │
│  4. Hooks 测试 - 测试自定义 Hooks                           │
│  5. Context 测试 - 测试 Context Provider                    │
│  6. Redux 测试 - 测试 Redux 状态管理                        │
└─────────────────────────────────────────────────────────────┘

环境配置 #

安装依赖 #

bash
npm install --save-dev @testing-library/react @testing-library/jest-dom @testing-library/user-event

配置 Jest #

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

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

函数组件测试 #

基本组件 #

javascript
// Greeting.jsx
function Greeting({ name }) {
  return <h1>Hello, {name}!</h1>;
}

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

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

带状态的组件 #

javascript
// Counter.jsx
import { useState } from 'react';

function Counter() {
  const [count, setCount] = useState(0);
  
  return (
    <div>
      <span data-testid="count">{count}</span>
      <button onClick={() => setCount(c => c + 1)}>Increment</button>
      <button onClick={() => setCount(c => c - 1)}>Decrement</button>
    </div>
  );
}

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

test('counter increments and decrements', async () => {
  const user = userEvent.setup();
  render(<Counter />);
  
  const count = screen.getByTestId('count');
  const increment = screen.getByRole('button', { name: /increment/i });
  const decrement = screen.getByRole('button', { name: /decrement/i });
  
  expect(count).toHaveTextContent('0');
  
  await user.click(increment);
  expect(count).toHaveTextContent('1');
  
  await user.click(increment);
  expect(count).toHaveTextContent('2');
  
  await user.click(decrement);
  expect(count).toHaveTextContent('1');
});

带 Props 的组件 #

javascript
// Button.jsx
function Button({ children, onClick, variant = 'primary', disabled = false }) {
  return (
    <button
      onClick={onClick}
      className={`btn btn-${variant}`}
      disabled={disabled}
    >
      {children}
    </button>
  );
}

// Button.test.jsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Button from './Button';

describe('Button', () => {
  test('renders with children', () => {
    render(<Button>Click me</Button>);
    expect(screen.getByRole('button')).toHaveTextContent('Click me');
  });
  
  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);
  });
  
  test('renders with variant', () => {
    render(<Button variant="secondary">Secondary</Button>);
    expect(screen.getByRole('button')).toHaveClass('btn-secondary');
  });
  
  test('renders disabled', () => {
    render(<Button disabled>Disabled</Button>);
    expect(screen.getByRole('button')).toBeDisabled();
  });
});

Hooks 测试 #

useState 测试 #

javascript
// Toggle.jsx
function Toggle() {
  const [isOn, setIsOn] = useState(false);
  
  return (
    <button onClick={() => setIsOn(!isOn)}>
      {isOn ? 'ON' : 'OFF'}
    </button>
  );
}

// Toggle.test.jsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Toggle from './Toggle';

test('toggle button', async () => {
  const user = userEvent.setup();
  render(<Toggle />);
  
  const button = screen.getByRole('button');
  
  expect(button).toHaveTextContent('OFF');
  
  await user.click(button);
  expect(button).toHaveTextContent('ON');
  
  await user.click(button);
  expect(button).toHaveTextContent('OFF');
});

useEffect 测试 #

javascript
// UserProfile.jsx
import { useState, useEffect } from 'react';

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  
  useEffect(() => {
    fetchUser(userId).then(data => {
      setUser(data);
      setLoading(false);
    });
  }, [userId]);
  
  if (loading) return <div>Loading...</div>;
  return <div>{user.name}</div>;
}

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

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

test('loads and displays user', async () => {
  render(<UserProfile userId={1} />);
  
  expect(screen.getByText('Loading...')).toBeInTheDocument();
  
  await waitFor(() => {
    expect(screen.getByText('John')).toBeInTheDocument();
  });
});

自定义 Hook 测试 #

javascript
// useCounter.js
import { useState } from 'react';

function useCounter(initialValue = 0) {
  const [count, setCount] = useState(initialValue);
  
  const increment = () => setCount(c => c + 1);
  const decrement = () => setCount(c => c - 1);
  const reset = () => setCount(initialValue);
  
  return { count, increment, decrement, reset };
}

// useCounter.test.js
import { renderHook, act } from '@testing-library/react';
import useCounter from './useCounter';

test('useCounter', () => {
  const { result } = renderHook(() => useCounter(0));
  
  expect(result.current.count).toBe(0);
  
  act(() => {
    result.current.increment();
  });
  expect(result.current.count).toBe(1);
  
  act(() => {
    result.current.decrement();
  });
  expect(result.current.count).toBe(0);
  
  act(() => {
    result.current.reset();
  });
  expect(result.current.count).toBe(0);
});

Context 测试 #

Context Provider 测试 #

javascript
// ThemeContext.jsx
import { createContext, useContext, useState } from 'react';

const ThemeContext = createContext();

function ThemeProvider({ children }) {
  const [theme, setTheme] = useState('light');
  
  const toggleTheme = () => {
    setTheme(t => t === 'light' ? 'dark' : 'light');
  };
  
  return (
    <ThemeContext.Provider value={{ theme, toggleTheme }}>
      {children}
    </ThemeContext.Provider>
  );
}

function useTheme() {
  return useContext(ThemeContext);
}

// ThemeContext.test.jsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ThemeProvider, useTheme } from './ThemeContext';

function ThemeConsumer() {
  const { theme, toggleTheme } = useTheme();
  return (
    <div>
      <span data-testid="theme">{theme}</span>
      <button onClick={toggleTheme}>Toggle</button>
    </div>
  );
}

test('theme context', async () => {
  const user = userEvent.setup();
  
  render(
    <ThemeProvider>
      <ThemeConsumer />
    </ThemeProvider>
  );
  
  expect(screen.getByTestId('theme')).toHaveTextContent('light');
  
  await user.click(screen.getByRole('button'));
  expect(screen.getByTestId('theme')).toHaveTextContent('dark');
});

Redux 测试 #

Store 测试 #

javascript
// store.js
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from './counterSlice';

export const store = configureStore({
  reducer: {
    counter: counterReducer,
  },
});

// counterSlice.js
import { createSlice } from '@reduxjs/toolkit';

const counterSlice = createSlice({
  name: 'counter',
  initialState: { value: 0 },
  reducers: {
    increment: state => { state.value += 1; },
    decrement: state => { state.value -= 1; },
  },
});

// Counter.test.jsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Provider } from 'react-redux';
import { store } from './store';
import Counter from './Counter';

function renderWithRedux(component) {
  return {
    ...render(<Provider store={store}>{component}</Provider>),
    store,
  };
}

test('counter with Redux', async () => {
  const user = userEvent.setup();
  renderWithRedux(<Counter />);
  
  const count = screen.getByTestId('count');
  const increment = screen.getByRole('button', { name: /increment/i });
  
  expect(count).toHaveTextContent('0');
  
  await user.click(increment);
  expect(count).toHaveTextContent('1');
});

异步组件测试 #

数据获取 #

javascript
// UserList.jsx
import { useState, useEffect } from 'react';

function UserList() {
  const [users, setUsers] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  
  useEffect(() => {
    fetch('/api/users')
      .then(res => res.json())
      .then(data => {
        setUsers(data);
        setLoading(false);
      })
      .catch(err => {
        setError(err.message);
        setLoading(false);
      });
  }, []);
  
  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error}</div>;
  
  return (
    <ul>
      {users.map(user => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

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

beforeEach(() => {
  global.fetch = jest.fn();
});

test('displays user list', async () => {
  fetch.mockResolvedValueOnce({
    json: () => Promise.resolve([
      { id: 1, name: 'John' },
      { id: 2, name: 'Jane' },
    ]),
  });
  
  render(<UserList />);
  
  expect(screen.getByText('Loading...')).toBeInTheDocument();
  
  await waitFor(() => {
    expect(screen.getByText('John')).toBeInTheDocument();
    expect(screen.getByText('Jane')).toBeInTheDocument();
  });
});

test('handles error', async () => {
  fetch.mockRejectedValueOnce(new Error('Network error'));
  
  render(<UserList />);
  
  await waitFor(() => {
    expect(screen.getByText('Error: Network error')).toBeInTheDocument();
  });
});

表单测试 #

javascript
// LoginForm.jsx
import { useState } from 'react';

function LoginForm({ onSubmit }) {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  
  const handleSubmit = (e) => {
    e.preventDefault();
    onSubmit({ email, password });
  };
  
  return (
    <form onSubmit={handleSubmit}>
      <input
        type="email"
        placeholder="Email"
        value={email}
        onChange={e => setEmail(e.target.value)}
        data-testid="email"
      />
      <input
        type="password"
        placeholder="Password"
        value={password}
        onChange={e => setPassword(e.target.value)}
        data-testid="password"
      />
      <button type="submit">Login</button>
    </form>
  );
}

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

test('login form submission', async () => {
  const user = userEvent.setup();
  const handleSubmit = jest.fn();
  
  render(<LoginForm onSubmit={handleSubmit} />);
  
  await user.type(screen.getByTestId('email'), 'test@example.com');
  await user.type(screen.getByTestId('password'), 'password123');
  await user.click(screen.getByRole('button', { name: /login/i }));
  
  expect(handleSubmit).toHaveBeenCalledWith({
    email: 'test@example.com',
    password: 'password123',
  });
});

最佳实践 #

1. 测试用户行为 #

javascript
// ✅ 好的做法
test('user can submit form', async () => {
  await user.type(emailInput, 'test@example.com');
  await user.click(submitButton);
  expect(onSubmit).toHaveBeenCalled();
});

// ❌ 不好的做法
test('form state changes', () => {
  component.setState({ email: 'test@example.com' });
});

2. 使用可访问性查询 #

javascript
// ✅ 好的做法
screen.getByRole('button', { name: /submit/i });
screen.getByLabelText('Email');

// ❌ 不好的做法
screen.getByTestId('submit-button');
container.querySelector('.btn');

3. 避免实现细节 #

javascript
// ✅ 好的做法
expect(screen.getByText('Success')).toBeInTheDocument();

// ❌ 不好的做法
expect(component.state('status')).toBe('success');

下一步 #

现在你已经掌握了 React 测试,接下来学习 Vue 测试 实战 Vue 组件测试!

最后更新:2026-03-28