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