引言
在 React 开发中,测试是保证代码质量的重要环节。良好的测试体系能够帮助我们:
- 快速发现回归问题
- 安全重构代码
- 文档化组件行为
- 提升开发信心
本文将介绍 React 测试的核心工具 Jest 和 Testing Library,并通过实战示例带你入门。
测试工具简介
Jest
Jest 是 Facebook 出品的 JavaScript 测试框架,具有以下特点:
- 零配置:开箱即用
- 快照测试:自动检测 UI 变化
- Mock 功能:轻松模拟依赖
- 并行 执行:提升测试速度
Testing Library
Testing Library 是一套测试工具集合,核心理念是:
测试应该像用户一样使用你的应用
它鼓励我们测试组件的行为而非实现细节,主要包含:
@testing-library/react:React 组件测试@testing-library/user-event:模拟用户交互@testing-library/jest-dom:DOM 匹配器
环境搭建
首先安装必要的依赖:
npm install -D jest @testing-library/react @testing-library/jest-dom @testing-library/user-event
创建 jest.setup.js 配置文件:
import '@testing-library/jest-dom';
// 全局匹配器
expect.extend({
// 自定义匹配器
});
在 package.json 中添加测试脚本:
{
"scripts": {
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage"
}
}
基础测试示例
测试简单组件
假设我们有一个简单的 Button 组件:
// Button.jsx
export default function Button({ children, onClick, disabled }) {
return (
<button onClick={onClick} 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('渲染按钮文本', () => {
render(<Button>点击我</Button>);
expect(screen.getByText('点击我')).toBeInTheDocument();
});
test('禁用状态下不可点击', async () => {
const user = userEvent.setup();
const handleClick = jest.fn();
render(<Button disabled onClick={handleClick}>禁用按钮</Button>);
const button = screen.getByText('禁用按钮');
expect(button).toBeDisabled();
await user.click(button);
expect(handleClick).not.toHaveBeenCalled();
});
test('点击时触发回调', async () => {
const user = userEvent.setup();
const handleClick = jest.fn();
render(<Button onClick={handleClick}>点击</Button>);
await user.click(screen.getByText('点击'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
});
测试异步操作
实际项目中,组件经常涉及异步操作。Testing Library 提供了 findBy 和 waitFor 来处理异步场景。
示例:数据获取组件
// UserProfile.jsx
import { useState, useEffect } from 'react';
export default function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => {
setUser(data);
setLoading(false);
});
}, [userId]);
if (loading) return <div>加载中...</div>;
if (!user) return <div>未找到用户</div>;
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
</div>
);
}
异步测试写法
// UserProfile.test.jsx
import { render, screen, waitFor } from '@testing-library/react';
import UserProfile from './UserProfile';
// Mock fetch
global.fetch = jest.fn();
describe('UserProfile 组件', () => {
beforeEach(() => {
jest.clearAllMocks();
});
test('显示加载状态', () => {
fetch.mockResolvedValueOnce({
json: async () => ({ name: '张三', email: 'zhang@example.com' })
});
render(<UserProfile userId="123" />);
expect(screen.getByText('加载中...')).toBeInTheDocument();
});
test('成功加载后显示用户信息', async () => {
fetch.mockResolvedValueOnce({
json: async () => ({ name: '张三', email: 'zhang@example.com' })
});
render(<UserProfile userId="123" />);
// 使用 findBy 等待元素出现
const nameElement = await screen.findByText('张三');
expect(nameElement).toBeInTheDocument();
expect(screen.getByText('zhang@example.com')).toBeInTheDocument();
});
test('加载失败显示错误信息', async () => {
fetch.mockRejectedValueOnce(new Error('Network error'));
render(<UserProfile userId="123" />);
await waitFor(() => {
expect(screen.queryByText('加载中...')).not.toBeInTheDocument();
});
});
});
测试自定义 Hooks
自定义 Hooks 的测试需要特殊处理。我们可以创建一个测试组件来包裹 Hook。
// useCounter.js
import { useState, useCallback } from 'react';
export function useCounter(initialValue = 0) {
const [count, setCount] = useState(initialValue);
const increment = useCallback(() => {
setCount(prev => prev + 1);
}, []);
const decrement = useCallback(() => {
setCount(prev => prev - 1);
}, []);
const reset = useCallback(() => {
setCount(initialValue);
}, [initialValue]);
return { count, increment, decrement, reset };
}
// useCounter.test.js
import { renderHook, act } from '@testing-library/react';
import { useCounter } from './useCounter';
describe('useCounter Hook', () => {
test('初始值为 0', () => {
const { result } = renderHook(() => useCounter());
expect(result.current.count).toBe(0);
});
test('使用自定义初始值', () => {
const { result } = renderHook(() => useCounter(10));
expect(result.current.count).toBe(10);
});
test('increment 增加计数', () => {
const { result } = renderHook(() => useCounter());
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
test('decrement 减少计数', () => {
const { result } = renderHook(() => useCounter(5));
act(() => {
result.current.decrement();
});
expect(result.current.count).toBe(4);
});
test('reset 重置为初始值', () => {
const { result } = renderHook(() => useCounter(10));
act(() => {
result.current.increment();
result.current.increment();
result.current.reset();
});
expect(result.current.count).toBe(10);
});
});
常用匹配器
Testing Library 配合 jest-dom 提供丰富的匹配器:
// 存在性检查
toBeInTheDocument();
not.toBeInTheDocument();
// 可见性检查
toBeVisible();
not.toBeVisible();
// 状态检查
toBeDisabled();
toBeEnabled();
toBeChecked();
toHaveFocus();
// 内容检查
toHaveTextContent('文本');
toHaveAttribute('href', '/link');
toHaveClass('active');
toHaveStyle({ color: 'red' });
// 数量检查
toHaveLength(3);
最佳实践
1. 测试行为而非实现
// ❌ 不推荐:测试实现细节
expect(component.props.className).toBe('btn-primary');
// ✅ 推荐:测试用户可见的行为
expect(screen.getByRole('button')).toHaveClass('btn-primary');
2. 使用语义化查询
// 优先级顺序
getByRole() // 按 ARIA 角色
getByLabelText() // 按标签文本
getByPlaceholderText() // 按占位符
getByText() // 按文本内容
getByTestId() // 最后选择(添加 data-testid)
3. 保持测试独立
// 每个测试应该独立运行
beforeEach(() => {
jest.clearAllMocks();
cleanup();
});
4. 测试边界情况
- 空状态
- 加载状态
- 错误状态
- 极端输入值
总结
React 测试是保证应用质量的关键环节。掌握 Jest 和 Testing Library 的核心用法,遵循"测试行为而非实现"的原则,能够帮助我们构建更可靠的应用。
核心要点回顾:
- 使用 Testing Library 模拟用户行为
- 优先使用语义化查询(getByRole 等)
- 异步操作使用 findBy 或 waitFor
- 测试 Hooks 使用 renderHook
- 保持测试独立、可维护
开始为你的 React 项目添加测试吧,这将是值得的投资!