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/