跳到主要内容

前端单元测试完全指南

单元测试是测试金字塔的基础,专注于测试单个函数、组件或模块的功能。通过系统化的单元测试实践,可以快速验证代码逻辑、提供即时反馈、确保代码质量,并为重构提供安全保障。

核心价值

单元测试 = 快速反馈 + 代码质量 + 重构安全 + 文档价值

  • 🚀 快速反馈:毫秒级测试执行,即时发现问题
  • 🎯 代码质量:强制思考边界条件和异常情况
  • 🛡️ 重构安全:为代码重构提供安全网
  • 📚 文档价值:测试用例作为代码使用说明
  • 🔧 开发效率:减少手动测试时间,提高开发速度
  • 📊 覆盖率监控:量化测试完整性,识别测试盲点

1. 测试环境搭建与配置

1.1 现代测试工具链

构建高效的测试环境需要选择合适的工具组合,每个工具都有其特定的职责和优势。

测试工具对比

工具类型优势劣势适用场景
Jest测试框架零配置、功能全面、生态丰富较重、启动慢React项目、大型应用
Vitest测试框架快速、现代、Vite集成生态较新Vite项目、现代应用
React Testing Library测试工具用户行为导向、最佳实践学习曲线React组件测试
MSWMock工具真实网络模拟、开发友好配置复杂API测试、集成测试

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: 80
53 },
54 './src/components/': {
55 branches: 90,
56 functions: 90,
57 lines: 90,
58 statements: 90
59 },
60 './src/utils/': {
61 branches: 95,
62 functions: 95,
63 lines: 95,
64 statements: 95
65 }
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: true
80 }
81 },
82
83 // 测试超时
84 testTimeout: 10000,
85
86 // 清理配置
87 clearMocks: true,
88 restoreMocks: true,
89
90 // 详细输出
91 verbose: true,
92
93 // 错误处理
94 errorOnDeprecated: true
95};
setupTests.ts - 测试环境设置
typescript
1import '@testing-library/jest-dom';
2import 'jest-canvas-mock';
3
4// 全局Mock
5global.fetch = jest.fn();
6global.ResizeObserver = jest.fn().mockImplementation(() => ({
7 observe: jest.fn(),
8 unobserve: jest.fn(),
9 disconnect: jest.fn(),
10}));
11
12global.IntersectionObserver = jest.fn().mockImplementation(() => ({
13 observe: jest.fn(),
14 unobserve: jest.fn(),
15 disconnect: jest.fn(),
16}));
17
18// Mock localStorage
19const 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;
28
29// Mock sessionStorage
30global.sessionStorage = localStorageMock;
31
32// Mock window.matchMedia
33Object.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});
46
47// Mock console methods for cleaner test output
48const 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});
60
61afterAll(() => {
62 console.error = originalError;
63});
64
65// 全局测试工具
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 ...properties
77 }),
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};
89
90// 清理函数
91afterEach(() => {
92 jest.clearAllMocks();
93 localStorage.clear();
94 sessionStorage.clear();
95});

2. 工具函数测试实践

安装和配置

bash
1# 安装Jest
2npm install --save-dev jest
3
4# 安装TypeScript支持
5npm install --save-dev @types/jest ts-jest
javascript
1// jest.config.js
2module.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.js
2function add(a, b) {
3 return a + b;
4}
5
6function multiply(a, b) {
7 return a * b;
8}
9
10describe('Math functions', () => {
11 describe('add', () => {
12 it('should add two positive numbers', () => {
13 expect(add(2, 3)).toBe(5);
14 });
15
16 it('should handle negative numbers', () => {
17 expect(add(-1, 1)).toBe(0);
18 });
19
20 it('should handle zero', () => {
21 expect(add(0, 5)).toBe(5);
22 });
23 });
24
25 describe('multiply', () => {
26 it('should multiply two numbers', () => {
27 expect(multiply(2, 3)).toBe(6);
28 });
29 });
30});

测试工具函数

工具函数测试

javascript
1// utils.js
2export function formatDate(date) {
3 return date.toISOString().split('T')[0];
4}
5
6export 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}
17
18export function validateEmail(email) {
19 const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
20 return emailRegex.test(email);
21}
javascript
1// utils.test.js
2import { formatDate, debounce, validateEmail } from './utils';
3
4describe('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 });
11
12 describe('debounce', () => {
13 it('should debounce function calls', (done) => {
14 let callCount = 0;
15 const debouncedFn = debounce(() => {
16 callCount++;
17 }, 100);
18
19 debouncedFn();
20 debouncedFn();
21 debouncedFn();
22
23 setTimeout(() => {
24 expect(callCount).toBe(1);
25 done();
26 }, 150);
27 });
28 });
29
30 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 });
35
36 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-dom
javascript
1// setupTests.js
2import '@testing-library/jest-dom';

基础组件测试

javascript
1// Button.jsx
2import React from 'react';
3
4function 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}
15
16export default Button;
javascript
1// Button.test.jsx
2import React from 'react';
3import { render, screen, fireEvent } from '@testing-library/react';
4import Button from './Button';
5
6describe('Button', () => {
7 it('renders with correct text', () => {
8 render(<Button>Click me</Button>);
9 expect(screen.getByText('Click me')).toBeInTheDocument();
10 });
11
12 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 });
19
20 it('is disabled when disabled prop is true', () => {
21 render(<Button disabled>Click me</Button>);
22 expect(screen.getByText('Click me')).toBeDisabled();
23 });
24
25 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.jsx
2import React, { useState } from 'react';
3
4function LoginForm({ onSubmit }) {
5 const [email, setEmail] = useState('');
6 const [password, setPassword] = useState('');
7
8 const handleSubmit = (e) => {
9 e.preventDefault();
10 onSubmit({ email, password });
11 };
12
13 return (
14 <form onSubmit={handleSubmit}>
15 <input
16 type="email"
17 value={email}
18 onChange={(e) => setEmail(e.target.value)}
19 placeholder="Email"
20 required
21 />
22 <input
23 type="password"
24 value={password}
25 onChange={(e) => setPassword(e.target.value)}
26 placeholder="Password"
27 required
28 />
29 <button type="submit">Login</button>
30 </form>
31 );
32}
33
34export default LoginForm;
javascript
1// LoginForm.test.jsx
2import React from 'react';
3import { render, screen, fireEvent } from '@testing-library/react';
4import LoginForm from './LoginForm';
5
6describe('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 });
14
15 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 });
27
28 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.js
2export 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.js
2import { fetchUser } from './api';
3
4// Mock fetch
5global.fetch = jest.fn();
6
7describe('fetchUser', () => {
8 beforeEach(() => {
9 fetch.mockClear();
10 });
11
12 it('should fetch user successfully', async () => {
13 const mockUser = { id: 1, name: 'John Doe' };
14 fetch.mockResolvedValueOnce({
15 ok: true,
16 json: async () => mockUser
17 });
18
19 const result = await fetchUser(1);
20 expect(result).toEqual(mockUser);
21 expect(fetch).toHaveBeenCalledWith('/api/users/1');
22 });
23
24 it('should throw error when user not found', async () => {
25 fetch.mockResolvedValueOnce({
26 ok: false
27 });
28
29 await expect(fetchUser(999)).rejects.toThrow('User not found');
30 });
31});

异步组件测试

javascript
1// UserProfile.jsx
2import React, { useState, useEffect } from 'react';
3
4function UserProfile({ userId }) {
5 const [user, setUser] = useState(null);
6 const [loading, setLoading] = useState(true);
7 const [error, setError] = useState(null);
8
9 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 }
24
25 loadUser();
26 }, [userId]);
27
28 if (loading) return <div>Loading...</div>;
29 if (error) return <div>Error: {error}</div>;
30 if (!user) return <div>No user found</div>;
31
32 return (
33 <div>
34 <h1>{user.name}</h1>
35 <p>{user.email}</p>
36 </div>
37 );
38}
39
40export default UserProfile;
javascript
1// UserProfile.test.jsx
2import React from 'react';
3import { render, screen, waitFor } from '@testing-library/react';
4import UserProfile from './UserProfile';
5
6// Mock fetch
7global.fetch = jest.fn();
8
9describe('UserProfile', () => {
10 beforeEach(() => {
11 fetch.mockClear();
12 });
13
14 it('shows loading state initially', () => {
15 render(<UserProfile userId={1} />);
16 expect(screen.getByText('Loading...')).toBeInTheDocument();
17 });
18
19 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 () => mockUser
24 });
25
26 render(<UserProfile userId={1} />);
27
28 await waitFor(() => {
29 expect(screen.getByText('John Doe')).toBeInTheDocument();
30 });
31 expect(screen.getByText('john@example.com')).toBeInTheDocument();
32 });
33
34 it('shows error when fetch fails', async () => {
35 fetch.mockResolvedValueOnce({
36 ok: false
37 });
38
39 render(<UserProfile userId={999} />);
40
41 await waitFor(() => {
42 expect(screen.getByText('Error: User not found')).toBeInTheDocument();
43 });
44 });
45});

Mock和Stub

函数Mock

javascript
1// calculator.js
2export function add(a, b) {
3 return a + b;
4}
5
6export function multiply(a, b) {
7 return a * b;
8}
9
10export 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.js
2import { calculate, add, multiply } from './calculator';
3
4// Mock the add and multiply functions
5jest.mock('./calculator', () => ({
6 ...jest.requireActual('./calculator'),
7 add: jest.fn(),
8 multiply: jest.fn()
9}));
10
11describe('calculate', () => {
12 beforeEach(() => {
13 add.mockClear();
14 multiply.mockClear();
15 });
16
17 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 });
25
26 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.js
2import { fetchUser } from './api';
3
4export async function getUserName(id) {
5 const user = await fetchUser(id);
6 return user.name;
7}
javascript
1// userService.test.js
2import { getUserName } from './userService';
3
4// Mock the api module
5jest.mock('./api', () => ({
6 fetchUser: jest.fn()
7}));
8
9import { fetchUser } from './api';
10
11describe('getUserName', () => {
12 beforeEach(() => {
13 fetchUser.mockClear();
14 });
15
16 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.js
2module.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: 80
15 }
16 }
17};

运行覆盖率

bash
1# 运行测试并生成覆盖率报告
2npm test -- --coverage
3
4# 只运行覆盖率
5npm test -- --coverage --watchAll=false

测试最佳实践

测试命名

javascript
1describe('UserService', () => {
2 describe('getUser', () => {
3 it('should return user when valid ID is provided', () => {
4 // test implementation
5 });
6
7 it('should throw error when user not found', () => {
8 // test implementation
9 });
10 });
11});

测试数据工厂

javascript
1// testFactories.js
2export function createUser(overrides = {}) {
3 return {
4 id: 1,
5 name: 'John Doe',
6 email: 'john@example.com',
7 ...overrides
8 };
9}
10
11export 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 });
6
7 afterEach(() => {
8 // 清理测试数据
9 });
10
11 afterAll(() => {
12 // 清理全局状态
13 });
14});

评论