9.1 测试基础

9.1.1 测试类型概述

TypeScript 项目中的测试可以分为以下几个层次:

// 测试金字塔结构
interface TestPyramid {
    unit: {
        description: '单元测试 - 测试单个函数或类';
        coverage: '70-80%';
        speed: 'fast';
        isolation: 'high';
    };
    integration: {
        description: '集成测试 - 测试模块间交互';
        coverage: '15-25%';
        speed: 'medium';
        isolation: 'medium';
    };
    e2e: {
        description: '端到端测试 - 测试完整用户流程';
        coverage: '5-15%';
        speed: 'slow';
        isolation: 'low';
    };
}

// 测试策略接口
interface TestStrategy {
    framework: 'jest' | 'vitest' | 'mocha';
    runner: 'node' | 'jsdom' | 'happy-dom';
    coverage: {
        threshold: number;
        reports: string[];
    };
    mocking: {
        modules: boolean;
        functions: boolean;
        timers: boolean;
    };
}

// 测试配置管理器
class TestConfigManager {
    private strategy: TestStrategy;
    
    constructor(strategy: TestStrategy) {
        this.strategy = strategy;
    }
    
    generateConfig(): Record<string, any> {
        switch (this.strategy.framework) {
            case 'jest':
                return this.generateJestConfig();
            case 'vitest':
                return this.generateVitestConfig();
            case 'mocha':
                return this.generateMochaConfig();
            default:
                throw new Error(`Unsupported framework: ${this.strategy.framework}`);
        }
    }
    
    private generateJestConfig(): Record<string, any> {
        return {
            preset: 'ts-jest',
            testEnvironment: this.strategy.runner,
            roots: ['<rootDir>/src'],
            testMatch: [
                '**/__tests__/**/*.+(ts|tsx|js)',
                '**/*.(test|spec).+(ts|tsx|js)'
            ],
            transform: {
                '^.+\\.(ts|tsx)$': 'ts-jest'
            },
            collectCoverageFrom: [
                'src/**/*.{ts,tsx}',
                '!src/**/*.d.ts',
                '!src/index.ts'
            ],
            coverageThreshold: {
                global: {
                    branches: this.strategy.coverage.threshold,
                    functions: this.strategy.coverage.threshold,
                    lines: this.strategy.coverage.threshold,
                    statements: this.strategy.coverage.threshold
                }
            },
            setupFilesAfterEnv: ['<rootDir>/src/setupTests.ts'],
            moduleNameMapping: {
                '^@/(.*)$': '<rootDir>/src/$1'
            }
        };
    }
    
    private generateVitestConfig(): Record<string, any> {
        return {
            test: {
                environment: this.strategy.runner,
                globals: true,
                setupFiles: ['./src/setupTests.ts'],
                coverage: {
                    provider: 'v8',
                    reporter: this.strategy.coverage.reports,
                    thresholds: {
                        global: {
                            branches: this.strategy.coverage.threshold,
                            functions: this.strategy.coverage.threshold,
                            lines: this.strategy.coverage.threshold,
                            statements: this.strategy.coverage.threshold
                        }
                    }
                }
            }
        };
    }
    
    private generateMochaConfig(): Record<string, any> {
        return {
            require: ['ts-node/register'],
            extensions: ['ts', 'tsx'],
            spec: 'src/**/*.{test,spec}.{ts,tsx}',
            timeout: 5000
        };
    }
}

9.1.2 测试环境搭建

// src/setupTests.ts - 测试环境配置
import '@testing-library/jest-dom';
import { configure } from '@testing-library/react';
import { server } from './mocks/server';

// 配置 Testing Library
configure({
    testIdAttribute: 'data-testid',
    asyncUtilTimeout: 5000
});

// 设置 MSW (Mock Service Worker)
beforeAll(() => {
    server.listen({
        onUnhandledRequest: 'error'
    });
});

afterEach(() => {
    server.resetHandlers();
    jest.clearAllMocks();
});

afterAll(() => {
    server.close();
});

// 全局测试工具
global.testUtils = {
    // 创建测试用的 Promise
    createPromise: <T>() => {
        let resolve: (value: T) => void;
        let reject: (reason?: any) => void;
        
        const promise = new Promise<T>((res, rej) => {
            resolve = res;
            reject = rej;
        });
        
        return { promise, resolve: resolve!, reject: reject! };
    },
    
    // 等待指定时间
    wait: (ms: number) => new Promise(resolve => setTimeout(resolve, ms)),
    
    // 创建模拟函数
    createMockFn: <T extends (...args: any[]) => any>(implementation?: T) => {
        return jest.fn(implementation) as jest.MockedFunction<T>;
    }
};

// 扩展全局类型
declare global {
    var testUtils: {
        createPromise: <T>() => {
            promise: Promise<T>;
            resolve: (value: T) => void;
            reject: (reason?: any) => void;
        };
        wait: (ms: number) => Promise<void>;
        createMockFn: <T extends (...args: any[]) => any>(implementation?: T) => jest.MockedFunction<T>;
    };
}

// 模拟浏览器 API
Object.defineProperty(window, 'matchMedia', {
    writable: true,
    value: jest.fn().mockImplementation(query => ({
        matches: false,
        media: query,
        onchange: null,
        addListener: jest.fn(),
        removeListener: jest.fn(),
        addEventListener: jest.fn(),
        removeEventListener: jest.fn(),
        dispatchEvent: jest.fn()
    }))
});

Object.defineProperty(window, 'ResizeObserver', {
    writable: true,
    value: jest.fn().mockImplementation(() => ({
        observe: jest.fn(),
        unobserve: jest.fn(),
        disconnect: jest.fn()
    }))
});

// 模拟 localStorage
const localStorageMock = {
    getItem: jest.fn(),
    setItem: jest.fn(),
    removeItem: jest.fn(),
    clear: jest.fn()
};

Object.defineProperty(window, 'localStorage', {
    value: localStorageMock
});

// 模拟 fetch
global.fetch = jest.fn();

9.2 单元测试

9.2.1 函数测试

// src/utils/math.ts - 被测试的工具函数
export class MathUtils {
    static add(a: number, b: number): number {
        if (!Number.isFinite(a) || !Number.isFinite(b)) {
            throw new Error('Arguments must be finite numbers');
        }
        return a + b;
    }
    
    static divide(a: number, b: number): number {
        if (b === 0) {
            throw new Error('Division by zero');
        }
        return a / b;
    }
    
    static factorial(n: number): number {
        if (n < 0 || !Number.isInteger(n)) {
            throw new Error('Argument must be a non-negative integer');
        }
        
        if (n === 0 || n === 1) {
            return 1;
        }
        
        return n * this.factorial(n - 1);
    }
    
    static isPrime(n: number): boolean {
        if (n < 2 || !Number.isInteger(n)) {
            return false;
        }
        
        for (let i = 2; i <= Math.sqrt(n); i++) {
            if (n % i === 0) {
                return false;
            }
        }
        
        return true;
    }
    
    static fibonacci(n: number): number {
        if (n < 0 || !Number.isInteger(n)) {
            throw new Error('Argument must be a non-negative integer');
        }
        
        if (n <= 1) {
            return n;
        }
        
        let a = 0, b = 1;
        for (let i = 2; i <= n; i++) {
            [a, b] = [b, a + b];
        }
        
        return b;
    }
}

// src/utils/string.ts
export class StringUtils {
    static capitalize(str: string): string {
        if (typeof str !== 'string') {
            throw new Error('Argument must be a string');
        }
        
        if (str.length === 0) {
            return str;
        }
        
        return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase();
    }
    
    static truncate(str: string, maxLength: number, suffix = '...'): string {
        if (typeof str !== 'string') {
            throw new Error('First argument must be a string');
        }
        
        if (typeof maxLength !== 'number' || maxLength < 0) {
            throw new Error('Max length must be a non-negative number');
        }
        
        if (str.length <= maxLength) {
            return str;
        }
        
        return str.substring(0, maxLength - suffix.length) + suffix;
    }
    
    static slugify(str: string): string {
        return str
            .toLowerCase()
            .trim()
            .replace(/[^\w\s-]/g, '')
            .replace(/[\s_-]+/g, '-')
            .replace(/^-+|-+$/g, '');
    }
}

// src/utils/__tests__/math.test.ts - 数学工具函数测试
import { MathUtils } from '../math';

describe('MathUtils', () => {
    describe('add', () => {
        it('should add two positive numbers correctly', () => {
            expect(MathUtils.add(2, 3)).toBe(5);
            expect(MathUtils.add(10, 20)).toBe(30);
        });
        
        it('should add negative numbers correctly', () => {
            expect(MathUtils.add(-2, -3)).toBe(-5);
            expect(MathUtils.add(-10, 5)).toBe(-5);
        });
        
        it('should add decimal numbers correctly', () => {
            expect(MathUtils.add(0.1, 0.2)).toBeCloseTo(0.3);
            expect(MathUtils.add(1.5, 2.5)).toBe(4);
        });
        
        it('should throw error for non-finite numbers', () => {
            expect(() => MathUtils.add(Infinity, 5)).toThrow('Arguments must be finite numbers');
            expect(() => MathUtils.add(NaN, 5)).toThrow('Arguments must be finite numbers');
        });
    });
    
    describe('divide', () => {
        it('should divide numbers correctly', () => {
            expect(MathUtils.divide(10, 2)).toBe(5);
            expect(MathUtils.divide(15, 3)).toBe(5);
        });
        
        it('should handle decimal division', () => {
            expect(MathUtils.divide(1, 3)).toBeCloseTo(0.333333);
        });
        
        it('should throw error when dividing by zero', () => {
            expect(() => MathUtils.divide(10, 0)).toThrow('Division by zero');
        });
    });
    
    describe('factorial', () => {
        it('should calculate factorial correctly', () => {
            expect(MathUtils.factorial(0)).toBe(1);
            expect(MathUtils.factorial(1)).toBe(1);
            expect(MathUtils.factorial(5)).toBe(120);
            expect(MathUtils.factorial(10)).toBe(3628800);
        });
        
        it('should throw error for negative numbers', () => {
            expect(() => MathUtils.factorial(-1)).toThrow('Argument must be a non-negative integer');
        });
        
        it('should throw error for non-integers', () => {
            expect(() => MathUtils.factorial(3.5)).toThrow('Argument must be a non-negative integer');
        });
    });
    
    describe('isPrime', () => {
        it('should identify prime numbers correctly', () => {
            expect(MathUtils.isPrime(2)).toBe(true);
            expect(MathUtils.isPrime(3)).toBe(true);
            expect(MathUtils.isPrime(17)).toBe(true);
            expect(MathUtils.isPrime(97)).toBe(true);
        });
        
        it('should identify non-prime numbers correctly', () => {
            expect(MathUtils.isPrime(1)).toBe(false);
            expect(MathUtils.isPrime(4)).toBe(false);
            expect(MathUtils.isPrime(15)).toBe(false);
            expect(MathUtils.isPrime(100)).toBe(false);
        });
        
        it('should return false for negative numbers and decimals', () => {
            expect(MathUtils.isPrime(-5)).toBe(false);
            expect(MathUtils.isPrime(3.5)).toBe(false);
        });
    });
    
    describe('fibonacci', () => {
        it('should calculate fibonacci numbers correctly', () => {
            expect(MathUtils.fibonacci(0)).toBe(0);
            expect(MathUtils.fibonacci(1)).toBe(1);
            expect(MathUtils.fibonacci(10)).toBe(55);
            expect(MathUtils.fibonacci(20)).toBe(6765);
        });
        
        it('should throw error for negative numbers', () => {
            expect(() => MathUtils.fibonacci(-1)).toThrow('Argument must be a non-negative integer');
        });
    });
});

// src/utils/__tests__/string.test.ts - 字符串工具函数测试
import { StringUtils } from '../string';

describe('StringUtils', () => {
    describe('capitalize', () => {
        it('should capitalize first letter and lowercase the rest', () => {
            expect(StringUtils.capitalize('hello')).toBe('Hello');
            expect(StringUtils.capitalize('WORLD')).toBe('World');
            expect(StringUtils.capitalize('hELLo WoRLD')).toBe('Hello world');
        });
        
        it('should handle empty string', () => {
            expect(StringUtils.capitalize('')).toBe('');
        });
        
        it('should handle single character', () => {
            expect(StringUtils.capitalize('a')).toBe('A');
            expect(StringUtils.capitalize('Z')).toBe('Z');
        });
        
        it('should throw error for non-string input', () => {
            expect(() => StringUtils.capitalize(123 as any)).toThrow('Argument must be a string');
            expect(() => StringUtils.capitalize(null as any)).toThrow('Argument must be a string');
        });
    });
    
    describe('truncate', () => {
        it('should truncate long strings', () => {
            expect(StringUtils.truncate('Hello World', 5)).toBe('He...');
            expect(StringUtils.truncate('TypeScript', 6)).toBe('Typ...');
        });
        
        it('should not truncate short strings', () => {
            expect(StringUtils.truncate('Hello', 10)).toBe('Hello');
            expect(StringUtils.truncate('Hi', 5)).toBe('Hi');
        });
        
        it('should use custom suffix', () => {
            expect(StringUtils.truncate('Hello World', 8, '---')).toBe('Hello---');
        });
        
        it('should handle edge cases', () => {
            expect(StringUtils.truncate('', 5)).toBe('');
            expect(StringUtils.truncate('Hello', 5)).toBe('Hello');
        });
        
        it('should throw error for invalid inputs', () => {
            expect(() => StringUtils.truncate(123 as any, 5)).toThrow('First argument must be a string');
            expect(() => StringUtils.truncate('Hello', -1)).toThrow('Max length must be a non-negative number');
        });
    });
    
    describe('slugify', () => {
        it('should convert strings to URL-friendly slugs', () => {
            expect(StringUtils.slugify('Hello World')).toBe('hello-world');
            expect(StringUtils.slugify('TypeScript is Awesome!')).toBe('typescript-is-awesome');
            expect(StringUtils.slugify('  Multiple   Spaces  ')).toBe('multiple-spaces');
        });
        
        it('should handle special characters', () => {
            expect(StringUtils.slugify('Hello@World#2023')).toBe('helloworld2023');
            expect(StringUtils.slugify('café & restaurant')).toBe('caf-restaurant');
        });
        
        it('should handle underscores and hyphens', () => {
            expect(StringUtils.slugify('hello_world-test')).toBe('hello-world-test');
            expect(StringUtils.slugify('___test___')).toBe('test');
        });
    });
});

9.2.2 类测试

// src/services/user.service.ts - 被测试的服务类
import { User, CreateUserRequest, UpdateUserRequest } from '../types/user';
import { ApiResponse } from '../types/api';

export class UserService {
    private baseUrl: string;
    
    constructor(baseUrl: string = '/api/users') {
        this.baseUrl = baseUrl;
    }
    
    async getUsers(): Promise<User[]> {
        const response = await fetch(this.baseUrl);
        
        if (!response.ok) {
            throw new Error(`Failed to fetch users: ${response.statusText}`);
        }
        
        const data: ApiResponse<User[]> = await response.json();
        
        if (!data.success) {
            throw new Error(data.error || 'Unknown error occurred');
        }
        
        return data.data || [];
    }
    
    async getUserById(id: string): Promise<User> {
        if (!id) {
            throw new Error('User ID is required');
        }
        
        const response = await fetch(`${this.baseUrl}/${id}`);
        
        if (!response.ok) {
            if (response.status === 404) {
                throw new Error('User not found');
            }
            throw new Error(`Failed to fetch user: ${response.statusText}`);
        }
        
        const data: ApiResponse<User> = await response.json();
        
        if (!data.success) {
            throw new Error(data.error || 'Unknown error occurred');
        }
        
        if (!data.data) {
            throw new Error('User data is missing');
        }
        
        return data.data;
    }
    
    async createUser(userData: CreateUserRequest): Promise<User> {
        this.validateUserData(userData);
        
        const response = await fetch(this.baseUrl, {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json'
            },
            body: JSON.stringify(userData)
        });
        
        if (!response.ok) {
            throw new Error(`Failed to create user: ${response.statusText}`);
        }
        
        const data: ApiResponse<User> = await response.json();
        
        if (!data.success) {
            throw new Error(data.error || 'Unknown error occurred');
        }
        
        if (!data.data) {
            throw new Error('Created user data is missing');
        }
        
        return data.data;
    }
    
    async updateUser(userData: UpdateUserRequest): Promise<User> {
        if (!userData.id) {
            throw new Error('User ID is required for update');
        }
        
        const response = await fetch(`${this.baseUrl}/${userData.id}`, {
            method: 'PUT',
            headers: {
                'Content-Type': 'application/json'
            },
            body: JSON.stringify(userData)
        });
        
        if (!response.ok) {
            throw new Error(`Failed to update user: ${response.statusText}`);
        }
        
        const data: ApiResponse<User> = await response.json();
        
        if (!data.success) {
            throw new Error(data.error || 'Unknown error occurred');
        }
        
        if (!data.data) {
            throw new Error('Updated user data is missing');
        }
        
        return data.data;
    }
    
    async deleteUser(id: string): Promise<void> {
        if (!id) {
            throw new Error('User ID is required');
        }
        
        const response = await fetch(`${this.baseUrl}/${id}`, {
            method: 'DELETE'
        });
        
        if (!response.ok) {
            throw new Error(`Failed to delete user: ${response.statusText}`);
        }
    }
    
    private validateUserData(userData: CreateUserRequest): void {
        if (!userData.name || userData.name.trim().length === 0) {
            throw new Error('User name is required');
        }
        
        if (!userData.email || !this.isValidEmail(userData.email)) {
            throw new Error('Valid email is required');
        }
    }
    
    private isValidEmail(email: string): boolean {
        const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
        return emailRegex.test(email);
    }
}

// src/services/__tests__/user.service.test.ts - 服务类测试
import { UserService } from '../user.service';
import { User, UserRole } from '../../types/user';
import { ApiResponse } from '../../types/api';

// 模拟 fetch
const mockFetch = global.fetch as jest.MockedFunction<typeof fetch>;

describe('UserService', () => {
    let userService: UserService;
    
    beforeEach(() => {
        userService = new UserService();
        mockFetch.mockClear();
    });
    
    describe('getUsers', () => {
        it('should fetch users successfully', async () => {
            const mockUsers: User[] = [
                {
                    id: '1',
                    name: 'John Doe',
                    email: 'john@example.com',
                    role: UserRole.USER,
                    createdAt: new Date(),
                    updatedAt: new Date()
                }
            ];
            
            const mockResponse: ApiResponse<User[]> = {
                success: true,
                data: mockUsers
            };
            
            mockFetch.mockResolvedValueOnce({
                ok: true,
                json: async () => mockResponse
            } as Response);
            
            const result = await userService.getUsers();
            
            expect(mockFetch).toHaveBeenCalledWith('/api/users');
            expect(result).toEqual(mockUsers);
        });
        
        it('should handle fetch error', async () => {
            mockFetch.mockResolvedValueOnce({
                ok: false,
                statusText: 'Internal Server Error'
            } as Response);
            
            await expect(userService.getUsers()).rejects.toThrow('Failed to fetch users: Internal Server Error');
        });
        
        it('should handle API error response', async () => {
            const mockResponse: ApiResponse<User[]> = {
                success: false,
                error: 'Database connection failed'
            };
            
            mockFetch.mockResolvedValueOnce({
                ok: true,
                json: async () => mockResponse
            } as Response);
            
            await expect(userService.getUsers()).rejects.toThrow('Database connection failed');
        });
        
        it('should return empty array when no data', async () => {
            const mockResponse: ApiResponse<User[]> = {
                success: true
            };
            
            mockFetch.mockResolvedValueOnce({
                ok: true,
                json: async () => mockResponse
            } as Response);
            
            const result = await userService.getUsers();
            expect(result).toEqual([]);
        });
    });
    
    describe('getUserById', () => {
        const mockUser: User = {
            id: '1',
            name: 'John Doe',
            email: 'john@example.com',
            role: UserRole.USER,
            createdAt: new Date(),
            updatedAt: new Date()
        };
        
        it('should fetch user by id successfully', async () => {
            const mockResponse: ApiResponse<User> = {
                success: true,
                data: mockUser
            };
            
            mockFetch.mockResolvedValueOnce({
                ok: true,
                json: async () => mockResponse
            } as Response);
            
            const result = await userService.getUserById('1');
            
            expect(mockFetch).toHaveBeenCalledWith('/api/users/1');
            expect(result).toEqual(mockUser);
        });
        
        it('should throw error for empty id', async () => {
            await expect(userService.getUserById('')).rejects.toThrow('User ID is required');
            expect(mockFetch).not.toHaveBeenCalled();
        });
        
        it('should handle 404 error', async () => {
            mockFetch.mockResolvedValueOnce({
                ok: false,
                status: 404,
                statusText: 'Not Found'
            } as Response);
            
            await expect(userService.getUserById('999')).rejects.toThrow('User not found');
        });
        
        it('should throw error when user data is missing', async () => {
            const mockResponse: ApiResponse<User> = {
                success: true
            };
            
            mockFetch.mockResolvedValueOnce({
                ok: true,
                json: async () => mockResponse
            } as Response);
            
            await expect(userService.getUserById('1')).rejects.toThrow('User data is missing');
        });
    });
    
    describe('createUser', () => {
        const validUserData = {
            name: 'Jane Doe',
            email: 'jane@example.com',
            role: UserRole.USER
        };
        
        const createdUser: User = {
            id: '2',
            ...validUserData,
            createdAt: new Date(),
            updatedAt: new Date()
        };
        
        it('should create user successfully', async () => {
            const mockResponse: ApiResponse<User> = {
                success: true,
                data: createdUser
            };
            
            mockFetch.mockResolvedValueOnce({
                ok: true,
                json: async () => mockResponse
            } as Response);
            
            const result = await userService.createUser(validUserData);
            
            expect(mockFetch).toHaveBeenCalledWith('/api/users', {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json'
                },
                body: JSON.stringify(validUserData)
            });
            expect(result).toEqual(createdUser);
        });
        
        it('should validate user data before creating', async () => {
            const invalidUserData = {
                name: '',
                email: 'invalid-email'
            };
            
            await expect(userService.createUser(invalidUserData as any)).rejects.toThrow('User name is required');
            expect(mockFetch).not.toHaveBeenCalled();
        });
        
        it('should validate email format', async () => {
            const invalidEmailData = {
                name: 'John Doe',
                email: 'invalid-email'
            };
            
            await expect(userService.createUser(invalidEmailData as any)).rejects.toThrow('Valid email is required');
        });
    });
    
    describe('updateUser', () => {
        const updateData = {
            id: '1',
            name: 'Updated Name'
        };
        
        const updatedUser: User = {
            id: '1',
            name: 'Updated Name',
            email: 'john@example.com',
            role: UserRole.USER,
            createdAt: new Date(),
            updatedAt: new Date()
        };
        
        it('should update user successfully', async () => {
            const mockResponse: ApiResponse<User> = {
                success: true,
                data: updatedUser
            };
            
            mockFetch.mockResolvedValueOnce({
                ok: true,
                json: async () => mockResponse
            } as Response);
            
            const result = await userService.updateUser(updateData);
            
            expect(mockFetch).toHaveBeenCalledWith('/api/users/1', {
                method: 'PUT',
                headers: {
                    'Content-Type': 'application/json'
                },
                body: JSON.stringify(updateData)
            });
            expect(result).toEqual(updatedUser);
        });
        
        it('should throw error when id is missing', async () => {
            const dataWithoutId = { name: 'Updated Name' };
            
            await expect(userService.updateUser(dataWithoutId as any)).rejects.toThrow('User ID is required for update');
            expect(mockFetch).not.toHaveBeenCalled();
        });
    });
    
    describe('deleteUser', () => {
        it('should delete user successfully', async () => {
            mockFetch.mockResolvedValueOnce({
                ok: true
            } as Response);
            
            await userService.deleteUser('1');
            
            expect(mockFetch).toHaveBeenCalledWith('/api/users/1', {
                method: 'DELETE'
            });
        });
        
        it('should throw error for empty id', async () => {
            await expect(userService.deleteUser('')).rejects.toThrow('User ID is required');
            expect(mockFetch).not.toHaveBeenCalled();
        });
        
        it('should handle delete error', async () => {
            mockFetch.mockResolvedValueOnce({
                ok: false,
                statusText: 'Forbidden'
            } as Response);
            
            await expect(userService.deleteUser('1')).rejects.toThrow('Failed to delete user: Forbidden');
        });
    });
});

9.3 集成测试

9.3.1 API 集成测试

// src/mocks/handlers.ts - MSW 处理器
import { rest } from 'msw';
import { User, UserRole } from '../types/user';
import { ApiResponse } from '../types/api';

// 模拟数据
let users: User[] = [
    {
        id: '1',
        name: 'John Doe',
        email: 'john@example.com',
        role: UserRole.USER,
        createdAt: new Date('2023-01-01'),
        updatedAt: new Date('2023-01-01')
    },
    {
        id: '2',
        name: 'Jane Smith',
        email: 'jane@example.com',
        role: UserRole.ADMIN,
        createdAt: new Date('2023-01-02'),
        updatedAt: new Date('2023-01-02')
    }
];

export const handlers = [
    // 获取用户列表
    rest.get('/api/users', (req, res, ctx) => {
        const response: ApiResponse<User[]> = {
            success: true,
            data: users
        };
        
        return res(ctx.json(response));
    }),
    
    // 获取单个用户
    rest.get('/api/users/:id', (req, res, ctx) => {
        const { id } = req.params;
        const user = users.find(u => u.id === id);
        
        if (!user) {
            return res(
                ctx.status(404),
                ctx.json({
                    success: false,
                    error: 'User not found'
                })
            );
        }
        
        const response: ApiResponse<User> = {
            success: true,
            data: user
        };
        
        return res(ctx.json(response));
    }),
    
    // 创建用户
    rest.post('/api/users', async (req, res, ctx) => {
        const userData = await req.json();
        
        // 验证邮箱唯一性
        const existingUser = users.find(u => u.email === userData.email);
        if (existingUser) {
            return res(
                ctx.status(400),
                ctx.json({
                    success: false,
                    error: 'Email already exists'
                })
            );
        }
        
        const newUser: User = {
            id: String(users.length + 1),
            name: userData.name,
            email: userData.email,
            role: userData.role || UserRole.USER,
            createdAt: new Date(),
            updatedAt: new Date()
        };
        
        users.push(newUser);
        
        const response: ApiResponse<User> = {
            success: true,
            data: newUser
        };
        
        return res(ctx.status(201), ctx.json(response));
    }),
    
    // 更新用户
    rest.put('/api/users/:id', async (req, res, ctx) => {
        const { id } = req.params;
        const updateData = await req.json();
        
        const userIndex = users.findIndex(u => u.id === id);
        if (userIndex === -1) {
            return res(
                ctx.status(404),
                ctx.json({
                    success: false,
                    error: 'User not found'
                })
            );
        }
        
        const updatedUser = {
            ...users[userIndex],
            ...updateData,
            updatedAt: new Date()
        };
        
        users[userIndex] = updatedUser;
        
        const response: ApiResponse<User> = {
            success: true,
            data: updatedUser
        };
        
        return res(ctx.json(response));
    }),
    
    // 删除用户
    rest.delete('/api/users/:id', (req, res, ctx) => {
        const { id } = req.params;
        const userIndex = users.findIndex(u => u.id === id);
        
        if (userIndex === -1) {
            return res(
                ctx.status(404),
                ctx.json({
                    success: false,
                    error: 'User not found'
                })
            );
        }
        
        users.splice(userIndex, 1);
        
        return res(
            ctx.status(204),
            ctx.json({ success: true })
        );
    }),
    
    // 模拟网络错误
    rest.get('/api/users/error', (req, res, ctx) => {
        return res.networkError('Network error');
    }),
    
    // 模拟服务器错误
    rest.get('/api/users/server-error', (req, res, ctx) => {
        return res(
            ctx.status(500),
            ctx.json({
                success: false,
                error: 'Internal server error'
            })
        );
    })
];

// src/mocks/server.ts - MSW 服务器配置
import { setupServer } from 'msw/node';
import { handlers } from './handlers';

export const server = setupServer(...handlers);

// src/__tests__/integration/user-api.test.ts - API 集成测试
import { UserService } from '../../services/user.service';
import { UserRole } from '../../types/user';
import { server } from '../../mocks/server';
import { rest } from 'msw';

describe('User API Integration', () => {
    let userService: UserService;
    
    beforeEach(() => {
        userService = new UserService();
    });
    
    describe('User CRUD Operations', () => {
        it('should perform complete user lifecycle', async () => {
            // 1. 获取初始用户列表
            const initialUsers = await userService.getUsers();
            expect(initialUsers).toHaveLength(2);
            
            // 2. 创建新用户
            const newUserData = {
                name: 'Test User',
                email: 'test@example.com',
                role: UserRole.USER
            };
            
            const createdUser = await userService.createUser(newUserData);
            expect(createdUser).toMatchObject(newUserData);
            expect(createdUser.id).toBeDefined();
            
            // 3. 验证用户已创建
            const usersAfterCreate = await userService.getUsers();
            expect(usersAfterCreate).toHaveLength(3);
            
            // 4. 获取创建的用户
            const fetchedUser = await userService.getUserById(createdUser.id);
            expect(fetchedUser).toEqual(createdUser);
            
            // 5. 更新用户
            const updateData = {
                id: createdUser.id,
                name: 'Updated Test User'
            };
            
            const updatedUser = await userService.updateUser(updateData);
            expect(updatedUser.name).toBe('Updated Test User');
            expect(updatedUser.email).toBe(newUserData.email);
            
            // 6. 删除用户
            await userService.deleteUser(createdUser.id);
            
            // 7. 验证用户已删除
            await expect(userService.getUserById(createdUser.id))
                .rejects.toThrow('User not found');
            
            const finalUsers = await userService.getUsers();
            expect(finalUsers).toHaveLength(2);
        });
        
        it('should handle duplicate email creation', async () => {
            const duplicateEmailData = {
                name: 'Duplicate User',
                email: 'john@example.com' // 已存在的邮箱
            };
            
            await expect(userService.createUser(duplicateEmailData as any))
                .rejects.toThrow('Email already exists');
        });
        
        it('should handle non-existent user operations', async () => {
            const nonExistentId = '999';
            
            // 获取不存在的用户
            await expect(userService.getUserById(nonExistentId))
                .rejects.toThrow('User not found');
            
            // 更新不存在的用户
            await expect(userService.updateUser({ id: nonExistentId, name: 'Test' }))
                .rejects.toThrow('User not found');
            
            // 删除不存在的用户
            await expect(userService.deleteUser(nonExistentId))
                .rejects.toThrow('User not found');
        });
    });
    
    describe('Error Handling', () => {
        it('should handle network errors', async () => {
            // 临时覆盖处理器以模拟网络错误
            server.use(
                rest.get('/api/users', (req, res, ctx) => {
                    return res.networkError('Network connection failed');
                })
            );
            
            await expect(userService.getUsers())
                .rejects.toThrow('fetch');
        });
        
        it('should handle server errors', async () => {
            // 临时覆盖处理器以模拟服务器错误
            server.use(
                rest.get('/api/users', (req, res, ctx) => {
                    return res(
                        ctx.status(500),
                        ctx.json({
                            success: false,
                            error: 'Internal server error'
                        })
                    );
                })
            );
            
            await expect(userService.getUsers())
                .rejects.toThrow('Internal server error');
        });
        
        it('should handle malformed responses', async () => {
            // 临时覆盖处理器以返回格式错误的响应
            server.use(
                rest.get('/api/users', (req, res, ctx) => {
                    return res(ctx.text('Invalid JSON'));
                })
            );
            
            await expect(userService.getUsers())
                .rejects.toThrow();
        });
    });
    
    describe('Concurrent Operations', () => {
        it('should handle concurrent user creation', async () => {
            const userPromises = Array.from({ length: 5 }, (_, index) => 
                userService.createUser({
                    name: `Concurrent User ${index}`,
                    email: `concurrent${index}@example.com`
                })
            );
            
            const createdUsers = await Promise.all(userPromises);
            
            expect(createdUsers).toHaveLength(5);
            createdUsers.forEach((user, index) => {
                expect(user.name).toBe(`Concurrent User ${index}`);
                expect(user.email).toBe(`concurrent${index}@example.com`);
            });
            
            // 验证所有用户都已创建
            const allUsers = await userService.getUsers();
            expect(allUsers).toHaveLength(7); // 2 初始 + 5 新创建
        });
        
        it('should handle concurrent read operations', async () => {
            const readPromises = Array.from({ length: 10 }, () => 
                userService.getUsers()
            );
            
            const results = await Promise.all(readPromises);
            
            // 所有结果应该相同
            results.forEach(users => {
                expect(users).toHaveLength(2);
                expect(users[0].name).toBe('John Doe');
                expect(users[1].name).toBe('Jane Smith');
            });
        });
    });
});

9.3.2 组件集成测试

// src/components/UserList.tsx - 用户列表组件
import React, { useState, useEffect } from 'react';
import { User } from '../types/user';
import { UserService } from '../services/user.service';

interface UserListProps {
    userService?: UserService;
    onUserSelect?: (user: User) => void;
    onError?: (error: string) => void;
}

export const UserList: React.FC<UserListProps> = ({
    userService = new UserService(),
    onUserSelect,
    onError
}) => {
    const [users, setUsers] = useState<User[]>([]);
    const [loading, setLoading] = useState(true);
    const [error, setError] = useState<string | null>(null);
    
    useEffect(() => {
        loadUsers();
    }, []);
    
    const loadUsers = async () => {
        try {
            setLoading(true);
            setError(null);
            const fetchedUsers = await userService.getUsers();
            setUsers(fetchedUsers);
        } catch (err) {
            const errorMessage = err instanceof Error ? err.message : 'Failed to load users';
            setError(errorMessage);
            onError?.(errorMessage);
        } finally {
            setLoading(false);
        }
    };
    
    const handleUserClick = (user: User) => {
        onUserSelect?.(user);
    };
    
    const handleRefresh = () => {
        loadUsers();
    };
    
    if (loading) {
        return (
            <div data-testid="user-list-loading">
                Loading users...
            </div>
        );
    }
    
    if (error) {
        return (
            <div data-testid="user-list-error">
                <p>Error: {error}</p>
                <button 
                    onClick={handleRefresh}
                    data-testid="retry-button"
                >
                    Retry
                </button>
            </div>
        );
    }
    
    if (users.length === 0) {
        return (
            <div data-testid="user-list-empty">
                No users found
            </div>
        );
    }
    
    return (
        <div data-testid="user-list">
            <div className="user-list-header">
                <h2>Users ({users.length})</h2>
                <button 
                    onClick={handleRefresh}
                    data-testid="refresh-button"
                >
                    Refresh
                </button>
            </div>
            
            <ul className="user-list-items">
                {users.map(user => (
                    <li 
                        key={user.id}
                        className="user-list-item"
                        data-testid={`user-item-${user.id}`}
                        onClick={() => handleUserClick(user)}
                    >
                        <div className="user-info">
                            <h3>{user.name}</h3>
                            <p>{user.email}</p>
                            <span className="user-role">{user.role}</span>
                        </div>
                    </li>
                ))}
            </ul>
        </div>
    );
};

// src/components/__tests__/UserList.integration.test.tsx - 组件集成测试
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { UserList } from '../UserList';
import { UserService } from '../../services/user.service';
import { User, UserRole } from '../../types/user';

// 创建模拟的 UserService
const createMockUserService = () => {
    const mockUsers: User[] = [
        {
            id: '1',
            name: 'John Doe',
            email: 'john@example.com',
            role: UserRole.USER,
            createdAt: new Date(),
            updatedAt: new Date()
        },
        {
            id: '2',
            name: 'Jane Smith',
            email: 'jane@example.com',
            role: UserRole.ADMIN,
            createdAt: new Date(),
            updatedAt: new Date()
        }
    ];
    
    return {
        getUsers: jest.fn().mockResolvedValue(mockUsers),
        getUserById: jest.fn(),
        createUser: jest.fn(),
        updateUser: jest.fn(),
        deleteUser: jest.fn()
    } as unknown as jest.Mocked<UserService>;
};

describe('UserList Integration', () => {
    let mockUserService: jest.Mocked<UserService>;
    let mockOnUserSelect: jest.Mock;
    let mockOnError: jest.Mock;
    
    beforeEach(() => {
        mockUserService = createMockUserService();
        mockOnUserSelect = jest.fn();
        mockOnError = jest.fn();
    });
    
    describe('Successful Data Loading', () => {
        it('should load and display users on mount', async () => {
            render(
                <UserList 
                    userService={mockUserService}
                    onUserSelect={mockOnUserSelect}
                    onError={mockOnError}
                />
            );
            
            // 应该显示加载状态
            expect(screen.getByTestId('user-list-loading')).toBeInTheDocument();
            
            // 等待用户加载完成
            await waitFor(() => {
                expect(screen.getByTestId('user-list')).toBeInTheDocument();
            });
            
            // 验证用户服务被调用
            expect(mockUserService.getUsers).toHaveBeenCalledTimes(1);
            
            // 验证用户列表显示
            expect(screen.getByText('Users (2)')).toBeInTheDocument();
            expect(screen.getByTestId('user-item-1')).toBeInTheDocument();
            expect(screen.getByTestId('user-item-2')).toBeInTheDocument();
            
            // 验证用户信息显示
            expect(screen.getByText('John Doe')).toBeInTheDocument();
            expect(screen.getByText('john@example.com')).toBeInTheDocument();
            expect(screen.getByText('Jane Smith')).toBeInTheDocument();
            expect(screen.getByText('jane@example.com')).toBeInTheDocument();
            
            // 验证没有调用错误回调
            expect(mockOnError).not.toHaveBeenCalled();
        });
        
        it('should handle user selection', async () => {
            render(
                <UserList 
                    userService={mockUserService}
                    onUserSelect={mockOnUserSelect}
                />
            );
            
            // 等待用户加载完成
            await waitFor(() => {
                expect(screen.getByTestId('user-list')).toBeInTheDocument();
            });
            
            // 点击第一个用户
            fireEvent.click(screen.getByTestId('user-item-1'));
            
            // 验证用户选择回调被调用
            expect(mockOnUserSelect).toHaveBeenCalledTimes(1);
            expect(mockOnUserSelect).toHaveBeenCalledWith({
                id: '1',
                name: 'John Doe',
                email: 'john@example.com',
                role: UserRole.USER,
                createdAt: expect.any(Date),
                updatedAt: expect.any(Date)
            });
        });
        
        it('should handle refresh functionality', async () => {
            render(
                <UserList 
                    userService={mockUserService}
                />
            );
            
            // 等待初始加载完成
            await waitFor(() => {
                expect(screen.getByTestId('user-list')).toBeInTheDocument();
            });
            
            // 点击刷新按钮
            fireEvent.click(screen.getByTestId('refresh-button'));
            
            // 验证用户服务被再次调用
            await waitFor(() => {
                expect(mockUserService.getUsers).toHaveBeenCalledTimes(2);
            });
        });
    });
    
    describe('Error Handling', () => {
        it('should handle loading errors', async () => {
            const errorMessage = 'Failed to fetch users';
            mockUserService.getUsers.mockRejectedValue(new Error(errorMessage));
            
            render(
                <UserList 
                    userService={mockUserService}
                    onError={mockOnError}
                />
            );
            
            // 等待错误状态显示
            await waitFor(() => {
                expect(screen.getByTestId('user-list-error')).toBeInTheDocument();
            });
            
            // 验证错误信息显示
            expect(screen.getByText(`Error: ${errorMessage}`)).toBeInTheDocument();
            expect(screen.getByTestId('retry-button')).toBeInTheDocument();
            
            // 验证错误回调被调用
            expect(mockOnError).toHaveBeenCalledWith(errorMessage);
        });
        
        it('should handle retry after error', async () => {
            const errorMessage = 'Network error';
            
            // 第一次调用失败
            mockUserService.getUsers
                .mockRejectedValueOnce(new Error(errorMessage))
                .mockResolvedValueOnce([]);
            
            render(
                <UserList 
                    userService={mockUserService}
                />
            );
            
            // 等待错误状态显示
            await waitFor(() => {
                expect(screen.getByTestId('user-list-error')).toBeInTheDocument();
            });
            
            // 点击重试按钮
            fireEvent.click(screen.getByTestId('retry-button'));
            
            // 等待重试完成
            await waitFor(() => {
                expect(screen.getByTestId('user-list-empty')).toBeInTheDocument();
            });
            
            // 验证用户服务被调用两次
            expect(mockUserService.getUsers).toHaveBeenCalledTimes(2);
        });
    });
    
    describe('Empty State', () => {
        it('should display empty state when no users', async () => {
            mockUserService.getUsers.mockResolvedValue([]);
            
            render(
                <UserList 
                    userService={mockUserService}
                />
            );
            
            // 等待空状态显示
            await waitFor(() => {
                expect(screen.getByTestId('user-list-empty')).toBeInTheDocument();
            });
            
            expect(screen.getByText('No users found')).toBeInTheDocument();
        });
    });
    
    describe('Loading States', () => {
        it('should show loading state during data fetch', async () => {
            // 创建一个永不解决的 Promise 来模拟长时间加载
            const neverResolvingPromise = new Promise(() => {});
            mockUserService.getUsers.mockReturnValue(neverResolvingPromise as any);
            
            render(
                <UserList 
                    userService={mockUserService}
                />
            );
            
            // 验证加载状态显示
            expect(screen.getByTestId('user-list-loading')).toBeInTheDocument();
            expect(screen.getByText('Loading users...')).toBeInTheDocument();
            
            // 验证其他状态不显示
            expect(screen.queryByTestId('user-list')).not.toBeInTheDocument();
            expect(screen.queryByTestId('user-list-error')).not.toBeInTheDocument();
            expect(screen.queryByTestId('user-list-empty')).not.toBeInTheDocument();
        });
        
        it('should show loading state during refresh', async () => {
            render(
                <UserList 
                    userService={mockUserService}
                />
            );
            
            // 等待初始加载完成
            await waitFor(() => {
                expect(screen.getByTestId('user-list')).toBeInTheDocument();
            });
            
            // 模拟刷新时的延迟
            const delayedPromise = new Promise(resolve => 
                setTimeout(() => resolve([]), 100)
            );
            mockUserService.getUsers.mockReturnValue(delayedPromise as any);
            
            // 点击刷新
            fireEvent.click(screen.getByTestId('refresh-button'));
            
            // 验证加载状态显示
            expect(screen.getByTestId('user-list-loading')).toBeInTheDocument();
            
            // 等待刷新完成
            await waitFor(() => {
                expect(screen.getByTestId('user-list-empty')).toBeInTheDocument();
            });
        });
    });
});

9.4 端到端测试

9.4.1 Playwright 配置

”`typescript // playwright.config.ts import { defineConfig, devices } from ‘@playwright/