Jest React 测试 #
React 测试概述 #
React 组件测试主要关注组件的渲染输出和用户交互,使用 Testing Library 的"以用户为中心"的测试方法。
text
┌─────────────────────────────────────────────────────────────┐
│ React 测试层次 │
├─────────────────────────────────────────────────────────────┤
│ 1. 渲染测试 - 组件是否正确渲染 │
│ 2. 交互测试 - 用户交互是否正常工作 │
│ 3. 状态测试 - 状态变化是否正确 │
│ 4. Props 测试 - Props 传递是否正确 │
│ 5. 集成测试 - 组件协作是否正常 │
└─────────────────────────────────────────────────────────────┘
环境配置 #
安装依赖 #
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'],
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
'\\.(css|less|scss|sass)$': 'identity-obj-proxy',
},
};
// jest.setup.js
import '@testing-library/jest-dom';
基础组件测试 #
简单组件 #
jsx
// 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();
});
test('renders with different names', () => {
const { rerender } = render(<Greeting name="John" />);
expect(screen.getByText('Hello, John!')).toBeInTheDocument();
rerender(<Greeting name="Jane" />);
expect(screen.getByText('Hello, Jane!')).toBeInTheDocument();
});
条件渲染 #
jsx
// UserStatus.jsx
function UserStatus({ isLoggedIn, user }) {
if (!isLoggedIn) {
return <button>Login</button>;
}
return <span>Welcome, {user.name}</span>;
}
// UserStatus.test.jsx
import { render, screen } from '@testing-library/react';
import UserStatus from './UserStatus';
describe('UserStatus', () => {
test('shows login button when not logged in', () => {
render(<UserStatus isLoggedIn={false} />);
expect(screen.getByRole('button', { name: 'Login' })).toBeInTheDocument();
});
test('shows welcome message when logged in', () => {
render(<UserStatus isLoggedIn={true} user={{ name: 'John' }} />);
expect(screen.getByText('Welcome, John')).toBeInTheDocument();
});
});
列表渲染 #
jsx
// TodoList.jsx
function TodoList({ items }) {
if (items.length === 0) {
return <p>No items</p>;
}
return (
<ul>
{items.map(item => (
<li key={item.id}>{item.text}</li>
))}
</ul>
);
}
// TodoList.test.jsx
import { render, screen } from '@testing-library/react';
import TodoList from './TodoList';
describe('TodoList', () => {
test('shows 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.getAllByRole('listitem')).toHaveLength(2);
expect(screen.getByText('Buy milk')).toBeInTheDocument();
expect(screen.getByText('Walk dog')).toBeInTheDocument();
});
});
用户交互测试 #
按钮点击 #
jsx
// 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';
describe('Counter', () => {
test('increments count', async () => {
const user = userEvent.setup();
render(<Counter />);
await user.click(screen.getByRole('button', { name: 'Increment' }));
expect(screen.getByTestId('count')).toHaveTextContent('1');
});
test('decrements count', async () => {
const user = userEvent.setup();
render(<Counter />);
await user.click(screen.getByRole('button', { name: 'Decrement' }));
expect(screen.getByTestId('count')).toHaveTextContent('-1');
});
test('multiple clicks', async () => {
const user = userEvent.setup();
render(<Counter />);
await user.click(screen.getByRole('button', { name: 'Increment' }));
await user.click(screen.getByRole('button', { name: 'Increment' }));
await user.click(screen.getByRole('button', { name: 'Increment' }));
expect(screen.getByTestId('count')).toHaveTextContent('3');
});
});
表单输入 #
jsx
// SearchForm.jsx
function SearchForm({ onSearch }) {
const [query, setQuery] = useState('');
const handleSubmit = (e) => {
e.preventDefault();
onSearch(query);
};
return (
<form onSubmit={handleSubmit}>
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search..."
/>
<button type="submit">Search</button>
</form>
);
}
// SearchForm.test.jsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import SearchForm from './SearchForm';
describe('SearchForm', () => {
test('updates input value', async () => {
const user = userEvent.setup();
render(<SearchForm onSearch={() => {}} />);
const input = screen.getByPlaceholderText('Search...');
await user.type(input, 'react testing');
expect(input).toHaveValue('react testing');
});
test('submits form with query', async () => {
const user = userEvent.setup();
const onSearch = jest.fn();
render(<SearchForm onSearch={onSearch} />);
await user.type(screen.getByPlaceholderText('Search...'), 'react');
await user.click(screen.getByRole('button', { name: 'Search' }));
expect(onSearch).toHaveBeenCalledWith('react');
});
});
复选框和单选框 #
jsx
// Preferences.jsx
function Preferences({ preferences, onChange }) {
return (
<div>
<label>
<input
type="checkbox"
checked={preferences.notifications}
onChange={(e) => onChange({ ...preferences, notifications: e.target.checked })}
/>
Enable notifications
</label>
<label>
<input
type="radio"
name="theme"
value="light"
checked={preferences.theme === 'light'}
onChange={() => onChange({ ...preferences, theme: 'light' })}
/>
Light
</label>
<label>
<input
type="radio"
name="theme"
value="dark"
checked={preferences.theme === 'dark'}
onChange={() => onChange({ ...preferences, theme: 'dark' })}
/>
Dark
</label>
</div>
);
}
// Preferences.test.jsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Preferences from './Preferences';
describe('Preferences', () => {
test('toggles checkbox', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
render(<Preferences preferences={{ notifications: false, theme: 'light' }} onChange={onChange} />);
await user.click(screen.getByLabelText('Enable notifications'));
expect(onChange).toHaveBeenCalledWith({ notifications: true, theme: 'light' });
});
test('selects radio button', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
render(<Preferences preferences={{ notifications: false, theme: 'light' }} onChange={onChange} />);
await user.click(screen.getByLabelText('Dark'));
expect(onChange).toHaveBeenCalledWith({ notifications: false, theme: 'dark' });
});
});
异步操作测试 #
数据获取 #
jsx
// UserProfile.jsx
import { useState, useEffect } from 'react';
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
async function fetchUser() {
try {
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) throw new Error('Failed to fetch');
const data = await response.json();
setUser(data);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
fetchUser();
}, [userId]);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
return <div>{user.name}</div>;
}
// UserProfile.test.jsx
import { render, screen, waitFor } from '@testing-library/react';
import UserProfile from './UserProfile';
describe('UserProfile', () => {
beforeEach(() => {
jest.spyOn(global, 'fetch');
});
afterEach(() => {
jest.restoreAllMocks();
});
test('shows loading state', () => {
fetch.mockImplementation(() => new Promise(() => {}));
render(<UserProfile userId={1} />);
expect(screen.getByText('Loading...')).toBeInTheDocument();
});
test('shows user data', async () => {
fetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ name: 'John' }),
});
render(<UserProfile userId={1} />);
await waitFor(() => {
expect(screen.getByText('John')).toBeInTheDocument();
});
});
test('shows error message', async () => {
fetch.mockResolvedValue({ ok: false });
render(<UserProfile userId={1} />);
await waitFor(() => {
expect(screen.getByText('Error: Failed to fetch')).toBeInTheDocument();
});
});
});
Hooks 测试 #
自定义 Hook 测试 #
jsx
// 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';
describe('useCounter', () => {
test('initializes with default value', () => {
const { result } = renderHook(() => useCounter());
expect(result.current.count).toBe(0);
});
test('initializes with custom value', () => {
const { result } = renderHook(() => useCounter(10));
expect(result.current.count).toBe(10);
});
test('increments count', () => {
const { result } = renderHook(() => useCounter());
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
test('decrements count', () => {
const { result } = renderHook(() => useCounter());
act(() => {
result.current.decrement();
});
expect(result.current.count).toBe(-1);
});
test('resets count', () => {
const { result } = renderHook(() => useCounter(5));
act(() => {
result.current.increment();
result.current.reset();
});
expect(result.current.count).toBe(5);
});
});
测试 useEffect #
jsx
// useDocumentTitle.js
import { useEffect } from 'react';
function useDocumentTitle(title) {
useEffect(() => {
document.title = title;
}, [title]);
}
// useDocumentTitle.test.js
import { renderHook } from '@testing-library/react';
import useDocumentTitle from './useDocumentTitle';
test('updates document title', () => {
renderHook(() => useDocumentTitle('Test Title'));
expect(document.title).toBe('Test Title');
});
test('updates title on change', () => {
const { rerender } = renderHook(({ title }) => useDocumentTitle(title), {
initialProps: { title: 'First Title' },
});
expect(document.title).toBe('First Title');
rerender({ title: 'Second Title' });
expect(document.title).toBe('Second Title');
});
Context 测试 #
jsx
// 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 TestComponent() {
const { theme, toggleTheme } = useTheme();
return (
<div>
<span data-testid="theme">{theme}</span>
<button onClick={toggleTheme}>Toggle</button>
</div>
);
}
describe('ThemeContext', () => {
test('provides default theme', () => {
render(
<ThemeProvider>
<TestComponent />
</ThemeProvider>
);
expect(screen.getByTestId('theme')).toHaveTextContent('light');
});
test('toggles theme', async () => {
const user = userEvent.setup();
render(
<ThemeProvider>
<TestComponent />
</ThemeProvider>
);
await user.click(screen.getByRole('button', { name: 'Toggle' }));
expect(screen.getByTestId('theme')).toHaveTextContent('dark');
await user.click(screen.getByRole('button', { name: 'Toggle' }));
expect(screen.getByTestId('theme')).toHaveTextContent('light');
});
});
最佳实践 #
1. 测试用户行为 #
javascript
// ❌ 不好的做法 - 测试实现细节
expect(component.state.count).toBe(1);
// ✅ 好的做法 - 测试用户看到的内容
expect(screen.getByText('Count: 1')).toBeInTheDocument();
2. 使用可访问性查询 #
javascript
// ❌ 不好的做法
screen.getByTestId('submit-button');
// ✅ 好的做法
screen.getByRole('button', { name: 'Submit' });
3. 测试异步操作 #
javascript
// ❌ 不好的做法
test('bad async', () => {
render(<Component />);
expect(screen.getByText('Loaded')).toBeInTheDocument();
});
// ✅ 好的做法
test('good async', async () => {
render(<Component />);
await screen.findByText('Loaded');
});
下一步 #
现在你已经掌握了 React 组件测试,接下来学习 Vue 测试 实战 Vue 组件测试!
最后更新:2026-03-28