前端单元测试完全指南
单元测试是测试金字塔的基础,专注于测试单个函数、组件或模块的功能。通过系统化的单元测试实践,可以快速验证代码逻辑、提供即时反馈、确保代码质量,并为重构提供安全保障。
核心价值
单元测试 = 快速反馈 + 代码质量 + 重构安全 + 文档价值
- 🚀 快速反馈:毫秒级测试执行,即时发现问题
- 🎯 代码质量:强制思考边界条件和异常情况
- 🛡️ 重构安全:为代码重构提供安全网
- 📚 文档价值:测试用例作为代码使用说明
- 🔧 开发效率:减少手动测试时间,提高开发速度
- 📊 覆盖率监控:量化测试完整性,识别测试盲点
1. 测试环境搭建与配置
1.1 现代测试工具链
构建高效的测试环境需要选择合适的工具组合,每个工具都有其特定的职责和优势。
测试工具对比
| 工具 | 类型 | 优势 | 劣势 | 适用场景 |
|---|---|---|---|---|
| Jest | 测试框架 | 零配置、功能全面、生态丰富 | 较重、启动慢 | React项目、大型应用 |
| Vitest | 测试框架 | 快速、现代、Vite集成 | 生态较新 | Vite项目、现代应用 |
| React Testing Library | 测试工具 | 用户行为导向、最佳实践 | 学习曲线 | React组件测试 |
| MSW | Mock工具 | 真实网络模拟、开发友好 | 配置复杂 | API测试、集成测试 |
- Jest配置
- Vitest配置
- NPM脚本
Jest完整配置
jest.config.js - 完整配置示例
javascript
1module.exports = {2 // 基础配置3 preset: 'ts-jest',4 testEnvironment: 'jsdom',5 6 // 文件路径配置7 roots: ['<rootDir>/src'],8 testMatch: [9 '<rootDir>/src/**/__tests__/**/*.{js,jsx,ts,tsx}',10 '<rootDir>/src/**/*.(test|spec).{js,jsx,ts,tsx}'11 ],12 13 // 模块解析14 moduleNameMapping: {15 '^@/(.*)$': '<rootDir>/src/$1',16 '^@components/(.*)$': '<rootDir>/src/components/$1',17 '^@utils/(.*)$': '<rootDir>/src/utils/$1',18 '\\.(css|less|scss|sass)$': 'identity-obj-proxy',19 '\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2)$': 'jest-transform-stub'20 },21 22 // 设置文件23 setupFilesAfterEnv: ['<rootDir>/src/setupTests.ts'],24 25 // 转换配置26 transform: {27 '^.+\\.(js|jsx|ts|tsx)$': 'ts-jest',28 '^.+\\.css$': 'jest-transform-css'29 },30 31 // 忽略转换的模块32 transformIgnorePatterns: [33 'node_modules/(?!(axios|@testing-library)/)'34 ],35 36 // 覆盖率配置37 collectCoverageFrom: [38 'src/**/*.{js,jsx,ts,tsx}',39 '!src/**/*.d.ts',40 '!src/index.tsx',41 '!src/serviceWorker.ts',42 '!src/reportWebVitals.ts',43 '!src/**/*.stories.{js,jsx,ts,tsx}',44 '!src/**/*.test.{js,jsx,ts,tsx}'45 ],46 47 coverageThreshold: {48 global: {49 branches: 80,50 functions: 80,51 lines: 80,52 statements: 8053 },54 './src/components/': {55 branches: 90,56 functions: 90,57 lines: 90,58 statements: 9059 },60 './src/utils/': {61 branches: 95,62 functions: 95,63 lines: 95,64 statements: 9565 }66 },67 68 coverageReporters: ['text', 'lcov', 'html', 'json-summary'],69 70 // 性能配置71 maxWorkers: '50%',72 cache: true,73 cacheDirectory: '<rootDir>/.jest-cache',74 75 // 全局变量76 globals: {77 'ts-jest': {78 tsconfig: 'tsconfig.json',79 isolatedModules: true80 }81 },82 83 // 测试超时84 testTimeout: 10000,85 86 // 清理配置87 clearMocks: true,88 restoreMocks: true,89 90 // 详细输出91 verbose: true,92 93 // 错误处理94 errorOnDeprecated: true95};setupTests.ts - 测试环境设置
typescript
1import '@testing-library/jest-dom';2import 'jest-canvas-mock';34// 全局Mock5global.fetch = jest.fn();6global.ResizeObserver = jest.fn().mockImplementation(() => ({7 observe: jest.fn(),8 unobserve: jest.fn(),9 disconnect: jest.fn(),10}));1112global.IntersectionObserver = jest.fn().mockImplementation(() => ({13 observe: jest.fn(),14 unobserve: jest.fn(),15 disconnect: jest.fn(),16}));1718// Mock localStorage19const localStorageMock = {20 getItem: jest.fn(),21 setItem: jest.fn(),22 removeItem: jest.fn(),23 clear: jest.fn(),24 length: 0,25 key: jest.fn(),26};27global.localStorage = localStorageMock;2829// Mock sessionStorage30global.sessionStorage = localStorageMock;3132// Mock window.matchMedia33Object.defineProperty(window, 'matchMedia', {34 writable: true,35 value: jest.fn().mockImplementation(query => ({36 matches: false,37 media: query,38 onchange: null,39 addListener: jest.fn(),40 removeListener: jest.fn(),41 addEventListener: jest.fn(),42 removeEventListener: jest.fn(),43 dispatchEvent: jest.fn(),44 })),45});4647// Mock console methods for cleaner test output48const originalError = console.error;49beforeAll(() => {50 console.error = (...args: any[]) => {51 if (52 typeof args[0] === 'string' &&53 args[0].includes('Warning: ReactDOM.render is no longer supported')54 ) {55 return;56 }57 originalError.call(console, ...args);58 };59});6061afterAll(() => {62 console.error = originalError;63});6465// 全局测试工具66global.testUtils = {67 // 等待异步操作完成68 waitForAsync: (ms = 0) => new Promise(resolve => setTimeout(resolve, ms)),69 70 // 创建模拟事件71 createMockEvent: (type: string, properties = {}) => ({72 type,73 preventDefault: jest.fn(),74 stopPropagation: jest.fn(),75 target: { value: '' },76 ...properties77 }),78 79 // 模拟用户输入80 mockUserInput: (element: HTMLElement, value: string) => {81 const event = new Event('input', { bubbles: true });82 Object.defineProperty(event, 'target', {83 writable: false,84 value: { value }85 });86 element.dispatchEvent(event);87 }88};8990// 清理函数91afterEach(() => {92 jest.clearAllMocks();93 localStorage.clear();94 sessionStorage.clear();95});Vitest现代化配置
vitest.config.ts - Vitest配置
typescript
1import { defineConfig } from 'vitest/config';2import react from '@vitejs/plugin-react';3import path from 'path';45export default defineConfig({6 plugins: [react()],7 8 test: {9 // 测试环境10 environment: 'jsdom',11 12 // 全局设置13 globals: true,14 15 // 设置文件16 setupFiles: ['./src/setupTests.ts'],17 18 // 包含的测试文件19 include: [20 'src/**/*.{test,spec}.{js,jsx,ts,tsx}',21 'src/**/__tests__/**/*.{js,jsx,ts,tsx}'22 ],23 24 // 排除的文件25 exclude: [26 'node_modules',27 'dist',28 '.idea',29 '.git',30 '.cache'31 ],32 33 // 覆盖率配置34 coverage: {35 provider: 'v8',36 reporter: ['text', 'json', 'html'],37 include: ['src/**/*.{js,jsx,ts,tsx}'],38 exclude: [39 'src/**/*.d.ts',40 'src/**/*.stories.{js,jsx,ts,tsx}',41 'src/**/*.test.{js,jsx,ts,tsx}',42 'src/main.tsx',43 'src/vite-env.d.ts'44 ],45 thresholds: {46 global: {47 branches: 80,48 functions: 80,49 lines: 80,50 statements: 8051 }52 }53 },54 55 // 性能配置56 pool: 'threads',57 poolOptions: {58 threads: {59 singleThread: false,60 maxThreads: 4,61 minThreads: 162 }63 },64 65 // 超时配置66 testTimeout: 10000,67 hookTimeout: 10000,68 69 // 监听模式配置70 watch: {71 ignore: ['node_modules/**', 'dist/**']72 },73 74 // 报告器配置75 reporter: ['verbose', 'json', 'html'],76 77 // 并发配置78 sequence: {79 concurrent: true80 }81 },82 83 resolve: {84 alias: {85 '@': path.resolve(__dirname, './src'),86 '@components': path.resolve(__dirname, './src/components'),87 '@utils': path.resolve(__dirname, './src/utils'),88 '@hooks': path.resolve(__dirname, './src/hooks'),89 '@types': path.resolve(__dirname, './src/types')90 }91 }92});测试相关NPM脚本
package.json - 测试脚本配置
json
1{2 "scripts": {3 "test": "jest",4 "test:watch": "jest --watch",5 "test:coverage": "jest --coverage",6 "test:ci": "jest --coverage --watchAll=false --passWithNoTests",7 "test:debug": "node --inspect-brk node_modules/.bin/jest --runInBand",8 "test:update": "jest --updateSnapshot",9 "test:clear": "jest --clearCache",10 11 // Vitest脚本12 "test:vitest": "vitest",13 "test:vitest:ui": "vitest --ui",14 "test:vitest:coverage": "vitest --coverage",15 "test:vitest:run": "vitest run",16 17 // 测试相关工具18 "lint:test": "eslint 'src/**/*.test.{js,jsx,ts,tsx}'",19 "type-check:test": "tsc --noEmit --project tsconfig.test.json"20 },21 22 "devDependencies": {23 "@testing-library/jest-dom": "^6.1.4",24 "@testing-library/react": "^13.4.0",25 "@testing-library/user-event": "^14.5.1",26 "@types/jest": "^29.5.8",27 "jest": "^29.7.0",28 "jest-environment-jsdom": "^29.7.0",29 "jest-transform-stub": "^2.0.0",30 "ts-jest": "^29.1.1",31 32 // Vitest相关33 "vitest": "^1.0.0",34 "@vitest/ui": "^1.0.0",35 "jsdom": "^23.0.0",36 37 // Mock相关38 "msw": "^2.0.0",39 "jest-canvas-mock": "^2.5.2"40 }41}2. 工具函数测试实践
安装和配置
bash
1# 安装Jest2npm install --save-dev jest34# 安装TypeScript支持5npm install --save-dev @types/jest ts-jestjavascript
1// jest.config.js2module.exports = {3 preset: 'ts-jest',4 testEnvironment: 'jsdom',5 setupFilesAfterEnv: ['<rootDir>/src/setupTests.ts'],6 moduleNameMapping: {7 '^@/(.*)$': '<rootDir>/src/$1'8 }9};基本测试结构
javascript
1// math.test.js2function add(a, b) {3 return a + b;4}56function multiply(a, b) {7 return a * b;8}910describe('Math functions', () => {11 describe('add', () => {12 it('should add two positive numbers', () => {13 expect(add(2, 3)).toBe(5);14 });1516 it('should handle negative numbers', () => {17 expect(add(-1, 1)).toBe(0);18 });1920 it('should handle zero', () => {21 expect(add(0, 5)).toBe(5);22 });23 });2425 describe('multiply', () => {26 it('should multiply two numbers', () => {27 expect(multiply(2, 3)).toBe(6);28 });29 });30});测试工具函数
工具函数测试
javascript
1// utils.js2export function formatDate(date) {3 return date.toISOString().split('T')[0];4}56export function debounce(func, wait) {7 let timeout;8 return function executedFunction(...args) {9 const later = () => {10 clearTimeout(timeout);11 func(...args);12 };13 clearTimeout(timeout);14 timeout = setTimeout(later, wait);15 };16}1718export function validateEmail(email) {19 const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;20 return emailRegex.test(email);21}javascript
1// utils.test.js2import { formatDate, debounce, validateEmail } from './utils';34describe('Utils', () => {5 describe('formatDate', () => {6 it('should format date correctly', () => {7 const date = new Date('2023-12-25T10:30:00Z');8 expect(formatDate(date)).toBe('2023-12-25');9 });10 });1112 describe('debounce', () => {13 it('should debounce function calls', (done) => {14 let callCount = 0;15 const debouncedFn = debounce(() => {16 callCount++;17 }, 100);1819 debouncedFn();20 debouncedFn();21 debouncedFn();2223 setTimeout(() => {24 expect(callCount).toBe(1);25 done();26 }, 150);27 });28 });2930 describe('validateEmail', () => {31 it('should validate correct email addresses', () => {32 expect(validateEmail('test@example.com')).toBe(true);33 expect(validateEmail('user.name@domain.co.uk')).toBe(true);34 });3536 it('should reject invalid email addresses', () => {37 expect(validateEmail('invalid-email')).toBe(false);38 expect(validateEmail('test@')).toBe(false);39 expect(validateEmail('@example.com')).toBe(false);40 });41 });42});React组件测试
安装测试库
bash
1npm install --save-dev @testing-library/react @testing-library/jest-domjavascript
1// setupTests.js2import '@testing-library/jest-dom';基础组件测试
javascript
1// Button.jsx2import React from 'react';34function Button({ children, onClick, disabled = false, variant = 'primary' }) {5 return (6 <button 7 onClick={onClick} 8 disabled={disabled}9 className={`btn btn-${variant}`}10 >11 {children}12 </button>13 );14}1516export default Button;javascript
1// Button.test.jsx2import React from 'react';3import { render, screen, fireEvent } from '@testing-library/react';4import Button from './Button';56describe('Button', () => {7 it('renders with correct text', () => {8 render(<Button>Click me</Button>);9 expect(screen.getByText('Click me')).toBeInTheDocument();10 });1112 it('calls onClick when clicked', () => {13 const handleClick = jest.fn();14 render(<Button onClick={handleClick}>Click me</Button>);15 16 fireEvent.click(screen.getByText('Click me'));17 expect(handleClick).toHaveBeenCalledTimes(1);18 });1920 it('is disabled when disabled prop is true', () => {21 render(<Button disabled>Click me</Button>);22 expect(screen.getByText('Click me')).toBeDisabled();23 });2425 it('applies correct variant class', () => {26 render(<Button variant="secondary">Click me</Button>);27 expect(screen.getByText('Click me')).toHaveClass('btn-secondary');28 });29});表单组件测试
javascript
1// LoginForm.jsx2import React, { useState } from 'react';34function LoginForm({ onSubmit }) {5 const [email, setEmail] = useState('');6 const [password, setPassword] = useState('');78 const handleSubmit = (e) => {9 e.preventDefault();10 onSubmit({ email, password });11 };1213 return (14 <form onSubmit={handleSubmit}>15 <input16 type="email"17 value={email}18 onChange={(e) => setEmail(e.target.value)}19 placeholder="Email"20 required21 />22 <input23 type="password"24 value={password}25 onChange={(e) => setPassword(e.target.value)}26 placeholder="Password"27 required28 />29 <button type="submit">Login</button>30 </form>31 );32}3334export default LoginForm;javascript
1// LoginForm.test.jsx2import React from 'react';3import { render, screen, fireEvent } from '@testing-library/react';4import LoginForm from './LoginForm';56describe('LoginForm', () => {7 it('renders form elements', () => {8 render(<LoginForm onSubmit={jest.fn()} />);9 10 expect(screen.getByPlaceholderText('Email')).toBeInTheDocument();11 expect(screen.getByPlaceholderText('Password')).toBeInTheDocument();12 expect(screen.getByRole('button', { name: 'Login' })).toBeInTheDocument();13 });1415 it('updates input values', () => {16 render(<LoginForm onSubmit={jest.fn()} />);17 18 const emailInput = screen.getByPlaceholderText('Email');19 const passwordInput = screen.getByPlaceholderText('Password');20 21 fireEvent.change(emailInput, { target: { value: 'test@example.com' } });22 fireEvent.change(passwordInput, { target: { value: 'password123' } });23 24 expect(emailInput.value).toBe('test@example.com');25 expect(passwordInput.value).toBe('password123');26 });2728 it('calls onSubmit with form data', () => {29 const mockOnSubmit = jest.fn();30 render(<LoginForm onSubmit={mockOnSubmit} />);31 32 fireEvent.change(screen.getByPlaceholderText('Email'), {33 target: { value: 'test@example.com' }34 });35 fireEvent.change(screen.getByPlaceholderText('Password'), {36 target: { value: 'password123' }37 });38 fireEvent.click(screen.getByRole('button', { name: 'Login' }));39 40 expect(mockOnSubmit).toHaveBeenCalledWith({41 email: 'test@example.com',42 password: 'password123'43 });44 });45});异步测试
Promise测试
javascript
1// api.js2export async function fetchUser(id) {3 const response = await fetch(`/api/users/${id}`);4 if (!response.ok) {5 throw new Error('User not found');6 }7 return response.json();8}javascript
1// api.test.js2import { fetchUser } from './api';34// Mock fetch5global.fetch = jest.fn();67describe('fetchUser', () => {8 beforeEach(() => {9 fetch.mockClear();10 });1112 it('should fetch user successfully', async () => {13 const mockUser = { id: 1, name: 'John Doe' };14 fetch.mockResolvedValueOnce({15 ok: true,16 json: async () => mockUser17 });1819 const result = await fetchUser(1);20 expect(result).toEqual(mockUser);21 expect(fetch).toHaveBeenCalledWith('/api/users/1');22 });2324 it('should throw error when user not found', async () => {25 fetch.mockResolvedValueOnce({26 ok: false27 });2829 await expect(fetchUser(999)).rejects.toThrow('User not found');30 });31});异步组件测试
javascript
1// UserProfile.jsx2import React, { useState, useEffect } from 'react';34function UserProfile({ userId }) {5 const [user, setUser] = useState(null);6 const [loading, setLoading] = useState(true);7 const [error, setError] = useState(null);89 useEffect(() => {10 async function loadUser() {11 try {12 const response = await fetch(`/api/users/${userId}`);13 if (!response.ok) {14 throw new Error('User not found');15 }16 const userData = await response.json();17 setUser(userData);18 } catch (err) {19 setError(err.message);20 } finally {21 setLoading(false);22 }23 }2425 loadUser();26 }, [userId]);2728 if (loading) return <div>Loading...</div>;29 if (error) return <div>Error: {error}</div>;30 if (!user) return <div>No user found</div>;3132 return (33 <div>34 <h1>{user.name}</h1>35 <p>{user.email}</p>36 </div>37 );38}3940export default UserProfile;javascript
1// UserProfile.test.jsx2import React from 'react';3import { render, screen, waitFor } from '@testing-library/react';4import UserProfile from './UserProfile';56// Mock fetch7global.fetch = jest.fn();89describe('UserProfile', () => {10 beforeEach(() => {11 fetch.mockClear();12 });1314 it('shows loading state initially', () => {15 render(<UserProfile userId={1} />);16 expect(screen.getByText('Loading...')).toBeInTheDocument();17 });1819 it('shows user data when fetch succeeds', async () => {20 const mockUser = { name: 'John Doe', email: 'john@example.com' };21 fetch.mockResolvedValueOnce({22 ok: true,23 json: async () => mockUser24 });2526 render(<UserProfile userId={1} />);2728 await waitFor(() => {29 expect(screen.getByText('John Doe')).toBeInTheDocument();30 });31 expect(screen.getByText('john@example.com')).toBeInTheDocument();32 });3334 it('shows error when fetch fails', async () => {35 fetch.mockResolvedValueOnce({36 ok: false37 });3839 render(<UserProfile userId={999} />);4041 await waitFor(() => {42 expect(screen.getByText('Error: User not found')).toBeInTheDocument();43 });44 });45});Mock和Stub
函数Mock
javascript
1// calculator.js2export function add(a, b) {3 return a + b;4}56export function multiply(a, b) {7 return a * b;8}910export function calculate(a, b, operation) {11 switch (operation) {12 case 'add':13 return add(a, b);14 case 'multiply':15 return multiply(a, b);16 default:17 throw new Error('Unknown operation');18 }19}javascript
1// calculator.test.js2import { calculate, add, multiply } from './calculator';34// Mock the add and multiply functions5jest.mock('./calculator', () => ({6 ...jest.requireActual('./calculator'),7 add: jest.fn(),8 multiply: jest.fn()9}));1011describe('calculate', () => {12 beforeEach(() => {13 add.mockClear();14 multiply.mockClear();15 });1617 it('should call add function for add operation', () => {18 add.mockReturnValue(5);19 20 const result = calculate(2, 3, 'add');21 22 expect(add).toHaveBeenCalledWith(2, 3);23 expect(result).toBe(5);24 });2526 it('should call multiply function for multiply operation', () => {27 multiply.mockReturnValue(6);28 29 const result = calculate(2, 3, 'multiply');30 31 expect(multiply).toHaveBeenCalledWith(2, 3);32 expect(result).toBe(6);33 });34});模块Mock
javascript
1// userService.js2import { fetchUser } from './api';34export async function getUserName(id) {5 const user = await fetchUser(id);6 return user.name;7}javascript
1// userService.test.js2import { getUserName } from './userService';34// Mock the api module5jest.mock('./api', () => ({6 fetchUser: jest.fn()7}));89import { fetchUser } from './api';1011describe('getUserName', () => {12 beforeEach(() => {13 fetchUser.mockClear();14 });1516 it('should return user name', async () => {17 fetchUser.mockResolvedValue({ name: 'John Doe' });18 19 const result = await getUserName(1);20 21 expect(fetchUser).toHaveBeenCalledWith(1);22 expect(result).toBe('John Doe');23 });24});测试覆盖率
配置覆盖率
javascript
1// jest.config.js2module.exports = {3 collectCoverageFrom: [4 'src/**/*.{js,jsx,ts,tsx}',5 '!src/**/*.d.ts',6 '!src/index.tsx',7 '!src/serviceWorker.ts'8 ],9 coverageThreshold: {10 global: {11 branches: 80,12 functions: 80,13 lines: 80,14 statements: 8015 }16 }17};运行覆盖率
bash
1# 运行测试并生成覆盖率报告2npm test -- --coverage34# 只运行覆盖率5npm test -- --coverage --watchAll=false测试最佳实践
测试命名
javascript
1describe('UserService', () => {2 describe('getUser', () => {3 it('should return user when valid ID is provided', () => {4 // test implementation5 });67 it('should throw error when user not found', () => {8 // test implementation9 });10 });11});测试数据工厂
javascript
1// testFactories.js2export function createUser(overrides = {}) {3 return {4 id: 1,5 name: 'John Doe',6 email: 'john@example.com',7 ...overrides8 };9}1011export function createUserList(count = 3) {12 return Array.from({ length: count }, (_, index) => 13 createUser({ id: index + 1, name: `User ${index + 1}` })14 );15}测试清理
javascript
1describe('UserService', () => {2 beforeEach(() => {3 // 设置测试环境4 jest.clearAllMocks();5 });67 afterEach(() => {8 // 清理测试数据9 });1011 afterAll(() => {12 // 清理全局状态13 });14});
评论