Jest 在react项目中的运用
Jest在React项目中的完整使用指南
下面详细介绍Jest在React项目中的各种使用场景和最佳实践。
安装和配置
1. 安装依赖
# 使用Create React App(已包含Jest)
npx create-react-app my-app
# 或在现有项目中安装
npm install --save-dev jest @testing-library/react @testing-library/jest-dom @testing-library/user-event
2. Jest配置文件
// jest.config.js
module.exports = {
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['<rootDir>/src/setupTests.js'],
moduleNameMapping: {
'\\.(css|less|scss|sass)$': 'identity-obj-proxy',
'\\.(jpg|jpeg|png|gif|webp|svg)$': '<rootDir>/__mocks__/fileMock.js',
'^@/(.*)$': '<rootDir>/src/$1' // 路径别名
},
collectCoverageFrom: [
'src/**/*.{js,jsx,ts,tsx}',
'!src/**/*.d.ts',
'!src/index.js',
'!src/reportWebVitals.js'
]
};
// __mocks__/fileMock.js
module.exports = 'test-file-stub';
// src/setupTests.js
import '@testing-library/jest-dom';
// 全局测试配置
基础组件测试
1. 简单组件测试
// components/Button.jsx
import React from 'react';
import './Button.css';
const Button = ({ onClick, children, variant = 'primary', disabled = false }) => {
return (
);
};
export default Button;
// components/Button.test.jsx
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import Button from './Button';
describe('Button Component', () => {
test('renders with correct text', () => {
render(Click Me);
expect(screen.getByText('Click Me')).toBeInTheDocument();
});
test('applies correct variant class', () => {
const { rerender } = render(Test);
expect(screen.getByTestId('button')).toHaveClass('btn-primary');
rerender(Test);
expect(screen.getByTestId('button')).toHaveClass('btn-secondary');
});
test('calls onClick when clicked', () => {
const handleClick = jest.fn();
render(Click Me);
fireEvent.click(screen.getByText('Click Me'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
test('is disabled when disabled prop is true', () => {
render(Click Me);
expect(screen.getByTestId('button')).toBeDisabled();
});
});
2. 表单组件测试
// components/LoginForm.jsx
import React, { useState } from 'react';
const LoginForm = ({ onSubmit, loading = false }) => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const handleSubmit = (e) => {
e.preventDefault();
onSubmit({ email, password });
};
return (
Email:
setEmail(e.target.value)}
required
data-testid="email-input"
/>
Password:
setPassword(e.target.value)}
required
data-testid="password-input"
/>
);
};
export default LoginForm;
// components/LoginForm.test.jsx
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import LoginForm from './LoginForm';
describe('LoginForm Component', () => {
const mockOnSubmit = jest.fn();
beforeEach(() => {
mockOnSubmit.mockClear();
});
test('submits form with correct data', async () => {
const user = userEvent.setup();
render();
// 使用 userEvent 模拟用户输入
await user.type(screen.getByTestId('email-input'), 'test@example.com');
await user.type(screen.getByTestId('password-input'), 'password123');
// 提交表单
await user.click(screen.getByTestId('submit-button'));
expect(mockOnSubmit).toHaveBeenCalledWith({
email: 'test@example.com',
password: 'password123'
});
});
test('shows loading state', () => {
render();
expect(screen.getByTestId('submit-button')).toBeDisabled();
expect(screen.getByTestId('submit-button')).toHaveTextContent('Logging in...');
});
test('validates required fields', async () => {
const user = userEvent.setup();
render();
// 直接提交而不填写字段
await user.click(screen.getByTestId('submit-button'));
// 由于HTML5验证,onSubmit不会被调用
expect(mockOnSubmit).not.toHaveBeenCalled();
});
});
异步组件测试
1. 数据获取组件
// components/UserList.jsx
import React, { useState, useEffect } from 'react';
import { userApi } from '../api/userApi';
const UserList = () => {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const fetchUsers = async () => {
try {
setLoading(true);
const data = await userApi.getUsers();
setUsers(data);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
fetchUsers();
}, []);
if (loading) return Loading users...;
if (error) return Error: {error};
return (
Users
{users.map(user => (
{user.name} - {user.email}
))}
);
};
export default UserList;
// components/UserList.test.jsx
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import UserList from './UserList';
import { userApi } from '../api/userApi';
// Mock API模块
jest.mock('../api/userApi');
describe('UserList Component', () => {
const mockUsers = [
{ id: 1, name: 'John Doe', email: 'john@example.com' },
{ id: 2, name: 'Jane Smith', email: 'jane@example.com' }
];
beforeEach(() => {
userApi.getUsers.mockClear();
});
test('displays loading state initially', () => {
userApi.getUsers.mockImplementation(() => new Promise(() => {}));
render();
expect(screen.getByTestId('loading')).toBeInTheDocument();
});
test('displays users when data is fetched successfully', async () => {
userApi.getUsers.mockResolvedValue(mockUsers);
render();
// 等待数据加载完成
await waitFor(() => {
expect(screen.getByTestId('user-list')).toBeInTheDocument();
});
expect(screen.getByText('John Doe - john@example.com')).toBeInTheDocument();
expect(screen.getByText('Jane Smith - jane@example.com')).toBeInTheDocument();
expect(screen.queryByTestId('loading')).not.toBeInTheDocument();
});
test('displays error when data fetch fails', async () => {
userApi.getUsers.mockRejectedValue(new Error('Network error'));
render();
await waitFor(() => {
expect(screen.getByTestId('error')).toBeInTheDocument();
});
expect(screen.getByText('Error: Network error')).toBeInTheDocument();
});
});
Hook测试
1. 自定义Hook测试
// hooks/useLocalStorage.js
import { useState, useEffect } from 'react';
export const useLocalStorage = (key, initialValue) => {
const [value, setValue] = useState(() => {
const storedValue = localStorage.getItem(key);
return storedValue ? JSON.parse(storedValue) : initialValue;
});
useEffect(() => {
localStorage.setItem(key, JSON.stringify(value));
}, [key, value]);
return [value, setValue];
};
// hooks/useLocalStorage.test.js
import { renderHook, act } from '@testing-library/react';
import { useLocalStorage } from './useLocalStorage';
// Mock localStorage
const localStorageMock = (() => {
let store = {};
return {
getItem: jest.fn((key) => store[key] || null),
setItem: jest.fn((key, value) => {
store[key] = value.toString();
}),
clear: jest.fn(() => {
store = {};
}),
removeItem: jest.fn((key) => {
delete store[key];
})
};
})();
Object.defineProperty(window, 'localStorage', {
value: localStorageMock
});
describe('useLocalStorage Hook', () => {
beforeEach(() => {
localStorageMock.clear();
localStorageMock.getItem.mockClear();
localStorageMock.setItem.mockClear();
});
test('uses initial value when no stored value', () => {
const { result } = renderHook(() => useLocalStorage('testKey', 'default'));
expect(result.current[0]).toBe('default');
expect(localStorageMock.getItem).toHaveBeenCalledWith('testKey');
});
test('uses stored value when available', () => {
localStorageMock.getItem.mockReturnValue('"stored value"');
const { result } = renderHook(() => useLocalStorage('testKey', 'default'));
expect(result.current[0]).toBe('stored value');
});
test('updates localStorage when value changes', () => {
const { result } = renderHook(() => useLocalStorage('testKey', 'default'));
act(() => {
result.current[1]('new value');
});
expect(localStorageMock.setItem).toHaveBeenCalledWith(
'testKey',
'"new value"'
);
expect(result.current[0]).toBe('new value');
});
});
路由组件测试
1. React Router组件测试
// components/Navigation.jsx
import React from 'react';
import { Link, useLocation } from 'react-router-dom';
const Navigation = () => {
const location = useLocation();
return (
Home
About
Contact
);
};
export default Navigation;
// components/Navigation.test.jsx
import React from 'react';
import { render, screen } from '@testing-library/react';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import Navigation from './Navigation';
describe('Navigation Component', () => {
const renderWithRouter = (initialPath = '/') => {
return render(
Home Page} />
About Page} />
Contact Page} />
);
};
test('highlights active link for home', () => {
renderWithRouter('/');
expect(screen.getByTestId('home-link')).toHaveClass('active');
expect(screen.getByTestId('about-link')).not.toHaveClass('active');
expect(screen.getByTestId('contact-link')).not.toHaveClass('active');
});
test('highlights active link for about', () => {
renderWithRouter('/about');
expect(screen.getByTestId('home-link')).not.toHaveClass('active');
expect(screen.getByTestId('about-link')).toHaveClass('active');
expect(screen.getByTestId('contact-link')).not.toHaveClass('active');
});
test('contains all navigation links', () => {
renderWithRouter();
expect(screen.getByTestId('home-link')).toHaveTextContent('Home');
expect(screen.getByTestId('about-link')).toHaveTextContent('About');
expect(screen.getByTestId('contact-link')).toHaveTextContent('Contact');
});
});
上下文(Context)测试
1. Context Provider测试
// context/ThemeContext.jsx
import React, { createContext, useContext, useState } from 'react';
const ThemeContext = createContext();
export const useTheme = () => {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
};
export const ThemeProvider = ({ children }) => {
const [theme, setTheme] = useState('light');
const toggleTheme = () => {
setTheme(prevTheme => prevTheme === 'light' ? 'dark' : 'light');
};
const value = {
theme,
toggleTheme,
isDark: theme === 'dark'
};
return (
{children}
);
};
// context/ThemeContext.test.jsx
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { ThemeProvider, useTheme } from './ThemeContext';
// 测试组件
const TestComponent = () => {
const { theme, toggleTheme, isDark } = useTheme();
return (
{theme}
{isDark.toString()}
);
};
describe('ThemeContext', () => {
test('provides default theme values', () => {
render(
);
expect(screen.getByTestId('theme')).toHaveTextContent('light');
expect(screen.getByTestId('is-dark')).toHaveTextContent('false');
});
test('toggles theme correctly', () => {
render(
);
// 初始状态
expect(screen.getByTestId('theme')).toHaveTextContent('light');
// 切换主题
fireEvent.click(screen.getByTestId('toggle-theme'));
// 验证主题已切换
expect(screen.getByTestId('theme')).toHaveTextContent('dark');
expect(screen.getByTestId('is-dark')).toHaveTextContent('true');
// 再次切换
fireEvent.click(screen.getByTestId('toggle-theme'));
expect(screen.getByTestId('theme')).toHaveTextContent('light');
});
});
集成测试
1. 完整功能测试
// components/TodoApp.jsx
import React, { useState } from 'react';
const TodoApp = () => {
const [todos, setTodos] = useState([]);
const [inputValue, setInputValue] = useState('');
const addTodo = () => {
if (inputValue.trim()) {
setTodos([...todos, { id: Date.now(), text: inputValue, completed: false }]);
setInputValue('');
}
};
const toggleTodo = (id) => {
setTodos(todos.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
));
};
const deleteTodo = (id) => {
setTodos(todos.filter(todo => todo.id !== id));
};
return (
Todo App
setInputValue(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && addTodo()}
placeholder="Add a new todo"
data-testid="todo-input"
/>
{todos.map(todo => (
toggleTodo(todo.id)}
data-testid="todo-text"
>
{todo.text}
))}
Total: {todos.length} | Completed: {todos.filter(t => t.completed).length}
);
};
export default TodoApp;
// components/TodoApp.test.jsx
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import TodoApp from './TodoApp';
describe('TodoApp Integration Test', () => {
test('complete todo flow', async () => {
const user = userEvent.setup();
render();
// 添加todo
await user.type(screen.getByTestId('todo-input'), 'Learn React Testing');
await user.click(screen.getByTestId('add-todo-button'));
// 验证todo已添加
expect(screen.getByText('Learn React Testing')).toBeInTheDocument();
expect(screen.getByTestId('todo-count')).toHaveTextContent('Total: 1 | Completed: 0');
// 标记为完成
await user.click(screen.getByTestId('todo-text'));
expect(screen.getByText('Learn React Testing')).toHaveStyle(
'text-decoration: line-through'
);
expect(screen.getByTestId('todo-count')).toHaveTextContent('Total: 1 | Completed: 1');
// 删除todo
await user.click(screen.getByTestId('delete-todo-button'));
expect(screen.queryByText('Learn React Testing')).not.toBeInTheDocument();
expect(screen.getByTestId('todo-count')).toHaveTextContent('Total: 0 | Completed: 0');
});
test('add multiple todos', async () => {
const user = userEvent.setup();
render();
// 添加多个todos
const todos = ['Todo 1', 'Todo 2', 'Todo 3'];
for (const todoText of todos) {
await user.type(screen.getByTestId('todo-input'), todoText);
await user.click(screen.getByTestId('add-todo-button'));
}
// 验证所有todos都存在
for (const todoText of todos) {
expect(screen.getByText(todoText)).toBeInTheDocument();
}
expect(screen.getByTestId('todo-count')).toHaveTextContent('Total: 3 | Completed: 0');
});
});
测试覆盖率配置
// package.json
{
"scripts": {
"test": "jest",
"test:coverage": "jest --coverage",
"test:watch": "jest --watch"
}
}
# 运行测试并生成覆盖率报告
npm run test:coverage
# 查看覆盖率报告
open coverage/lcov-report/index.html
最佳实践
- 优先测试用户行为:而不是实现细节
- 使用适当的查询方法:优先使用
getByRole
、getByLabelText
- 避免过度Mock:只Mock外部依赖
- 保持测试独立:每个测试应该能够独立运行
- 使用描述性测试名称:清晰表达测试意图
通过以上示例和模式,您可以在React项目中有效地使用Jest进行各种类型的测试,确保代码质量和应用稳定性。