|
@@ -0,0 +1,740 @@
|
|
|
|
|
+# 后端模块包测试规范
|
|
|
|
|
+
|
|
|
|
|
+## 版本信息
|
|
|
|
|
+| 版本 | 日期 | 描述 | 作者 |
|
|
|
|
|
+|------|------|------|------|
|
|
|
|
|
+| 1.0 | 2025-12-26 | 从测试策略文档拆分,专注后端模块包测试 | James (Claude Code) |
|
|
|
|
|
+
|
|
|
|
|
+## 概述
|
|
|
|
|
+
|
|
|
|
|
+本文档定义了后端模块包的测试标准和最佳实践。
|
|
|
|
|
+- **目标**: `packages/*-module` - 业务模块包(user-module、auth-module、file-module等)
|
|
|
|
|
+- **测试类型**: 单元测试 + 集成测试
|
|
|
|
|
+
|
|
|
|
|
+## 测试框架栈
|
|
|
|
|
+
|
|
|
|
|
+- **Vitest**: 测试运行器
|
|
|
|
|
+- **hono/testing**: Hono路由测试
|
|
|
|
|
+- **TypeORM**: 数据库测试
|
|
|
|
|
+- **PostgreSQL**: 测试数据库
|
|
|
|
|
+- **shared-test-util**: 共享测试基础设施
|
|
|
|
|
+
|
|
|
|
|
+## 测试分层策略
|
|
|
|
|
+
|
|
|
|
|
+### 1. 单元测试(Unit Tests)
|
|
|
|
|
+- **范围**: 单个服务、工具函数、Schema验证
|
|
|
|
|
+- **目标**: 验证独立单元的正确性
|
|
|
|
|
+- **位置**: `packages/*-module/tests/unit/**/*.test.ts`
|
|
|
|
|
+- **框架**: Vitest
|
|
|
|
|
+- **覆盖率目标**: ≥ 80%
|
|
|
|
|
+
|
|
|
|
|
+### 2. 集成测试(Integration Tests)
|
|
|
|
|
+- **范围**: 路由、服务、数据库集成
|
|
|
|
|
+- **目标**: 验证模块内部组件协作
|
|
|
|
|
+- **位置**: `packages/*-module/tests/integration/**/*.test.ts`
|
|
|
|
|
+- **框架**: Vitest + hono/testing + TypeORM
|
|
|
|
|
+- **覆盖率目标**: ≥ 60%
|
|
|
|
|
+
|
|
|
|
|
+## 测试文件结构
|
|
|
|
|
+
|
|
|
|
|
+```
|
|
|
|
|
+packages/user-module/
|
|
|
|
|
+├── src/
|
|
|
|
|
+│ ├── entities/
|
|
|
|
|
+│ │ ├── user.entity.ts
|
|
|
|
|
+│ │ └── role.entity.ts
|
|
|
|
|
+│ ├── services/
|
|
|
|
|
+│ │ ├── user.service.ts
|
|
|
|
|
+│ │ └── role.service.ts
|
|
|
|
|
+│ ├── schemas/
|
|
|
|
|
+│ │ ├── user.schema.ts
|
|
|
|
|
+│ │ └── role.schema.ts
|
|
|
|
|
+│ └── routes/
|
|
|
|
|
+│ ├── user.routes.ts
|
|
|
|
|
+│ └── role.routes.ts
|
|
|
|
|
+└── tests/
|
|
|
|
|
+ ├── unit/
|
|
|
|
|
+ │ ├── services/
|
|
|
|
|
+ │ │ ├── user.service.test.ts
|
|
|
|
|
+ │ │ └── role.service.test.ts
|
|
|
|
|
+ │ └── schemas/
|
|
|
|
|
+ │ ├── user.schema.test.ts
|
|
|
|
|
+ │ └── role.schema.test.ts
|
|
|
|
|
+ ├── integration/
|
|
|
|
|
+ │ ├── routes/
|
|
|
|
|
+ │ │ ├── user.routes.integration.test.ts
|
|
|
|
|
+ │ │ └── role.routes.integration.test.ts
|
|
|
|
|
+ │ └── services/
|
|
|
|
|
+ │ └── user.service.integration.test.ts
|
|
|
|
|
+ └── fixtures/
|
|
|
|
|
+ ├── test-db.ts # 测试数据库配置
|
|
|
|
|
+ └── factories.ts # 测试数据工厂
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+## 单元测试最佳实践
|
|
|
|
|
+
|
|
|
|
|
+### 1. 测试Service层
|
|
|
|
|
+```typescript
|
|
|
|
|
+import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
|
|
|
+import { UserService } from '../src/services/user.service';
|
|
|
|
|
+import { User } from '../src/entities/user.entity';
|
|
|
|
|
+import { Repository } from 'typeorm';
|
|
|
|
|
+
|
|
|
|
|
+describe('UserService', () => {
|
|
|
|
|
+ let userService: UserService;
|
|
|
|
|
+ let mockUserRepo: Partial<Repository<User>>;
|
|
|
|
|
+
|
|
|
|
|
+ beforeEach(() => {
|
|
|
|
|
+ // 模拟Repository
|
|
|
|
|
+ mockUserRepo = {
|
|
|
|
|
+ findOne: vi.fn(),
|
|
|
|
|
+ find: vi.fn(),
|
|
|
|
|
+ create: vi.fn(),
|
|
|
|
|
+ save: vi.fn(),
|
|
|
|
|
+ update: vi.fn(),
|
|
|
|
|
+ delete: vi.fn()
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ userService = new UserService(mockUserRepo as Repository<User>);
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ describe('findById()', () => {
|
|
|
|
|
+ it('应该返回用户数据', async () => {
|
|
|
|
|
+ // Arrange
|
|
|
|
|
+ const mockUser = { id: 1, username: 'testuser', email: 'test@example.com' };
|
|
|
|
|
+ mockUserRepo.findOne?.mockResolvedValue(mockUser);
|
|
|
|
|
+
|
|
|
|
|
+ // Act
|
|
|
|
|
+ const result = await userService.findById(1);
|
|
|
|
|
+
|
|
|
|
|
+ // Assert
|
|
|
|
|
+ expect(result).toEqual(mockUser);
|
|
|
|
|
+ expect(mockUserRepo.findOne).toHaveBeenCalledWith({
|
|
|
|
|
+ where: { id: 1 }
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it('应该返回null当用户不存在', async () => {
|
|
|
|
|
+ mockUserRepo.findOne?.mockResolvedValue(null);
|
|
|
|
|
+
|
|
|
|
|
+ const result = await userService.findById(999);
|
|
|
|
|
+
|
|
|
|
|
+ expect(result).toBeNull();
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ describe('createUser()', () => {
|
|
|
|
|
+ it('应该创建新用户', async () => {
|
|
|
|
|
+ const userData = {
|
|
|
|
|
+ username: 'newuser',
|
|
|
|
|
+ email: 'new@example.com',
|
|
|
|
|
+ password: 'hashedpassword'
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const createdUser = { id: 1, ...userData };
|
|
|
|
|
+ mockUserRepo.create?.mockReturnValue(createdUser);
|
|
|
|
|
+ mockUserRepo.save?.mockResolvedValue(createdUser);
|
|
|
|
|
+
|
|
|
|
|
+ const result = await userService.createUser(userData);
|
|
|
|
|
+
|
|
|
|
|
+ expect(result).toEqual(createdUser);
|
|
|
|
|
+ expect(mockUserRepo.create).toHaveBeenCalledWith(userData);
|
|
|
|
|
+ expect(mockUserRepo.save).toHaveBeenCalledWith(createdUser);
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it('应该抛出错误当用户名已存在', async () => {
|
|
|
|
|
+ mockUserRepo.findOne?.mockResolvedValue({ id: 1, username: 'existing' });
|
|
|
|
|
+
|
|
|
|
|
+ await expect(
|
|
|
|
|
+ userService.createUser({ username: 'existing', email: 'test@example.com', password: 'hash' })
|
|
|
|
|
+ ).rejects.toThrow('用户名已存在');
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+});
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### 2. 测试Schema验证
|
|
|
|
|
+```typescript
|
|
|
|
|
+import { describe, it, expect } from 'vitest';
|
|
|
|
|
+import { userSchema } from '../src/schemas/user.schema';
|
|
|
|
|
+
|
|
|
|
|
+describe('userSchema', () => {
|
|
|
|
|
+ it('应该验证有效的用户数据', async () => {
|
|
|
|
|
+ const validData = {
|
|
|
|
|
+ username: 'testuser',
|
|
|
|
|
+ email: 'test@example.com',
|
|
|
|
|
+ password: 'password123'
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const result = await userSchema.parseAsync(validData);
|
|
|
|
|
+
|
|
|
|
|
+ expect(result).toEqual(validData);
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it('应该拒绝无效的邮箱格式', async () => {
|
|
|
|
|
+ const invalidData = {
|
|
|
|
|
+ username: 'testuser',
|
|
|
|
|
+ email: 'invalid-email',
|
|
|
|
|
+ password: 'password123'
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ await expect(userSchema.parseAsync(invalidData)).rejects.toThrow();
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it('应该拒绝空密码', async () => {
|
|
|
|
|
+ const invalidData = {
|
|
|
|
|
+ username: 'testuser',
|
|
|
|
|
+ email: 'test@example.com',
|
|
|
|
|
+ password: ''
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ await expect(userSchema.parseAsync(invalidData)).rejects.toThrow();
|
|
|
|
|
+ });
|
|
|
|
|
+});
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### 3. 测试工具函数
|
|
|
|
|
+```typescript
|
|
|
|
|
+import { describe, it, expect } from 'vitest';
|
|
|
|
|
+import { hashPassword, comparePassword } from '../src/utils/password.util';
|
|
|
|
|
+
|
|
|
|
|
+describe('密码工具函数', () => {
|
|
|
|
|
+ describe('hashPassword()', () => {
|
|
|
|
|
+ it('应该返回哈希后的密码', async () => {
|
|
|
|
|
+ const password = 'password123';
|
|
|
|
|
+ const hash = await hashPassword(password);
|
|
|
|
|
+
|
|
|
|
|
+ expect(hash).not.toBe(password);
|
|
|
|
|
+ expect(hash).toMatch(/^\$2[ayb]\$.{56}$/); // bcrypt格式
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it('应该为相同密码生成不同的哈希值', async () => {
|
|
|
|
|
+ const password = 'password123';
|
|
|
|
|
+ const hash1 = await hashPassword(password);
|
|
|
|
|
+ const hash2 = await hashPassword(password);
|
|
|
|
|
+
|
|
|
|
|
+ expect(hash1).not.toBe(hash2);
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ describe('comparePassword()', () => {
|
|
|
|
|
+ it('应该返回true当密码匹配', async () => {
|
|
|
|
|
+ const password = 'password123';
|
|
|
|
|
+ const hash = await hashPassword(password);
|
|
|
|
|
+
|
|
|
|
|
+ const result = await comparePassword(password, hash);
|
|
|
|
|
+
|
|
|
|
|
+ expect(result).toBe(true);
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it('应该返回false当密码不匹配', async () => {
|
|
|
|
|
+ const hash = await hashPassword('password123');
|
|
|
|
|
+
|
|
|
|
|
+ const result = await comparePassword('wrongpassword', hash);
|
|
|
|
|
+
|
|
|
|
|
+ expect(result).toBe(false);
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+});
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+## 集成测试最佳实践
|
|
|
|
|
+
|
|
|
|
|
+### 1. 测试路由层
|
|
|
|
|
+```typescript
|
|
|
|
|
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
|
|
|
+import { integrateRoutes } from 'hono/testing';
|
|
|
|
|
+import { Hono } from 'hono';
|
|
|
|
|
+import { getTestDataSource } from './fixtures/test-db';
|
|
|
|
|
+import { userRoutes } from '../src/routes/user.routes';
|
|
|
|
|
+import { seedTestUser } from './fixtures/factories';
|
|
|
|
|
+
|
|
|
|
|
+describe('用户路由集成测试', () => {
|
|
|
|
|
+ let dataSource: DataSource;
|
|
|
|
|
+ let app: Hono;
|
|
|
|
|
+
|
|
|
|
|
+ beforeEach(async () => {
|
|
|
|
|
+ dataSource = await getTestDataSource();
|
|
|
|
|
+ await dataSource.initialize();
|
|
|
|
|
+
|
|
|
|
|
+ app = new Hono();
|
|
|
|
|
+ app.route('/api/users', userRoutes);
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ afterEach(async () => {
|
|
|
|
|
+ await dataSource.destroy();
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ describe('GET /api/users', () => {
|
|
|
|
|
+ it('应该返回用户列表', async () => {
|
|
|
|
|
+ // Arrange
|
|
|
|
|
+ await seedTestUser(dataSource, { username: 'user1' });
|
|
|
|
|
+ await seedTestUser(dataSource, { username: 'user2' });
|
|
|
|
|
+
|
|
|
|
|
+ // Act
|
|
|
|
|
+ const res = await integrateRoutes(app).GET('/api/users');
|
|
|
|
|
+
|
|
|
|
|
+ // Assert
|
|
|
|
|
+ expect(res.status).toBe(200);
|
|
|
|
|
+ const json = await res.json();
|
|
|
|
|
+ expect(json.data).toHaveLength(2);
|
|
|
|
|
+ expect(json.data[0].username).toBe('user1');
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it('应该支持分页查询', async () => {
|
|
|
|
|
+ // 创建15个用户
|
|
|
|
|
+ for (let i = 1; i <= 15; i++) {
|
|
|
|
|
+ await seedTestUser(dataSource, { username: `user${i}` });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 查询第一页
|
|
|
|
|
+ const res1 = await integrateRoutes(app).GET('/api/users?page=1&pageSize=10');
|
|
|
|
|
+ expect(res1.status).toBe(200);
|
|
|
|
|
+ const json1 = await res1.json();
|
|
|
|
|
+ expect(json1.data).toHaveLength(10);
|
|
|
|
|
+ expect(json1.total).toBe(15);
|
|
|
|
|
+
|
|
|
|
|
+ // 查询第二页
|
|
|
|
|
+ const res2 = await integrateRoutes(app).GET('/api/users?page=2&pageSize=10');
|
|
|
|
|
+ expect(res2.status).toBe(200);
|
|
|
|
|
+ const json2 = await res2.json();
|
|
|
|
|
+ expect(json2.data).toHaveLength(5);
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ describe('GET /api/users/:id', () => {
|
|
|
|
|
+ it('应该返回用户详情', async () => {
|
|
|
|
|
+ const user = await seedTestUser(dataSource, { username: 'testuser' });
|
|
|
|
|
+
|
|
|
|
|
+ const res = await integrateRoutes(app).GET(`/api/users/${user.id}`);
|
|
|
|
|
+
|
|
|
|
|
+ expect(res.status).toBe(200);
|
|
|
|
|
+ const json = await res.json();
|
|
|
|
|
+ expect(json.username).toBe('testuser');
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it('应该返回404当用户不存在', async () => {
|
|
|
|
|
+ const res = await integrateRoutes(app).GET('/api/users/99999');
|
|
|
|
|
+
|
|
|
|
|
+ expect(res.status).toBe(404);
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ describe('POST /api/users', () => {
|
|
|
|
|
+ it('应该创建新用户', async () => {
|
|
|
|
|
+ const userData = {
|
|
|
|
|
+ username: 'newuser',
|
|
|
|
|
+ email: 'new@example.com',
|
|
|
|
|
+ password: 'password123'
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const res = await integrateRoutes(app).POST('/api/users', { json: userData });
|
|
|
|
|
+
|
|
|
|
|
+ expect(res.status).toBe(201);
|
|
|
|
|
+ const json = await res.json();
|
|
|
|
|
+ expect(json.username).toBe('newuser');
|
|
|
|
|
+
|
|
|
|
|
+ // 验证数据库中的数据
|
|
|
|
|
+ const userRepo = dataSource.getRepository(User);
|
|
|
|
|
+ const user = await userRepo.findOne({ where: { username: 'newuser' } });
|
|
|
|
|
+ expect(user).toBeDefined();
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it('应该拒绝重复的用户名', async () => {
|
|
|
|
|
+ await seedTestUser(dataSource, { username: 'existing' });
|
|
|
|
|
+
|
|
|
|
|
+ const res = await integrateRoutes(app).POST('/api/users', {
|
|
|
|
|
+ json: {
|
|
|
|
|
+ username: 'existing',
|
|
|
|
|
+ email: 'different@example.com',
|
|
|
|
|
+ password: 'password123'
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ expect(res.status).toBe(400);
|
|
|
|
|
+ const json = await res.json();
|
|
|
|
|
+ expect(json.error).toContain('用户名已存在');
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+});
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### 2. 测试Service与数据库集成
|
|
|
|
|
+```typescript
|
|
|
|
|
+describe('UserService与数据库集成', () => {
|
|
|
|
|
+ let dataSource: DataSource;
|
|
|
|
|
+ let userService: UserService;
|
|
|
|
|
+
|
|
|
|
|
+ beforeEach(async () => {
|
|
|
|
|
+ dataSource = await getTestDataSource();
|
|
|
|
|
+ await dataSource.initialize();
|
|
|
|
|
+
|
|
|
|
|
+ const userRepo = dataSource.getRepository(User);
|
|
|
|
|
+ userService = new UserService(userRepo);
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ afterEach(async () => {
|
|
|
|
|
+ await dataSource.destroy();
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it('应该从数据库查询用户', async () => {
|
|
|
|
|
+ // 创建测试数据
|
|
|
|
|
+ const userRepo = dataSource.getRepository(User);
|
|
|
|
|
+ const createdUser = await userRepo.save({
|
|
|
|
|
+ username: 'testuser',
|
|
|
|
|
+ email: 'test@example.com',
|
|
|
|
|
+ password: 'hashedpassword'
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // 测试查询
|
|
|
|
|
+ const foundUser = await userService.findById(createdUser.id);
|
|
|
|
|
+
|
|
|
|
|
+ expect(foundUser).toBeDefined();
|
|
|
|
|
+ expect(foundUser?.username).toBe('testuser');
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it('应该正确处理事务', async () => {
|
|
|
|
|
+ await dataSource.transaction(async (manager) => {
|
|
|
|
|
+ const user1 = await manager.save(User, {
|
|
|
|
|
+ username: 'user1',
|
|
|
|
|
+ email: 'user1@example.com',
|
|
|
|
|
+ password: 'hash'
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ const user2 = await manager.save(User, {
|
|
|
|
|
+ username: 'user2',
|
|
|
|
|
+ email: 'user2@example.com',
|
|
|
|
|
+ password: 'hash'
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ expect(user1.id).toBeDefined();
|
|
|
|
|
+ expect(user2.id).toBeDefined();
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+});
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### 3. 测试中间件集成
|
|
|
|
|
+```typescript
|
|
|
|
|
+import { authMiddleware } from '../src/middleware/auth.middleware';
|
|
|
|
|
+import { generateToken } from '@d8d/shared-utils/jwt.util';
|
|
|
|
|
+
|
|
|
|
|
+describe('认证中间件集成测试', () => {
|
|
|
|
|
+ it('应该允许有效token的请求', async () => {
|
|
|
|
|
+ const app = new Hono();
|
|
|
|
|
+ app.use('/api/protected/*', authMiddleware);
|
|
|
|
|
+ app.get('/api/protected/data', (c) => c.json({ message: 'Protected data' }));
|
|
|
|
|
+
|
|
|
|
|
+ const token = generateToken({ userId: 1, username: 'testuser' });
|
|
|
|
|
+ const res = await integrateRoutes(app).GET('/api/protected/data', {
|
|
|
|
|
+ headers: {
|
|
|
|
|
+ Authorization: `Bearer ${token}`
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ expect(res.status).toBe(200);
|
|
|
|
|
+ expect(await res.json()).toEqual({ message: 'Protected data' });
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it('应该拒绝无效token的请求', async () => {
|
|
|
|
|
+ const app = new Hono();
|
|
|
|
|
+ app.use('/api/protected/*', authMiddleware);
|
|
|
|
|
+ app.get('/api/protected/data', (c) => c.json({ message: 'Protected data' }));
|
|
|
|
|
+
|
|
|
|
|
+ const res = await integrateRoutes(app).GET('/api/protected/data', {
|
|
|
|
|
+ headers: {
|
|
|
|
|
+ Authorization: 'Bearer invalid-token'
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ expect(res.status).toBe(401);
|
|
|
|
|
+ });
|
|
|
|
|
+});
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+## 测试数据工厂模式
|
|
|
|
|
+
|
|
|
|
|
+### factories.ts
|
|
|
|
|
+```typescript
|
|
|
|
|
+import { User } from '../src/entities/user.entity';
|
|
|
|
|
+import { Repository } from 'typeorm';
|
|
|
|
|
+
|
|
|
|
|
+export function buildUser(overrides = {}): Partial<User> {
|
|
|
|
|
+ return {
|
|
|
|
|
+ id: Math.floor(Math.random() * 10000),
|
|
|
|
|
+ username: `testuser_${Date.now()}`,
|
|
|
|
|
+ email: `test_${Date.now()}@example.com`,
|
|
|
|
|
+ password: 'hashedpassword',
|
|
|
|
|
+ role: 'user',
|
|
|
|
|
+ active: true,
|
|
|
|
|
+ createdAt: new Date(),
|
|
|
|
|
+ updatedAt: new Date(),
|
|
|
|
|
+ ...overrides
|
|
|
|
|
+ };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export async function createTestUser(
|
|
|
|
|
+ userRepo: Repository<User>,
|
|
|
|
|
+ overrides = {}
|
|
|
|
|
+): Promise<User> {
|
|
|
|
|
+ const userData = buildUser(overrides);
|
|
|
|
|
+ const user = userRepo.create(userData);
|
|
|
|
|
+ return await userRepo.save(user);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 批量创建测试数据
|
|
|
|
|
+export async function createTestUsers(
|
|
|
|
|
+ userRepo: Repository<User>,
|
|
|
|
|
+ count: number,
|
|
|
|
|
+ overrides = {}
|
|
|
|
|
+): Promise<User[]> {
|
|
|
|
|
+ const users = [];
|
|
|
|
|
+ for (let i = 0; i < count; i++) {
|
|
|
|
|
+ const user = await createTestUser(userRepo, {
|
|
|
|
|
+ username: `user${i}`,
|
|
|
|
|
+ email: `user${i}@example.com`,
|
|
|
|
|
+ ...overrides
|
|
|
|
|
+ });
|
|
|
|
|
+ users.push(user);
|
|
|
|
|
+ }
|
|
|
|
|
+ return users;
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+## 数据库测试策略
|
|
|
|
|
+
|
|
|
|
|
+### 1. 使用事务回滚
|
|
|
|
|
+```typescript
|
|
|
|
|
+describe('使用事务回滚', () => {
|
|
|
|
|
+ let queryRunner: QueryRunner;
|
|
|
|
|
+ let dataSource: DataSource;
|
|
|
|
|
+
|
|
|
|
|
+ beforeEach(async () => {
|
|
|
|
|
+ dataSource = await getTestDataSource();
|
|
|
|
|
+ await dataSource.initialize();
|
|
|
|
|
+ queryRunner = dataSource.createQueryRunner();
|
|
|
|
|
+ await queryRunner.startTransaction();
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ afterEach(async () => {
|
|
|
|
|
+ await queryRunner.rollbackTransaction();
|
|
|
|
|
+ await queryRunner.release();
|
|
|
|
|
+ await dataSource.destroy();
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it('每个测试后自动回滚', async () => {
|
|
|
|
|
+ const userRepo = queryRunner.manager.getRepository(User);
|
|
|
|
|
+ await userRepo.save({
|
|
|
|
|
+ username: 'testuser',
|
|
|
|
|
+ email: 'test@example.com',
|
|
|
|
|
+ password: 'hash'
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // 数据存在于事务中
|
|
|
|
|
+ const users = await userRepo.find();
|
|
|
|
|
+ expect(users).toHaveLength(1);
|
|
|
|
|
+
|
|
|
|
|
+ // 测试结束后自动回滚,数据库为空
|
|
|
|
|
+ });
|
|
|
|
|
+});
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### 2. 使用测试数据库
|
|
|
|
|
+```typescript
|
|
|
|
|
+// tests/fixtures/test-db.ts
|
|
|
|
|
+import { DataSource } from 'typeorm';
|
|
|
|
|
+import { User } from '../src/entities/user.entity';
|
|
|
|
|
+import { Role } from '../src/entities/role.entity';
|
|
|
|
|
+
|
|
|
|
|
+export async function getTestDataSource(): Promise<DataSource> {
|
|
|
|
|
+ return new DataSource({
|
|
|
|
|
+ type: 'postgres',
|
|
|
|
|
+ host: 'localhost',
|
|
|
|
|
+ port: 5432,
|
|
|
|
|
+ username: 'postgres',
|
|
|
|
|
+ password: 'test_password',
|
|
|
|
|
+ database: 'test_d8dai', // 独立的测试数据库
|
|
|
|
|
+ entities: [User, Role],
|
|
|
|
|
+ synchronize: true, // 自动同步表结构
|
|
|
|
|
+ dropSchema: true, // 每次测试前清空数据库
|
|
|
|
|
+ logging: false // 测试环境关闭SQL日志
|
|
|
|
|
+ });
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+## 覆盖率标准
|
|
|
|
|
+
|
|
|
|
|
+| 测试类型 | 最低要求 | 目标要求 | 关键模块要求 |
|
|
|
|
|
+|----------|----------|----------|--------------|
|
|
|
|
|
+| 单元测试 | 70% | 80% | 90% |
|
|
|
|
|
+| 集成测试 | 50% | 60% | 70% |
|
|
|
|
|
+
|
|
|
|
|
+**关键模块定义**:
|
|
|
|
|
+- 认证授权Service:90%单元测试覆盖率
|
|
|
|
|
+- 数据库操作Service:85%单元测试覆盖率
|
|
|
|
|
+- 核心业务逻辑:80%集成测试覆盖率
|
|
|
|
|
+
|
|
|
|
|
+## 运行测试
|
|
|
|
|
+
|
|
|
|
|
+### 本地开发
|
|
|
|
|
+```bash
|
|
|
|
|
+# 运行所有测试
|
|
|
|
|
+cd packages/user-module && pnpm test
|
|
|
|
|
+
|
|
|
|
|
+# 只运行单元测试
|
|
|
|
|
+pnpm test:unit
|
|
|
|
|
+
|
|
|
|
|
+# 只运行集成测试
|
|
|
|
|
+pnpm test:integration
|
|
|
|
|
+
|
|
|
|
|
+# 生成覆盖率报告
|
|
|
|
|
+pnpm test:coverage
|
|
|
|
|
+
|
|
|
|
|
+# 运行特定测试文件
|
|
|
|
|
+pnpm test user.service.test.ts
|
|
|
|
|
+
|
|
|
|
|
+# 运行特定测试用例
|
|
|
|
|
+pnpm test --testNamePattern="应该创建用户"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### CI/CD
|
|
|
|
|
+```yaml
|
|
|
|
|
+user-module-tests:
|
|
|
|
|
+ runs-on: ubuntu-latest
|
|
|
|
|
+ services:
|
|
|
|
|
+ postgres:
|
|
|
|
|
+ image: postgres:17
|
|
|
|
|
+ env:
|
|
|
|
|
+ POSTGRES_PASSWORD: test_password
|
|
|
|
|
+ POSTGRES_DB: test_d8dai
|
|
|
|
|
+ steps:
|
|
|
|
|
+ - run: cd packages/user-module && pnpm install
|
|
|
|
|
+ - run: cd packages/user-module && pnpm test
|
|
|
|
|
+
|
|
|
|
|
+all-modules-tests:
|
|
|
|
|
+ runs-on: ubuntu-latest
|
|
|
|
|
+ steps:
|
|
|
|
|
+ - run: cd packages/user-module && pnpm test
|
|
|
|
|
+ - run: cd packages/auth-module && pnpm test
|
|
|
|
|
+ - run: cd packages/file-module && pnpm test
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+## 测试命名约定
|
|
|
|
|
+
|
|
|
|
|
+### 文件命名
|
|
|
|
|
+- Service单元测试:`[service].service.test.ts`
|
|
|
|
|
+- Schema单元测试:`[schema].schema.test.ts`
|
|
|
|
|
+- 路由集成测试:`[route].routes.integration.test.ts`
|
|
|
|
|
+- Service集成测试:`[service].service.integration.test.ts`
|
|
|
|
|
+
|
|
|
|
|
+### 测试描述
|
|
|
|
|
+```typescript
|
|
|
|
|
+describe('UserService', () => {
|
|
|
|
|
+ describe('findById()', () => {
|
|
|
|
|
+ it('应该返回用户数据', async () => { });
|
|
|
|
|
+ it('应该返回null当用户不存在', async () => { });
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ describe('createUser()', () => {
|
|
|
|
|
+ it('应该创建新用户', async () => { });
|
|
|
|
|
+ it('应该拒绝重复的用户名', async () => { });
|
|
|
|
|
+ });
|
|
|
|
|
+});
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+## 常见错误避免
|
|
|
|
|
+
|
|
|
|
|
+### ❌ 不要在单元测试中使用真实数据库
|
|
|
|
|
+```typescript
|
|
|
|
|
+// 错误:单元测试连接真实数据库
|
|
|
|
|
+it('应该查询用户', async () => {
|
|
|
|
|
+ const dataSource = await getTestDataSource(); // 不要在单元测试中
|
|
|
|
|
+ // ...
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+// 正确:使用模拟的Repository
|
|
|
|
|
+it('应该查询用户', async () => {
|
|
|
|
|
+ const mockRepo = { findOne: vi.fn() };
|
|
|
|
|
+ const userService = new UserService(mockRepo);
|
|
|
|
|
+ // ...
|
|
|
|
|
+});
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### ❌ 不要在集成测试中mock数据库
|
|
|
|
|
+```typescript
|
|
|
|
|
+// 错误:集成测试中mock数据库
|
|
|
|
|
+vi.mock('typeorm', () => ({
|
|
|
|
|
+ getRepository: vi.fn(() => mockRepo)
|
|
|
|
|
+}));
|
|
|
|
|
+
|
|
|
|
|
+// 正确:使用真实数据库
|
|
|
|
|
+const dataSource = await getTestDataSource();
|
|
|
|
|
+const userRepo = dataSource.getRepository(User);
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### ❌ 不要共享测试数据
|
|
|
|
|
+```typescript
|
|
|
|
|
+// 错误:测试间共享数据
|
|
|
|
|
+let testUserId: number;
|
|
|
|
|
+
|
|
|
|
|
+it('应该创建用户', async () => {
|
|
|
|
|
+ const user = await createUser({ username: 'test' });
|
|
|
|
|
+ testUserId = user.id; // 污染下一个测试
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+it('应该更新用户', async () => {
|
|
|
|
|
+ await updateUser(testUserId, { username: 'updated' }); // 依赖上一个测试
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+// 正确:每个测试独立创建数据
|
|
|
|
|
+it('应该更新用户', async () => {
|
|
|
|
|
+ const user = await createTestUser(dataSource);
|
|
|
|
|
+ await updateUser(user.id, { username: 'updated' });
|
|
|
|
|
+});
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+## 调试技巧
|
|
|
|
|
+
|
|
|
|
|
+### 1. 查看SQL查询
|
|
|
|
|
+```typescript
|
|
|
|
|
+const dataSource = new DataSource({
|
|
|
|
|
+ // ...
|
|
|
|
|
+ logging: true, // 显示所有SQL
|
|
|
|
|
+ maxQueryExecutionTime: 100 // 记录慢查询
|
|
|
|
|
+});
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### 2. 打印测试数据
|
|
|
|
|
+```typescript
|
|
|
|
|
+it('调试测试', async () => {
|
|
|
|
|
+ const users = await userService.findAll();
|
|
|
|
|
+ console.debug('用户列表:', users);
|
|
|
|
|
+ console.debug('用户数量:', users.length);
|
|
|
|
|
+});
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### 3. 使用only运行特定测试
|
|
|
|
|
+```typescript
|
|
|
|
|
+it.only('只运行这个测试', async () => {
|
|
|
|
|
+ // ...
|
|
|
|
|
+});
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+## 参考实现
|
|
|
|
|
+
|
|
|
|
|
+- 用户模块包:`packages/user-module/tests/`
|
|
|
|
|
+- 认证模块包:`packages/auth-module/tests/`
|
|
|
|
|
+- 文件模块包:`packages/file-module/tests/`
|
|
|
|
|
+
|
|
|
|
|
+## 相关文档
|
|
|
|
|
+
|
|
|
|
|
+- [测试策略概述](./testing-strategy.md)
|
|
|
|
|
+- [Web UI包测试规范](./web-ui-testing-standards.md)
|
|
|
|
|
+- [Web Server包测试规范](./web-server-testing-standards.md)
|
|
|
|
|
+- [Mini UI包测试规范](./mini-ui-testing-standards.md)
|
|
|
|
|
+- [后端模块包开发规范](./backend-module-package-standards.md)
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+**文档状态**: 正式版
|
|
|
|
|
+**适用范围**: packages/*-module
|