在现代前端开发中,测试是确保代码质量和应用稳定性的关键环节。本文将深入探讨如何使用Jest和React Testing Library构建完整的React应用测试体系。
目录
测试基础概念
测试类型
单元测试(Unit Testing)
- 测试单个组件或函数的功能
- 快速执行,易于调试
- 应该占测试套件的大部分
集成测试(Integration Testing)
- 测试多个组件协同工作
- 验证组件间的交互
- 更接近真实用户场景
端到端测试(E2E Testing)
- 模拟真实用户操作
- 测试完整的用户流程
- 执行较慢,但提供最高信心
测试金字塔
/\
/E2E\ - 少量,覆盖关键用户流程
/______\
/Integration\ - 适量,测试组件交互
/______________\
Unit Tests - 大量,快速反馈
Jest测试框架
Jest是Facebook开发的JavaScript测试框架,提供了完整的测试解决方案。
核心特性
- 零配置:开箱即用
- 快照测试:自动生成和比较组件渲染结果
- 内置断言:丰富的匹配器
- 代码覆盖率:无需额外配置
- 并行执行:提高测试速度
基本语法
// 基础测试结构
describe('Calculator', () => {
test('should add two numbers', () => {
expect(add(2, 3)).toBe(5);
});
test('should handle negative numbers', () => {
expect(add(-1, 1)).toBe(0);
});
});
常用匹配器
// 基本匹配器
expect(value).toBe(4); // 严格相等
expect(value).toEqual({name: 'John'}); // 深度相等
expect(value).toBeNull(); // null值
expect(value).toBeUndefined(); // undefined值
expect(value).toBeTruthy(); // 真值
expect(value).toBeFalsy(); // 假值
// 数字匹配器
expect(value).toBeGreaterThan(3);
expect(value).toBeCloseTo(0.3);
// 字符串匹配器
expect('team').toMatch(/I/);
expect('Christoph').toMatch('stop');
// 数组匹配器
expect(['Alice', 'Bob', 'Eve']).toContain('Alice');
// 异常匹配器
expect(() => {
throw new Error('Wrong!');
}).toThrow('Wrong!');
React Testing Library介绍
React Testing Library基于"测试应该尽可能接近用户使用软件的方式"的理念设计。
核心原则
- 用户为中心:通过用户能看到和交互的方式测试
- 实现细节无关:不测试组件内部状态或方法
- 可访问性优先:鼓励编写可访问的组件
查询优先级
// 推荐优先级(从高到低)
// 1. 对所有人可访问的查询
getByRole('button', {name: /submit/i})
getByLabelText(/username/i)
getByPlaceholderText(/enter username/i)
getByText(/hello world/i)
// 2. 语义化查询
getByAltText(/profile picture/i)
getByTitle(/close/i)
// 3. 测试ID(最后选择)
getByTestId('submit-button')
环境搭建
安装依赖
# 如果使用Create React App,已内置所需依赖
npx create-react-app my-app
# 手动安装
npm install --save-dev @testing-library/react @testing-library/jest-dom @testing-library/user-event
配置文件
// src/setupTests.js
import '@testing-library/jest-dom';
// jest.config.js
module.exports = {
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['<rootDir>/src/setupTests.js'],
moduleNameMapping: {
'\\.(css|less|scss|sass)$': 'identity-obj-proxy',
},
collectCoverageFrom: [
'src/**/*.{js,jsx}',
'!src/index.js',
'!src/reportWebVitals.js',
],
};
基础组件测试
简单组件测试
// components/Button.js
import React from 'react';
const Button = ({ children, onClick, disabled = false, variant = 'primary' }) => {
return (
<button
onClick={onClick}
disabled={disabled}
className={`btn btn-${variant}`}
data-testid="custom-button"
>
{children}
</button>
);
};
export default Button;
// components/__tests__/Button.test.js
import React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Button from '../Button';
describe('Button Component', () => {
test('renders button with text', () => {
render(<Button>Click me</Button>);
expect(screen.getByRole('button', { name: /click me/i })).toBeInTheDocument();
});
test('calls onClick when clicked', async () => {
const user = userEvent.setup();
const handleClick = jest.fn();
render(<Button onClick={handleClick}>Click me</Button>);
await user.click(screen.getByRole('button'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
test('is disabled when disabled prop is true', () => {
render(<Button disabled>Click me</Button>);
expect(screen.getByRole('button')).toBeDisabled();
});
test('applies correct CSS class for variant', () => {
render(<Button variant="secondary">Click me</Button>);
expect(screen.getByRole('button')).toHaveClass('btn-secondary');
});
});
表单组件测试
// components/LoginForm.js
import React, { useState } from 'react';
const LoginForm = ({ onSubmit }) => {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const handleSubmit = (e) => {
e.preventDefault();
setError('');
if (!username || !password) {
setError('请填写所有字段');
return;
}
onSubmit({ username, password });
};
return (
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="username">用户名:</label>
<input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
</div>
<div>
<label htmlFor="password">密码:</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
{error && <div role="alert">{error}</div>}
<button type="submit">登录</button>
</form>
);
};
export default LoginForm;
// components/__tests__/LoginForm.test.js
import React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import LoginForm from '../LoginForm';
describe('LoginForm', () => {
test('renders form fields', () => {
render(<LoginForm onSubmit={jest.fn()} />);
expect(screen.getByLabelText(/用户名/i)).toBeInTheDocument();
expect(screen.getByLabelText(/密码/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /登录/i })).toBeInTheDocument();
});
test('shows error when submitting empty form', async () => {
const user = userEvent.setup();
render(<LoginForm onSubmit={jest.fn()} />);
await user.click(screen.getByRole('button', { name: /登录/i }));
expect(screen.getByRole('alert')).toHaveTextContent('请填写所有字段');
});
test('calls onSubmit with form data when valid', async () => {
const user = userEvent.setup();
const mockSubmit = jest.fn();
render(<LoginForm onSubmit={mockSubmit} />);
await user.type(screen.getByLabelText(/用户名/i), 'testuser');
await user.type(screen.getByLabelText(/密码/i), 'password123');
await user.click(screen.getByRole('button', { name: /登录/i }));
expect(mockSubmit).toHaveBeenCalledWith({
username: 'testuser',
password: 'password123'
});
});
});
用户交互测试
模拟用户事件
import userEvent from '@testing-library/user-event';
// 点击事件
await user.click(element);
// 输入文本
await user.type(input, 'hello world');
// 选择选项
await user.selectOptions(select, 'option1');
// 键盘事件
await user.keyboard('{Enter}');
await user.keyboard('{Escape}');
// 复合操作
await user.clear(input);
await user.tab();
复杂交互示例
// components/TodoList.js
import React, { useState } from 'react';
const TodoList = () => {
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 (
<div>
<div>
<input
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
placeholder="添加新任务"
onKeyPress={(e) => e.key === 'Enter' && addTodo()}
/>
<button onClick={addTodo}>添加</button>
</div>
<ul>
{todos.map(todo => (
<li key={todo.id}>
<input
type="checkbox"
checked={todo.completed}
onChange={() => toggleTodo(todo.id)}
aria-label={`标记 "${todo.text}" 为${todo.completed ? '未完成' : '已完成'}`}
/>
<span style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}>
{todo.text}
</span>
<button onClick={() => deleteTodo(todo.id)}>删除</button>
</li>
))}
</ul>
</div>
);
};
export default TodoList;
// components/__tests__/TodoList.test.js
import React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import TodoList from '../TodoList';
describe('TodoList', () => {
test('adds new todo when button clicked', async () => {
const user = userEvent.setup();
render(<TodoList />);
const input = screen.getByPlaceholderText(/添加新任务/i);
const addButton = screen.getByRole('button', { name: /添加/i });
await user.type(input, '学习React测试');
await user.click(addButton);
expect(screen.getByText('学习React测试')).toBeInTheDocument();
expect(input).toHaveValue('');
});
test('adds todo when Enter key pressed', async () => {
const user = userEvent.setup();
render(<TodoList />);
const input = screen.getByPlaceholderText(/添加新任务/i);
await user.type(input, '学习Jest');
await user.keyboard('{Enter}');
expect(screen.getByText('学习Jest')).toBeInTheDocument();
});
test('toggles todo completion', async () => {
const user = userEvent.setup();
render(<TodoList />);
// 添加任务
await user.type(screen.getByPlaceholderText(/添加新任务/i), '测试任务');
await user.click(screen.getByRole('button', { name: /添加/i }));
// 切换完成状态
const checkbox = screen.getByRole('checkbox');
await user.click(checkbox);
expect(checkbox).toBeChecked();
expect(screen.getByText('测试任务')).toHaveStyle('text-decoration: line-through');
});
test('deletes todo', async () => {
const user = userEvent.setup();
render(<TodoList />);
// 添加任务
await user.type(screen.getByPlaceholderText(/添加新任务/i), '要删除的任务');
await user.click(screen.getByRole('button', { name: /添加/i }));
// 删除任务
await user.click(screen.getByRole('button', { name: /删除/i }));
expect(screen.queryByText('要删除的任务')).not.toBeInTheDocument();
});
});
异步操作测试
API调用测试
// services/api.js
export const fetchUser = async (id) => {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) {
throw new Error('用户不存在');
}
return response.json();
};
// components/UserProfile.js
import React, { useState, useEffect } from 'react';
import { fetchUser } from '../services/api';
const UserProfile = ({ userId }) => {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const loadUser = async () => {
try {
setLoading(true);
setError(null);
const userData = await fetchUser(userId);
setUser(userData);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
if (userId) {
loadUser();
}
}, [userId]);
if (loading) return <div>加载中...</div>;
if (error) return <div role="alert">错误: {error}</div>;
if (!user) return <div>用户不存在</div>;
return (
<div>
<h2>{user.name}</h2>
<p>邮箱: {user.email}</p>
<p>电话: {user.phone}</p>
</div>
);
};
export default UserProfile;
// components/__tests__/UserProfile.test.js
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import UserProfile from '../UserProfile';
import { fetchUser } from '../../services/api';
// Mock API调用
jest.mock('../../services/api');
const mockFetchUser = fetchUser as jest.MockedFunction<typeof fetchUser>;
describe('UserProfile', () => {
beforeEach(() => {
mockFetchUser.mockClear();
});
test('shows loading state initially', () => {
mockFetchUser.mockImplementation(() => new Promise(() => {})); // 永不resolve
render(<UserProfile userId="1" />);
expect(screen.getByText(/加载中/i)).toBeInTheDocument();
});
test('displays user data when loaded successfully', async () => {
const mockUser = {
id: '1',
name: '张三',
email: 'zhangsan@example.com',
phone: '123-456-7890'
};
mockFetchUser.mockResolvedValue(mockUser);
render(<UserProfile userId="1" />);
await waitFor(() => {
expect(screen.getByText('张三')).toBeInTheDocument();
});
expect(screen.getByText('邮箱: zhangsan@example.com')).toBeInTheDocument();
expect(screen.getByText('电话: 123-456-7890')).toBeInTheDocument();
});
test('displays error when API call fails', async () => {
mockFetchUser.mockRejectedValue(new Error('用户不存在'));
render(<UserProfile userId="999" />);
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent('错误: 用户不存在');
});
});
test('refetches user when userId changes', async () => {
const { rerender } = render(<UserProfile userId="1" />);
await waitFor(() => {
expect(mockFetchUser).toHaveBeenCalledWith('1');
});
rerender(<UserProfile userId="2" />);
await waitFor(() => {
expect(mockFetchUser).toHaveBeenCalledWith('2');
});
expect(mockFetchUser).toHaveBeenCalledTimes(2);
});
});
延时操作测试
// components/SearchInput.js
import React, { useState, useEffect } from 'react';
const SearchInput = ({ onSearch, delay = 300 }) => {
const [query, setQuery] = useState('');
useEffect(() => {
const timer = setTimeout(() => {
if (query) {
onSearch(query);
}
}, delay);
return () => clearTimeout(timer);
}, [query, delay, onSearch]);
return (
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="搜索..."
/>
);
};
export default SearchInput;
// components/__tests__/SearchInput.test.js
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import SearchInput from '../SearchInput';
// 启用fake timers
jest.useFakeTimers();
describe('SearchInput', () => {
afterEach(() => {
jest.clearAllTimers();
});
test('debounces search calls', async () => {
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
const mockSearch = jest.fn();
render(<SearchInput onSearch={mockSearch} delay={300} />);
const input = screen.getByPlaceholderText(/搜索/i);
// 快速输入多个字符
await user.type(input, 'react');
// 此时还没有调用搜索
expect(mockSearch).not.toHaveBeenCalled();
// 快进时间
jest.advanceTimersByTime(300);
// 现在应该调用搜索
await waitFor(() => {
expect(mockSearch).toHaveBeenCalledWith('react');
});
expect(mockSearch).toHaveBeenCalledTimes(1);
});
test('cancels previous timer when typing quickly', async () => {
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
const mockSearch = jest.fn();
render(<SearchInput onSearch={mockSearch} delay={300} />);
const input = screen.getByPlaceholderText(/搜索/i);
// 输入 'r'
await user.type(input, 'r');
jest.advanceTimersByTime(100);
// 继续输入 'e'
await user.type(input, 'e');
jest.advanceTimersByTime(100);
// 继续输入 'act'
await user.type(input, 'act');
// 快进到延时结束
jest.advanceTimersByTime(300);
// 应该只调用一次,使用最终的查询
await waitFor(() => {
expect(mockSearch).toHaveBeenCalledWith('react');
});
expect(mockSearch).toHaveBeenCalledTimes(1);
});
});
Mock和Spy
模拟函数
// 创建mock函数
const mockFn = jest.fn();
// 设置返回值
mockFn.mockReturnValue('mocked value');
mockFn.mockReturnValueOnce('first call');
mockFn.mockReturnValueOnce('second call');
// 设置异步返回值
mockFn.mockResolvedValue('async value');
mockFn.mockRejectedValue(new Error('async error'));
// 设置实现
mockFn.mockImplementation((x) => x * 2);
mockFn.mockImplementationOnce((x) => x * 3);
// 验证调用
expect(mockFn).toHaveBeenCalled();
expect(mockFn).toHaveBeenCalledTimes(2);
expect(mockFn).toHaveBeenCalledWith('arg1', 'arg2');
expect(mockFn).toHaveBeenLastCalledWith('last arg');
模拟模块
// 完全模拟模块
jest.mock('../utils/helper');
// 部分模拟模块
jest.mock('../utils/helper', () => ({
...jest.requireActual('../utils/helper'),
formatDate: jest.fn(() => '2023-01-01'),
}));
// 自动模拟
jest.mock('../services/api');
// 手动模拟
jest.mock('../services/api', () => ({
fetchData: jest.fn(),
postData: jest.fn(),
}));
Spy函数
// 监听对象方法
const obj = {
method: () => 'original'
};
const spy = jest.spyOn(obj, 'method');
spy.mockReturnValue('mocked');
// 监听console.log
const consoleSpy = jest.spyOn(console, 'log').mockImplementation();
// 恢复原始实现
spy.mockRestore();
自定义Hook测试
renderHook使用
// hooks/useCounter.js
import { useState } from 'react';
const 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 };
};
export default useCounter;
// hooks/__tests__/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(5));
act(() => {
result.current.decrement();
});
expect(result.current.count).toBe(4);
});
test('resets to initial value', () => {
const { result } = renderHook(() => useCounter(10));
act(() => {
result.current.increment();
result.current.increment();
});
expect(result.current.count).toBe(12);
act(() => {
result.current.reset();
});
expect(result.current.count).toBe(10);
});
});
异步Hook测试
// hooks/useFetch.js
import { useState, useEffect } from 'react';
const useFetch = (url) => {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
setError(null);
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
setData(result);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
if (url) {
fetchData();
}
}, [url]);
return { data, loading, error };
};
export default useFetch;
// hooks/__tests__/useFetch.test.js
import { renderHook, waitFor } from '@testing-library/react';
import useFetch from '../useFetch';
// Mock fetch
global.fetch = jest.fn();
const mockFetch = fetch as jest.MockedFunction<typeof fetch>;
describe('useFetch', () => {
beforeEach(() => {
mockFetch.mockClear();
});
test('returns initial state', () => {
const { result } = renderHook(() => useFetch(''));
expect(result.current.data).toBeNull();
expect(result.current.loading).toBe(true);
expect(result.current.error).toBeNull();
});
test('fetches data successfully', async () => {
const mockData = { id: 1, name: 'Test' };
mockFetch.mockResolvedValue({
ok: true,
json: async () => mockData,
} as Response);
const { result } = renderHook(() => useFetch('/api/test'));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.data).toEqual(mockData);
expect(result.current.error).toBeNull();
});
test('handles fetch error', async () => {
mockFetch.mockResolvedValue({
ok: false,
status: 404,
} as Response);
const { result } = renderHook(() => useFetch('/api/not-found'));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.data).toBeNull();
expect(result.current.error).toBe('HTTP error! status: 404');
});
test('refetches when URL changes', async () => {
const { result, rerender } = renderHook(
({ url }) => useFetch(url),
{ initialProps: { url: '/api/test1' } }
);
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
rerender({ url: '/api/test2' });
expect(result.current.loading).toBe(true);
expect(mockFetch).toHaveBeenCalledTimes(2);
});
});
集成测试
多组件协作测试
// components/ShoppingCart.js
import React, { useState } from 'react';
import ProductList from './ProductList';
import Cart from './Cart';
const ShoppingCart = () => {
const [cartItems, setCartItems] = useState([]);
const addToCart = (product) => {
setCartItems(prev => {
const existing = prev.find(item => item.id === product.id);
if (existing) {
return prev.map(item =>
item.id === product.id
? { ...item, quantity: item.quantity + 1 }
: item
);
}
return [...prev, { ...product, quantity: 1 }];
});
};
const removeFromCart = (productId) => {
setCartItems(prev => prev.filter(item => item.id !== productId));
};
const updateQuantity = (productId, quantity) => {
if (quantity <= 0) {
removeFromCart(productId);
return;
}
setCartItems(prev =>
prev.map(item =>
item.id === productId ? { ...item, quantity } : item
)
);
};
return (
<div>
<h1>购物商城</h1>
<div style={{ display: 'flex' }}>
<ProductList onAddToCart={addToCart} />
<Cart
items={cartItems}
onRemove={removeFromCart}
onUpdateQuantity={updateQuantity}
/>
</div>
</div>
);
};
export default ShoppingCart;
// components/__tests__/ShoppingCart.integration.test.js
import React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import ShoppingCart from '../ShoppingCart';
describe('ShoppingCart Integration', () => {
test('complete shopping flow', async () => {
const user = userEvent.setup();
render(<ShoppingCart />);
// 验证初始状态
expect(screen.getByText('购物商城')).toBeInTheDocument();
expect(screen.getByText('购物车空空如也')).toBeInTheDocument();
// 添加第一个商品
const firstAddButton = screen.getAllByText('添加到购物车')[0];
await user.click(firstAddButton);
// 验证购物车更新
expect(screen.queryByText('购物车空空如也')).not.toBeInTheDocument();
expect(screen.getByText(/商品1/)).toBeInTheDocument();
expect(screen.getByText(/数量: 1/)).toBeInTheDocument();
// 再次添加同一商品
await user.click(firstAddButton);
expect(screen.getByText(/数量: 2/)).toBeInTheDocument();
// 添加第二个商品
const secondAddButton = screen.getAllByText('添加到购物车')[1];
await user.click(secondAddButton);
// 验证两个商品都在购物车中
expect(screen.getByText(/商品1/)).toBeInTheDocument();
expect(screen.getByText(/商品2/)).toBeInTheDocument();
// 更新数量
const quantityInput = screen.getAllByRole('spinbutton')[0];
await user.clear(quantityInput);
await user.type(quantityInput, '5');
expect(screen.getByDisplayValue('5')).toBeInTheDocument();
// 删除商品
const removeButton = screen.getAllByText('删除')[0];
await user.click(removeButton);
expect(screen.queryByText(/商品1/)).not.toBeInTheDocument();
expect(screen.getByText(/商品2/)).toBeInTheDocument();
// 计算总价
expect(screen.getByText(/总计:/)).toBeInTheDocument();
});
test('handles edge cases', async () => {
const user = userEvent.setup();
render(<ShoppingCart />);
// 添加商品
const addButton = screen.getAllByText('添加到购物车')[0];
await user.click(addButton);
// 将数量设为0(应该删除商品)
const quantityInput = screen.getByRole('spinbutton');
await user.clear(quantityInput);
await user.type(quantityInput, '0');
expect(screen.getByText('购物车空空如也')).toBeInTheDocument();
});
});
测试最佳实践
1. 测试结构组织
// 推荐的测试文件结构
describe('ComponentName', () => {
// 设置和清理
beforeEach(() => {
// 通用设置
});
afterEach(() => {
// 清理工作
});
// 按功能分组
describe('rendering', () => {
test('renders correctly with default props', () => {});
test('renders correctly with custom props', () => {});
});
describe('user interactions', () => {
test('handles click events', () => {});
test('handles form submission', () => {});
});
describe('edge cases', () => {
test('handles empty data', () => {});
test('handles error states', () => {});
});
});
2. 测试命名约定
// 好的测试名称
test('shows error message when form is submitted with empty email', () => {});
test('updates cart total when item quantity changes', () => {});
test('disables submit button when request is pending', () => {});
// 避免的测试名称
test('test email validation', () => {});
test('cart functionality', () => {});
test('button state', () => {});
3. 断言最佳实践
// 精确断言
expect(screen.getByRole('button')).toBeEnabled();
expect(screen.getByText('Success!')).toBeInTheDocument();
// 避免过于宽泛的断言
expect(container.firstChild).toBeTruthy(); // 不够具体
// 使用语义化查询
screen.getByRole('button', { name: /submit/i });
screen.getByLabelText(/email address/i);
// 避免实现细节
expect(component.state.isLoading).toBe(false); // 测试实现细节
4. 辅助函数
// test-utils.js
import React from 'react';
import { render } from '@testing-library/react';
import { BrowserRouter } from 'react-router-dom';
import { Provider } from 'react-redux';
import { store } from '../store';
// 自定义渲染函数
export const renderWithProviders = (ui, options = {}) => {
const Wrapper = ({ children }) => (
<Provider store={store}>
<BrowserRouter>
{children}
</BrowserRouter>
</Provider>
);
return render(ui, { wrapper: Wrapper, ...options });
};
// 通用的等待函数
export const waitForLoadingToFinish = () =>
waitFor(() => expect(screen.queryByText(/loading/i)).not.toBeInTheDocument());
// 表单填写辅助函数
export const fillForm = async (user, formData) => {
for (const [field, value] of Object.entries(formData)) {
const input = screen.getByLabelText(new RegExp(field, 'i'));
await user.clear(input);
await user.type(input, value);
}
};
5. 代码覆盖率
# 运行测试并生成覆盖率报告
npm test -- --coverage
# 设置覆盖率阈值
# package.json
{
"jest": {
"collectCoverageFrom": [
"src/**/*.{js,jsx}",
"!src/index.js",
"!src/reportWebVitals.js"
],
"coverageThreshold": {
"global": {
"branches": 80,
"functions": 80,
"lines": 80,
"statements": 80
}
}
}
}
6. 性能测试
test('renders large list efficiently', () => {
const largeData = Array.from({ length: 1000 }, (_, i) => ({
id: i,
name: `Item ${i}`
}));
const startTime = performance.now();
render(<LargeList items={largeData} />);
const endTime = performance.now();
expect(endTime - startTime).toBeLessThan(100); // 100ms 以内
});
7. 快照测试
test('matches snapshot', () => {
const { container } = render(<Button variant="primary">Click me</Button>);
expect(container.firstChild).toMatchSnapshot();
});
// 内联快照
test('renders correctly', () => {
const { container } = render(<Button>Test</Button>);
expect(container.firstChild).toMatchInlineSnapshot(`
<button
class="btn btn-primary"
data-testid="custom-button"
>
Test
</button>
`);
});
总结
React测试是确保应用质量的重要环节。通过Jest和React Testing Library的组合,我们可以:
- 编写用户导向的测试:测试用户实际看到和交互的内容
- 保持测试稳定性:避免测试实现细节,专注于行为
- 提高开发效率:快速反馈,及早发现问题
- 增强重构信心:完善的测试覆盖让代码重构更安全