引言
在现代前端开发中,单元测试已经成为保证代码质量和项目稳定性的重要手段。本文将带你从零开始,深入学习如何使用Jest和Testing Library进行前端单元测试,涵盖从基础概念到高级实践的完整知识体系。
一、为什么需要单元测试?
1.1 单元测试的价值
- 提高代码质量:通过测试驱动开发(TDD),促使我们编写更模块化、可维护的代码
- 快速定位问题:当测试失败时,能够迅速定位到具体的问题代码
- 重构信心:有了完善的测试覆盖,重构代码时可以放心大胆地进行
- 文档作用:测试用例本身就是最好的代码使用文档
- 降低维护成本:虽然前期需要投入时间编写测试,但长期来看可以大幅降低维护成本
1.2 常见的测试误区
- 认为测试会拖慢开发速度
- 只测试简单的函数,忽略复杂的业务逻辑
- 过度追求测试覆盖率而忽视测试质量
- 编写脆弱的测试(测试实现细节而非行为)
二、Jest基础入门
2.1 什么是Jest?
Jest是Facebook开源的JavaScript测试框架,具有以下特点:
- 零配置:开箱即用
- 快照测试:轻松测试UI组件
- 并行测试:充分利用多核CPU
- 丰富的API:提供完整的断言库和Mock功能
- 优秀的错误提示:清晰的测试失败信息
2.2 安装与配置
# 安装Jest
npm install --save-dev jest
# 对于TypeScript项目
npm install --save-dev jest @types/jest ts-jest
# 初始化配置
npx jest --init
基础的jest.config.js配置:
module.exports = {
testEnvironment: 'node',
roots: ['<rootDir>/src'],
testMatch: ['**/__tests__/**/*.ts', '**/?(*.)+(spec|test).ts'],
transform: {
'^.+\\.ts$': 'ts-jest',
},
collectCoverageFrom: [
'src/**/*.{js,ts}',
'!src/**/*.d.ts',
],
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
},
};
2.3 编写第一个测试
// sum.js
function sum(a, b) {
return a + b;
}
module.exports = sum;
// sum.test.js
const sum = require('./sum');
describe('sum函数测试', () => {
test('1 + 2 应该等于 3', () => {
expect(sum(1, 2)).toBe(3);
});
test('负数相加', () => {
expect(sum(-1, -2)).toBe(-3);
});
test('浮点数相加', () => {
expect(sum(0.1, 0.2)).toBeCloseTo(0.3);
});
});
2.4 常用的匹配器(Matchers)
// 基本匹配
expect(value).toBe(expected); // 严格相等
expect(value).toEqual(expected); // 深度相等
expect(value).not.toBe(expected); // 取反
// 真值判断
expect(value).toBeTruthy();
expect(value).toBeFalsy();
expect(value).toBeNull();
expect(value).toBeUndefined();
expect(value).toBeDefined();
// 数字比较
expect(value).toBeGreaterThan(3);
expect(value).toBeGreaterThanOrEqual(3.5);
expect(value).toBeLessThan(5);
expect(value).toBeLessThanOrEqual(4.5);
// 字符串匹配
expect(string).toMatch(/pattern/);
expect(string).toContain('substring');
// 数组和可迭代对象
expect(array).toContain(item);
expect(array).toHaveLength(3);
// 对象匹配
expect(object).toHaveProperty('key');
expect(object).toMatchObject({ key: value });
// 异常测试
expect(() => someFunction()).toThrow();
expect(() => someFunction()).toThrow('error message');
三、异步代码测试
3.1 Promise测试
// fetchData.js
function fetchData() {
return fetch('https://api.example.com/data')
.then(response => response.json());
}
// fetchData.test.js
test('异步获取数据', () => {
return fetchData().then(data => {
expect(data).toHaveProperty('name');
});
});
// 或使用async/await
test('异步获取数据 - async/await', async () => {
const data = await fetchData();
expect(data).toHaveProperty('name');
});
3.2 测试错误处理
test('测试失败的请求', async () => {
expect.assertions(1);
try {
await fetchData();
} catch (error) {
expect(error.message).toMatch('Network error');
}
});
// 或使用rejects
test('测试失败的请求 - rejects', async () => {
await expect(fetchData()).rejects.toThrow('Network error');
});
3.3 回调函数测试
function fetchDataCallback(callback) {
setTimeout(() => {
callback({ data: 'peanut butter' });
}, 1000);
}
test('回调函数测试', (done) => {
function callback(data) {
try {
expect(data.data).toBe('peanut butter');
done();
} catch (error) {
done(error);
}
}
fetchDataCallback(callback);
});
四、Mock功能详解
4.1 Mock函数
// 创建Mock函数
const mockFn = jest.fn();
// 使用Mock函数
mockFn('arg1', 'arg2');
mockFn('arg3');
// 检查调用
expect(mockFn).toHaveBeenCalled();
expect(mockFn).toHaveBeenCalledTimes(2);
expect(mockFn).toHaveBeenCalledWith('arg1', 'arg2');
expect(mockFn).toHaveBeenLastCalledWith('arg3');
// 设置返回值
mockFn.mockReturnValue(42);
mockFn.mockReturnValueOnce(1).mockReturnValueOnce(2);
// 设置实现
mockFn.mockImplementation(arg => arg * 2);
4.2 Mock模块
// users.js
import axios from 'axios';
export async function getUsers() {
const response = await axios.get('/api/users');
return response.data;
}
// users.test.js
import axios from 'axios';
import { getUsers } from './users';
jest.mock('axios');
test('获取用户列表', async () => {
const users = [{ id: 1, name: 'Alice' }];
axios.get.mockResolvedValue({ data: users });
const result = await getUsers();
expect(result).toEqual(users);
expect(axios.get).toHaveBeenCalledWith('/api/users');
});
4.3 部分Mock
// 只Mock模块的某些部分
jest.mock('./module', () => ({
...jest.requireActual('./module'),
functionToMock: jest.fn(),
}));
4.4 Mock定时器
// timer.js
export function delayedGreeting(name, delay) {
setTimeout(() => {
console.log(`Hello, ${name}!`);
}, delay);
}
// timer.test.js
jest.useFakeTimers();
test('延迟问候', () => {
const spy = jest.spyOn(console, 'log');
delayedGreeting('Alice', 1000);
// 快进1秒
jest.advanceTimersByTime(1000);
expect(spy).toHaveBeenCalledWith('Hello, Alice!');
spy.mockRestore();
});
五、React Testing Library实战
5.1 为什么选择Testing Library?
Testing Library遵循的核心原则是"测试你的软件使用方式",它鼓励编写更贴近用户行为的测试,而不是测试实现细节。
核心理念:
- 测试应该关注用户如何使用应用
- 避免测试组件内部实现
- 通过可访问性查询元素,提高代码质量
5.2 安装配置
npm install --save-dev @testing-library/react @testing-library/jest-dom @testing-library/user-event
// setupTests.js
import '@testing-library/jest-dom';
5.3 基础组件测试
// Button.jsx
export function Button({ onClick, children, disabled = false }) {
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 onClick={handleClick}>点击</Button>);
await user.click(screen.getByText('点击'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
test('禁用状态不可点击', async () => {
const user = userEvent.setup();
const handleClick = jest.fn();
render(<Button onClick={handleClick} disabled>点击</Button>);
await user.click(screen.getByText('点击'));
expect(handleClick).not.toHaveBeenCalled();
});
});
5.4 查询元素的方法
// 按角色查询(推荐)
screen.getByRole('button', { name: /submit/i });
screen.getByRole('textbox', { name: /username/i });
// 按标签文本查询
screen.getByLabelText('用户名');
// 按占位符查询
screen.getByPlaceholderText('请输入用户名');
// 按文本内容查询
screen.getByText('登录');
screen.getByText(/submit/i); // 正则表达式
// 按测试ID查询(最后的选择)
screen.getByTestId('submit-button');
// 查询变体
// getBy*: 找不到抛出错误
// queryBy*: 找不到返回null
// findBy*: 异步查询,返回Promise
// 多个元素
screen.getAllByRole('listitem');
screen.queryAllByRole('listitem');
screen.findAllByRole('listitem');
5.5 测试表单交互
// LoginForm.jsx
import { useState } from 'react';
export function LoginForm({ onSubmit }) {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const handleSubmit = (e) => {
e.preventDefault();
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>
);
}
// LoginForm.test.jsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { LoginForm } from './LoginForm';
describe('LoginForm组件', () => {
test('成功提交表单', async () => {
const user = userEvent.setup();
const handleSubmit = jest.fn();
render(<LoginForm onSubmit={handleSubmit} />);
// 填写表单
await user.type(screen.getByLabelText('用户名'), 'alice');
await user.type(screen.getByLabelText('密码'), 'password123');
// 提交
await user.click(screen.getByRole('button', { name: '登录' }));
// 验证
expect(handleSubmit).toHaveBeenCalledWith({
username: 'alice',
password: 'password123',
});
});
test('空表单提交显示错误', async () => {
const user = userEvent.setup();
const handleSubmit = jest.fn();
render(<LoginForm onSubmit={handleSubmit} />);
await user.click(screen.getByRole('button', { name: '登录' }));
expect(screen.getByRole('alert')).toHaveTextContent('用户名和密码不能为空');
expect(handleSubmit).not.toHaveBeenCalled();
});
});
5.6 测试异步组件
// UserList.jsx
import { useState, useEffect } from 'react';
export function UserList() {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetch('/api/users')
.then(res => res.json())
.then(data => {
setUsers(data);
setLoading(false);
})
.catch(err => {
setError(err.message);
setLoading(false);
});
}, []);
if (loading) return <div>加载中...</div>;
if (error) return <div>错误:{error}</div>;
return (
<ul>
{users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
// UserList.test.jsx
import { render, screen, waitFor } from '@testing-library/react';
import { UserList } from './UserList';
// Mock fetch
global.fetch = jest.fn();
describe('UserList组件', () => {
beforeEach(() => {
fetch.mockClear();
});
test('成功加载用户列表', async () => {
const mockUsers = [
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' },
];
fetch.mockResolvedValueOnce({
json: async () => mockUsers,
});
render(<UserList />);
// 检查加载状态
expect(screen.getByText('加载中...')).toBeInTheDocument();
// 等待数据加载
await waitFor(() => {
expect(screen.getByText('Alice')).toBeInTheDocument();
});
expect(screen.getByText('Bob')).toBeInTheDocument();
expect(screen.queryByText('加载中...')).not.toBeInTheDocument();
});
test('加载失败显示错误', async () => {
fetch.mockRejectedValueOnce(new Error('Network error'));
render(<UserList />);
await waitFor(() => {
expect(screen.getByText(/错误:Network error/)).toBeInTheDocument();
});
});
});
5.7 测试自定义Hooks
// useCounter.js
import { useState, useCallback } from 'react';
export function useCounter(initialValue = 0) {
const [count, setCount] = useState(initialValue);
const increment = useCallback(() => {
setCount(c => c + 1);
}, []);
const decrement = useCallback(() => {
setCount(c => c - 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);
});
});
六、高级测试技巧
6.1 测试Context
// ThemeContext.jsx
import { createContext, useContext, useState } from 'react';
const ThemeContext = createContext();
export function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light');
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
return useContext(ThemeContext);
}
// ThemeButton.jsx
export function ThemeButton() {
const { theme, setTheme } = useTheme();
return (
<button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
当前主题:{theme}
</button>
);
}
// ThemeButton.test.jsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ThemeProvider } from './ThemeContext';
import { ThemeButton } from './ThemeButton';
describe('ThemeButton组件', () => {
test('切换主题', async () => {
const user = userEvent.setup();
render(
<ThemeProvider>
<ThemeButton />
</ThemeProvider>
);
expect(screen.getByText('当前主题:light')).toBeInTheDocument();
await user.click(screen.getByRole('button'));
expect(screen.getByText('当前主题:dark')).toBeInTheDocument();
});
});
6.2 快照测试
// Card.test.jsx
import { render } from '@testing-library/react';
import { Card } from './Card';
test('Card组件快照', () => {
const { container } = render(
<Card title="标题" content="内容" />
);
expect(container.firstChild).toMatchSnapshot();
});
// 更新快照:jest -u
6.3 测试错误边界
// ErrorBoundary.jsx
import { Component } from 'react';
export class ErrorBoundary extends Component {
state = { hasError: false, error: null };
static getDerivedStateFromError(error) {
return { hasError: true, error };
}
render() {
if (this.state.hasError) {
return <div role="alert">出错了:{this.state.error.message}</div>;
}
return this.props.children;
}
}
// ErrorBoundary.test.jsx
import { render, screen } from '@testing-library/react';
import { ErrorBoundary } from './ErrorBoundary';
function ProblematicComponent() {
throw new Error('测试错误');
}
test('捕获错误并显示', () => {
// 抑制错误输出
const spy = jest.spyOn(console, 'error').mockImplementation(() => {});
render(
<ErrorBoundary>
<ProblematicComponent />
</ErrorBoundary>
);
expect(screen.getByRole('alert')).toHaveTextContent('出错了:测试错误');
spy.mockRestore();
});
6.4 性能测试
import { render } from '@testing-library/react';
import { performance } from 'perf_hooks';
test('组件渲染性能', () => {
const start = performance.now();
render(<ExpensiveComponent data={largeDataset} />);
const end = performance.now();
const renderTime = end - start;
expect(renderTime).toBeLessThan(100); // 渲染时间少于100ms
});
七、测试最佳实践
7.1 AAA模式
遵循Arrange(准备)、Act(执行)、Assert(断言)模式:
test('用户登录流程', async () => {
// Arrange - 准备测试数据和环境
const user = userEvent.setup();
const mockLogin = jest.fn();
render(<LoginForm onLogin={mockLogin} />);
// Act - 执行操作
await user.type(screen.getByLabelText('用户名'), 'alice');
await user.type(screen.getByLabelText('密码'), 'pass123');
await user.click(screen.getByRole('button', { name: '登录' }));
// Assert - 验证结果
expect(mockLogin).toHaveBeenCalledWith({
username: 'alice',
password: 'pass123',
});
});
7.2 测试命名规范
// 好的命名
test('空用户名提交时显示错误消息', () => {});
test('点击删除按钮后移除项目', () => {});
// 避免的命名
test('测试1', () => {});
test('应该工作', () => {});
7.3 避免测试实现细节
// ❌ 不好的测试 - 测试实现细节
test('状态更新', () => {
const { result } = renderHook(() => useState(0));
expect(result.current[0]).toBe(0);
});
// ✅ 好的测试 - 测试用户行为
test('点击按钮增加计数', async () => {
render(<Counter />);
await userEvent.click(screen.getByRole('button', { name: '+' }));
expect(screen.getByText('计数:1')).toBeInTheDocument();
});
7.4 使用测试辅助函数
// testUtils.js
import { render } from '@testing-library/react';
import { ThemeProvider } from './ThemeContext';
export function renderWithTheme(ui, options) {
return render(
<ThemeProvider>
{ui}
</ThemeProvider>,
options
);
}
// 使用
test('带主题的组件', () => {
renderWithTheme(<MyComponent />);
});
7.5 合理使用beforeEach和afterEach
describe('数据库操作', () => {
let db;
beforeEach(() => {
db = createTestDatabase();
});
afterEach(() => {
db.close();
});
test('插入数据', () => {
db.insert({ name: 'Alice' });
expect(db.findByName('Alice')).toBeDefined();
});
});
7.6 测试覆盖率目标
// jest.config.js
module.exports = {
collectCoverageFrom: [
'src/**/*.{js,jsx,ts,tsx}',
'!src/**/*.d.ts',
'!src/index.tsx',
'!src/reportWebVitals.ts',
],
coverageThreshold: {
global: {
branches: 70,
functions: 70,
lines: 70,
statements: 70,
},
},
};
覆盖率建议:
- 核心业务逻辑:90%+
- UI组件:80%+
- 工具函数:100%
- 整体项目:70%+
但要记住:高覆盖率≠高质量测试,测试质量比数字更重要。
八、CI/CD集成
8.1 GitHub Actions示例
# .github/workflows/test.yml
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '18'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test -- --coverage
- name: Upload coverage
uses: codecov/codecov-action@v3
with:
files: ./coverage/lcov.info
8.2 package.json脚本
{
"scripts": {
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage",
"test:ci": "jest --ci --coverage --maxWorkers=2"
}
}
九、常见问题与解决方案
9.1 测试运行缓慢
问题:测试套件执行时间过长
解决方案:
// 使用并行运行
jest --maxWorkers=4
// 只运行改变的测试
jest --onlyChanged
// 关闭不必要的coverage
jest --no-coverage
9.2 Mock不生效
问题:Mock的模块没有被正确替换
解决方案:
// 确保jest.mock在最顶部
jest.mock('./module');
// 对于ES模块,使用正确的语法
jest.mock('./module', () => ({
__esModule: true,
default: jest.fn(),
}));
9.3 异步测试超时
问题:异步测试超过默认5秒超时
解决方案:
// 方法1:增加单个测试超时时间
test('长时间运行的测试', async () => {
// ...
}, 10000); // 10秒
// 方法2:设置全局超时
jest.setTimeout(10000);
9.4 清理测试副作用
问题:测试之间相互影响
解决方案:
afterEach(() => {
// 清理所有Mock
jest.clearAllMocks();
// 重置所有Mock
jest.resetAllMocks();
// 清理DOM
cleanup();
});