09. 单元测试 - Jest
概述
Jest 是 Facebook 开源的 JavaScript 测试框架,广泛用于 React 应用的单元测试。它提供测试运行器、断言库、Mock 功能、代码覆盖率等一站式解决方案。
| 维度 | 内容 |
|---|---|
| What | 使用 Jest 测试 JavaScript/React 代码的单元 |
| Why | 确保代码正确性,提高代码质量 |
| When | 测试函数、工具类、自定义 Hooks |
| Where | __tests__目录或.test.js文件 |
| Who | 需要保证代码质量的开发者 |
| How | test('adds 1 + 2 to equal 3', () => { expect(add(1, 2)).toBe(3) }) |
1. Jest 基础
1.1 安装配置
npminstall--save-dev jest @types/jest// package.json{"scripts":{"test":"jest","test:watch":"jest --watch","test:coverage":"jest --coverage"}}1.2 基本测试结构
// math.js export function add(a, b) { return a + b; } export function subtract(a, b) { return a - b; } export function multiply(a, b) { return a * b; } export function divide(a, b) { if (b === 0) throw new Error('除数不能为0'); return a / b; } // math.test.js import { add, subtract, multiply, divide } from './math'; describe('数学函数', () => { test('add 函数: 1 + 2 = 3', () => { expect(add(1, 2)).toBe(3); }); test('subtract 函数: 5 - 3 = 2', () => { expect(subtract(5, 3)).toBe(2); }); test('multiply 函数: 2 * 3 = 6', () => { expect(multiply(2, 3)).toBe(6); }); test('divide 函数: 6 / 2 = 3', () => { expect(divide(6, 2)).toBe(3); }); test('divide 函数: 除以0抛出错误', () => { expect(() => divide(6, 0)).toThrow('除数不能为0'); }); });2. 常用断言
2.1 相等性断言
test('相等性断言', () => { // 基本相等 expect(1 + 1).toBe(2); // 对象相等(深度比较) expect({ name: '张三' }).toEqual({ name: '张三' }); // 不相等 expect(1 + 1).not.toBe(3); // 严格相等(SameValue) expect(NaN).toBeNaN(); expect(+0).not.toBe(-0); });2.2 真值断言
test('真值断言', () => { // 真值 expect(true).toBeTruthy(); expect(1).toBeTruthy(); expect('hello').toBeTruthy(); // 假值 expect(false).toBeFalsy(); expect(0).toBeFalsy(); expect('').toBeFalsy(); expect(null).toBeFalsy(); expect(undefined).toBeFalsy(); // 特定值 expect(null).toBeNull(); expect(undefined).toBeUndefined(); expect('hello').toBeDefined(); });2.3 数值断言
test('数值断言', () => { expect(5).toBeGreaterThan(3); // > expect(5).toBeGreaterThanOrEqual(5); // >= expect(3).toBeLessThan(5); // < expect(3).toBeLessThanOrEqual(3); // <= expect(0.1 + 0.2).toBeCloseTo(0.3); // 浮点数 });2.4 数组和对象断言
test('数组和对象断言', () => { // 数组 expect([1, 2, 3]).toContain(2); expect([1, 2, 3]).toHaveLength(3); // 对象 expect({ name: '张三', age: 18 }).toHaveProperty('name'); expect({ name: '张三', age: 18 }).toHaveProperty('age', 18); // 字符串 expect('hello world').toContain('world'); expect('hello world').toMatch(/world/); });3. 异步测试
3.1 Promise 测试
// api.js export function fetchUser(id) { return fetch(`/api/users/${id}`).then(res => res.json()); } // api.test.js import { fetchUser } from './api'; // 使用 async/await test('fetchUser 返回用户数据', async () => { const user = await fetchUser(1); expect(user).toHaveProperty('id', 1); }); // 使用 resolves test('fetchUser 返回用户数据 (resolves)', () => { return expect(fetchUser(1)).resolves.toHaveProperty('id', 1); }); // 测试错误 test('fetchUser 失败时抛出错误', async () => { await expect(fetchUser(999)).rejects.toThrow(); });3.2 回调测试
// callback.js export function fetchData(callback) { setTimeout(() => { callback({ data: 'success' }); }, 100); } // callback.test.js import { fetchData } from './callback'; test('fetchData 调用回调', (done) => { fetchData((data) => { expect(data).toEqual({ data: 'success' }); done(); // 等待回调完成 }); });4. Mock 功能
4.1 函数 Mock
// 创建 Mock 函数 test('Mock 函数', () => { const mockFn = jest.fn(); mockFn('hello', 'world'); mockFn('again'); // 调用断言 expect(mockFn).toHaveBeenCalled(); expect(mockFn).toHaveBeenCalledTimes(2); expect(mockFn).toHaveBeenCalledWith('hello', 'world'); expect(mockFn).toHaveBeenLastCalledWith('again'); }); // Mock 返回值 const mockFn = jest.fn() .mockReturnValueOnce('first') .mockReturnValueOnce('second') .mockReturnValue('default'); console.log(mockFn()); // 'first' console.log(mockFn()); // 'second' console.log(mockFn()); // 'default' // Mock 实现 const mockFn = jest.fn((x) => x * 2); console.log(mockFn(5)); // 104.2 模块 Mock
// __mocks__/axios.js export default { get: jest.fn(() => Promise.resolve({ data: { users: [] } })), post: jest.fn(() => Promise.resolve({ data: { success: true } })), }; // userService.test.js import axios from 'axios'; import { getUsers, createUser } from './userService'; jest.mock('axios'); test('getUsers 返回用户列表', async () => { const mockUsers = [{ id: 1, name: '张三' }]; axios.get.mockResolvedValue({ data: mockUsers }); const users = await getUsers(); expect(users).toEqual(mockUsers); expect(axios.get).toHaveBeenCalledWith('/api/users'); }); test('createUser 创建用户', async () => { const newUser = { name: '李四' }; axios.post.mockResolvedValue({ data: { id: 2, ...newUser } }); const user = await createUser(newUser); expect(user).toHaveProperty('id', 2); });4.3 时间 Mock
// 使用假计时器 jest.useFakeTimers(); test('延迟执行', () => { const callback = jest.fn(); setTimeout(callback, 1000); jest.advanceTimersByTime(500); expect(callback).not.toHaveBeenCalled(); jest.advanceTimersByTime(500); expect(callback).toHaveBeenCalled(); });5. 测试 React 组件
5.1 工具函数测试
// utils/format.js export function formatDate(date) { return new Date(date).toLocaleDateString('zh-CN'); } export function formatPrice(price) { return `¥${price.toFixed(2)}`; } export function validateEmail(email) { return /\S+@\S+\.\S+/.test(email); } // utils/format.test.js import { formatDate, formatPrice, validateEmail } from './format'; describe('格式化函数', () => { test('formatDate 格式化日期', () => { expect(formatDate('2024-01-15')).toBe('2024/1/15'); }); test('formatPrice 格式化价格', () => { expect(formatPrice(19.9)).toBe('¥19.90'); expect(formatPrice(100)).toBe('¥100.00'); }); test('validateEmail 验证邮箱', () => { expect(validateEmail('test@example.com')).toBe(true); expect(validateEmail('invalid')).toBe(false); }); });5.2 自定义 Hook 测试
// 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 }; } // hooks/useCounter.test.js import { renderHook, act } from '@testing-library/react'; import { useCounter } from './useCounter'; describe('useCounter', () => { 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. 完整示例
// services/todoService.js const API_URL = 'https://api.example.com/todos'; export async function fetchTodos() { const response = await fetch(API_URL); if (!response.ok) throw new Error('获取失败'); return response.json(); } export async function createTodo(todo) { const response = await fetch(API_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(todo), }); if (!response.ok) throw new Error('创建失败'); return response.json(); } export async function updateTodo(id, updates) { const response = await fetch(`${API_URL}/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(updates), }); if (!response.ok) throw new Error('更新失败'); return response.json(); } export async function deleteTodo(id) { const response = await fetch(`${API_URL}/${id}`, { method: 'DELETE' }); if (!response.ok) throw new Error('删除失败'); return true; } // services/todoService.test.js import { fetchTodos, createTodo, updateTodo, deleteTodo } from './todoService'; global.fetch = jest.fn(); describe('Todo Service', () => { beforeEach(() => { fetch.mockClear(); }); describe('fetchTodos', () => { test('成功获取 todos', async () => { const mockTodos = [{ id: 1, title: '测试', completed: false }]; fetch.mockResolvedValue({ ok: true, json: async () => mockTodos, }); const todos = await fetchTodos(); expect(todos).toEqual(mockTodos); expect(fetch).toHaveBeenCalledWith('https://api.example.com/todos'); }); test('获取失败时抛出错误', async () => { fetch.mockResolvedValue({ ok: false }); await expect(fetchTodos()).rejects.toThrow('获取失败'); }); }); describe('createTodo', () => { test('成功创建 todo', async () => { const newTodo = { title: '新任务', completed: false }; const createdTodo = { id: 1, ...newTodo }; fetch.mockResolvedValue({ ok: true, json: async () => createdTodo, }); const result = await createTodo(newTodo); expect(result).toEqual(createdTodo); expect(fetch).toHaveBeenCalledWith( 'https://api.example.com/todos', expect.objectContaining({ method: 'POST' }) ); }); }); describe('updateTodo', () => { test('成功更新 todo', async () => { const updates = { completed: true }; const updatedTodo = { id: 1, title: '测试', completed: true }; fetch.mockResolvedValue({ ok: true, json: async () => updatedTodo, }); const result = await updateTodo(1, updates); expect(result).toEqual(updatedTodo); expect(fetch).toHaveBeenCalledWith( 'https://api.example.com/todos/1', expect.objectContaining({ method: 'PATCH' }) ); }); }); describe('deleteTodo', () => { test('成功删除 todo', async () => { fetch.mockResolvedValue({ ok: true }); const result = await deleteTodo(1); expect(result).toBe(true); expect(fetch).toHaveBeenCalledWith( 'https://api.example.com/todos/1', expect.objectContaining({ method: 'DELETE' }) ); }); test('删除失败时抛出错误', async () => { fetch.mockResolvedValue({ ok: false }); await expect(deleteTodo(1)).rejects.toThrow('删除失败'); }); }); });7. 总结
核心要点
| 要点 | 说明 |
|---|---|
| 测试结构 | describe + test/ it |
| 断言 | expect + matcher |
| 异步测试 | async/await、resolves、rejects |
| Mock | jest.fn()、jest.mock() |
记忆口诀
Jest 测试框架强,describe 分组 test 写
expect 断言各种值,异步测试要等待
Mock 模拟外部依赖,代码覆盖率不用愁
8. 相关资源
- Jest 官方文档
- React Testing Library