React Testing完全指南:Jest、React Testing Library实战
在现代前端开发中,测试是确保代码质量和应用稳定性的关键环节。本文将深入探讨如何使用Jest和React Testing Library构建完整的React应用测试体系。
目录
- 测试基础概念
- Jest测试框架
- React Testing Library介绍
- 环境搭建
- 基础组件测试
- 用户交互测试
- 异步操作测试
- Mock和Spy
- 自定义Hook测试
- 集成测试
- 测试最佳实践
测试基础概念
测试类型
单元测试(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 (<buttononClick={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><inputid="username"type="text"value={username}onChange={(e) => setUsername(e.target.value)}/></div><div><label htmlFor="password">密码:</label><inputid="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><inputvalue={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}><inputtype="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(() => {})); // 永不resolverender(<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 (<inputtype="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} /><Cartitems={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(`<buttonclass="btn btn-primary"data-testid="custom-button">Test</button>`);
});
总结
React测试是确保应用质量的重要环节。通过Jest和React Testing Library的组合,我们可以:
- 编写用户导向的测试:测试用户实际看到和交互的内容
- 保持测试稳定性:避免测试实现细节,专注于行为
- 提高开发效率:快速反馈,及早发现问题
- 增强重构信心:完善的测试覆盖让代码重构更安全