backend-module-testing-standards.md 26 KB

后端模块包测试规范

版本信息

版本 日期 描述 作者
2.0 2025-12-26 基于实际测试实现重写,修正不准确的描述 James (Claude Code)
1.0 2025-12-26 从测试策略文档拆分,专注后端模块包测试 James (Claude Code)

概述

本文档定义了后端模块包的测试标准和最佳实践,基于项目实际的测试实现经验总结。

目标包

  • packages/core-module: 核心模块(用户、认证、文件等)
  • allin-packages/: AllIn业务模块(订单、企业、渠道等)

实际测试架构

重要: 项目的测试采用集中式管理模式:

  • 测试文件位于各模块包内的 tests/ 目录
  • 使用共享测试工具 @d8d/shared-test-util
  • 集成测试使用真实PostgreSQL数据库
  • 单元测试使用mock隔离依赖

测试框架栈

测试运行器

  • Vitest: 统一的测试运行器
  • 配置: 关闭文件并行测试 (fileParallelism: false) 避免数据库连接冲突

测试工具

  • hono/testing: testClient - API路由测试
  • @d8d/shared-test-util: 共享测试基础设施
    • IntegrationTestDatabase - 集成测试数据库管理
    • setupIntegrationDatabaseHooksWithEntities - 测试生命周期钩子
    • TestDataFactory - 测试数据工厂
  • TypeORM: 数据库操作和实体管理

Mock工具

  • vitest.mock: 依赖项模拟
  • vi.mocked: 类型安全的mock操作

测试文件结构

实际项目结构

packages/core-module/
├── auth-module/
│   └── tests/
│       ├── integration/
│       │   ├── auth.integration.test.ts
│       │   ├── phone-decrypt.integration.test.ts
│       │   └── system-config-integration.test.ts
│       └── unit/
│           └── mini-auth.service.test.ts
├── file-module/
│   └── tests/
│       ├── integration/
│       │   └── file.routes.integration.test.ts
│       └── unit/
│           └── file.service.test.ts
└── user-module/
    └── tests/
        ├── integration/
        │   ├── role.integration.test.ts
        │   └── user.routes.integration.test.ts
        └── utils/
            ├── integration-test-db.ts       # 模块专用测试工具
            └── integration-test-utils.ts

allin-packages/order-module/
├── src/
│   ├── routes/
│   ├── services/
│   ├── entities/
│   └── schemas/
├── tests/
│   ├── integration/
│   │   ├── order.integration.test.ts
│   │   └── talent-employment.integration.test.ts
│   └── utils/
│       └── test-data-factory.ts
└── vitest.config.ts

配置文件模板

// vitest.config.ts
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    globals: true,
    environment: 'node',
    include: ['tests/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html'],
      exclude: [
        'node_modules/',
        'dist/',
        'tests/',
        '**/*.d.ts',
        '**/*.config.*'
      ]
    },
    // 关键: 关闭并行测试避免数据库连接冲突
    fileParallelism: false
  }
});

集成测试最佳实践

1. 使用hono/testing测试API

import { describe, it, expect, beforeEach } from 'vitest';
import { testClient } from 'hono/testing';
import {
  IntegrationTestDatabase,
  setupIntegrationDatabaseHooksWithEntities
} from '@d8d/shared-test-util';
import { userRoutes } from '../src/routes';
import { UserEntity } from '../src/entities/user.entity';
import { Role } from '../src/entities/role.entity';
import { File } from '@d8d/core-module/file-module';
import { TestDataFactory } from './utils/integration-test-db';

// 设置集成测试钩子
setupIntegrationDatabaseHooksWithEntities([UserEntity, Role, File])

describe('用户路由API集成测试', () => {
  let client: ReturnType<typeof testClient<typeof userRoutes>>;
  let authService: AuthService;
  let testToken: string;
  let testUser: any;

  beforeEach(async () => {
    // 创建测试客户端
    client = testClient(userRoutes);

    // 获取数据源
    const dataSource = await IntegrationTestDatabase.getDataSource();
    if (!dataSource) throw new Error('Database not initialized');

    // 初始化服务
    const userService = new UserService(dataSource);
    authService = new AuthService(userService);

    // 创建测试用户并生成token
    testUser = await TestDataFactory.createTestUser(dataSource, {
      username: 'testuser_auth',
      email: 'testuser_auth@example.com'
    });

    testToken = authService.generateToken(testUser);
  });

  describe('用户创建路由测试', () => {
    it('应该拒绝无认证令牌的用户创建请求', async () => {
      const userData = {
        username: 'testuser_create',
        email: 'testcreate@example.com',
        password: 'TestPassword123!',
        nickname: 'Test User',
        phone: '13800138001'
      };

      const response = await client.index.$post({
        json: userData
      });

      // 应该返回401状态码,因为缺少认证
      expect(response.status).toBe(401);
      if (response.status === 401) {
        const responseData = await response.json();
        expect(responseData.message).toContain('Authorization header missing');
      }
    });

    it('应该成功创建用户(使用有效认证令牌)', async () => {
      const userData = {
        username: 'testuser_create_success',
        email: 'testcreate_success@example.com',
        password: 'TestPassword123!',
        nickname: 'Test User Success'
      };

      const response = await client.index.$post({
        json: userData
      }, {
        headers: {
          'Authorization': `Bearer ${testToken}`
        }
      });

      expect(response.status).toBe(200);
      const responseData = await response.json();
      expect(responseData.data).toHaveProperty('id');
      expect(responseData.data.username).toBe('testuser_create_success');
    });
  });

  describe('用户查询路由测试', () => {
    it('应该支持分页查询用户列表', async () => {
      // 创建多个测试用户
      const dataSource = await IntegrationTestDatabase.getDataSource();
      for (let i = 0; i < 15; i++) {
        await TestDataFactory.createTestUser(dataSource, {
          username: `pageuser_${i}`
        });
      }

      const response = await client.index.$get({
        query: {
          page: '1',
          pageSize: '10'
        }
      }, {
        headers: {
          'Authorization': `Bearer ${testToken}`
        }
      });

      expect(response.status).toBe(200);
      const responseData = await response.json();
      expect(responseData.data).toHaveLength(10);
      expect(responseData.total).toBeGreaterThanOrEqual(15);
    });
  });
});

2. 测试数据工厂模式

// tests/utils/integration-test-db.ts
import { DataSource } from 'typeorm';
import { UserEntity } from '../../src/entities/user.entity';
import { Role } from '../../src/entities/role.entity';

/**
 * 测试数据工厂类
 */
export class TestDataFactory {
  /**
   * 创建测试用户数据
   */
  static createUserData(overrides: Partial<UserEntity> = {}): Partial<UserEntity> {
    const timestamp = Date.now();
    return {
      username: `testuser_${timestamp}`,
      password: 'TestPassword123!',
      email: `test_${timestamp}@example.com`,
      phone: `138${timestamp.toString().slice(-8)}`,
      nickname: `Test User ${timestamp}`,
      name: `Test Name ${timestamp}`,
      isDisabled: 0,
      isDeleted: 0,
      ...overrides
    };
  }

  /**
   * 创建测试角色数据
   */
  static createRoleData(overrides: Partial<Role> = {}): Partial<Role> {
    const timestamp = Date.now();
    return {
      name: `test_role_${timestamp}`,
      description: `Test role description ${timestamp}`,
      ...overrides
    };
  }

  /**
   * 在数据库中创建测试用户
   */
  static async createTestUser(
    dataSource: DataSource,
    overrides: Partial<UserEntity> = {}
  ): Promise<UserEntity> {
    const userData = this.createUserData(overrides);
    const userRepository = dataSource.getRepository(UserEntity);

    const user = userRepository.create(userData);
    return await userRepository.save(user);
  }

  /**
   * 在数据库中创建测试角色
   */
  static async createTestRole(
    dataSource: DataSource,
    overrides: Partial<Role> = {}
  ): Promise<Role> {
    const roleData = this.createRoleData(overrides);
    const roleRepository = dataSource.getRepository(Role);

    const role = roleRepository.create(roleData);
    return await roleRepository.save(role);
  }
}

3. 集成测试断言工具

// tests/utils/integration-test-utils.ts
import { IntegrationTestDatabase } from '@d8d/shared-test-util';
import { UserEntity } from '../../src/entities/user.entity';

/**
 * 集成测试断言工具
 */
export class IntegrationTestAssertions {
  /**
   * 断言响应状态码
   */
  static expectStatus(response: { status: number }, expectedStatus: number): void {
    if (response.status !== expectedStatus) {
      throw new Error(`Expected status ${expectedStatus}, but got ${response.status}`);
    }
  }

  /**
   * 断言用户存在于数据库中
   */
  static async expectUserToExist(username: string): Promise<void> {
    const dataSource = await IntegrationTestDatabase.getDataSource();
    if (!dataSource) {
      throw new Error('Database not initialized');
    }

    const userRepository = dataSource.getRepository(UserEntity);
    const user = await userRepository.findOne({ where: { username } });

    if (!user) {
      throw new Error(`Expected user ${username} to exist in database`);
    }
  }

  /**
   * 断言用户不存在于数据库中
   */
  static async expectUserNotToExist(username: string): Promise<void> {
    const dataSource = await IntegrationTestDatabase.getDataSource();
    if (!dataSource) {
      throw new Error('Database not initialized');
    }

    const userRepository = dataSource.getRepository(UserEntity);
    const user = await userRepository.findOne({ where: { username } });

    if (user) {
      throw new Error(`Expected user ${username} not to exist in database`);
    }
  }
}

4. 测试完整业务流程

describe('订单管理完整流程测试', () => {
  let client: ReturnType<typeof testClient<typeof orderRoutes>>;
  let testToken: string;
  let testPlatform: any;
  let testCompany: any;
  let testChannel: any;

  beforeEach(async () => {
    client = testClient(orderRoutes);
    const dataSource = await IntegrationTestDatabase.getDataSource();

    // 创建测试基础数据
    testPlatform = await createTestPlatform(dataSource);
    testCompany = await createTestCompany(dataSource);
    testChannel = await createTestChannel(dataSource);

    // 创建测试用户并生成token
    const testUser = await TestDataFactory.createTestUser(dataSource);
    testToken = JWTUtil.generateToken({
      userId: testUser.id,
      username: testUser.username
    });
  });

  it('应该完成订单创建到分配人员的完整流程', async () => {
    // 1. 创建订单
    const createResponse = await client.create.$post({
      json: {
        orderName: '测试订单',
        platformId: testPlatform.id,
        companyId: testCompany.id,
        channelId: testChannel.id,
        expectedStartDate: new Date().toISOString(),
        orderStatus: 'DRAFT'
      }
    }, {
      headers: { 'Authorization': `Bearer ${testToken}` }
    });

    expect(createResponse.status).toBe(200);
    const { data: order } = await createResponse.json();

    // 2. 更新订单状态
    const updateResponse = await client[':id'].$patch({
      param: { id: order.id },
      json: { orderStatus: 'ACTIVE' }
    }, {
      headers: { 'Authorization': `Bearer ${testToken}` }
    });

    expect(updateResponse.status).toBe(200);

    // 3. 分配人员到订单
    const assignResponse = await client.assign.$post({
      json: {
        orderId: order.id,
        personIds: [1, 2, 3]
      }
    }, {
      headers: { 'Authorization': `Bearer ${testToken}` }
    });

    expect(assignResponse.status).toBe(200);

    // 4. 验证订单详情
    const detailResponse = await client[':id'].$get({
      param: { id: order.id }
    }, {
      headers: { 'Authorization': `Bearer ${testToken}` }
    });

    expect(detailResponse.status).toBe(200);
    const { data: orderDetail } = await detailResponse.json();
    expect(orderDetail.orderStatus).toBe('ACTIVE');
  });
});

单元测试最佳实践

1. Service层单元测试

import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import { DataSource } from 'typeorm';
import { FileService } from '../../src/services/file.service';
import { File } from '../../src/entities/file.entity';
import { MinioService } from '../../src/services/minio.service';

// Mock依赖项
vi.mock('../../src/services/minio.service');
vi.mock('@d8d/shared-utils', () => ({
  logger: {
    error: vi.fn(),
    db: vi.fn()
  },
  ErrorSchema: {}
}));
vi.mock('uuid', () => ({
  v4: () => 'test-uuid-123'
}));

describe('FileService', () => {
  let mockDataSource: DataSource;
  let fileService: FileService;

  beforeEach(() => {
    mockDataSource = {
      getRepository: vi.fn(() => ({
        findOne: vi.fn(),
        findOneBy: vi.fn(),
        save: vi.fn(),
        create: vi.fn()
      }))
    } as unknown as DataSource;

    fileService = new FileService(mockDataSource);
  });

  afterEach(() => {
    vi.clearAllMocks();
  });

  describe('createFile', () => {
    it('应该成功创建文件并生成上传策略', async () => {
      const mockFileData = {
        name: 'test.txt',
        type: 'text/plain',
        size: 1024,
        uploadUserId: 1
      };

      const mockUploadPolicy = {
        'x-amz-algorithm': 'test-algorithm',
        'x-amz-credential': 'test-credential',
        host: 'https://minio.example.com'
      };

      const mockSavedFile = {
        id: 1,
        ...mockFileData,
        path: '1/test-uuid-123-test.txt',
        uploadTime: new Date(),
        createdAt: new Date(),
        updatedAt: new Date()
      };

      const mockGenerateUploadPolicy = vi.fn().mockResolvedValue(mockUploadPolicy);
      vi.mocked(MinioService).mockImplementation(() => ({
        generateUploadPolicy: mockGenerateUploadPolicy
      } as unknown as MinioService));

      // Mock GenericCrudService的create方法
      vi.spyOn(fileService, 'create').mockResolvedValue(mockSavedFile as File);

      const result = await fileService.createFile(mockFileData);

      expect(mockGenerateUploadPolicy).toHaveBeenCalledWith('1/test-uuid-123-test.txt');
      expect(fileService.create).toHaveBeenCalledWith(expect.objectContaining({
        name: 'test.txt',
        path: '1/test-uuid-123-test.txt',
        uploadUserId: 1
      }));
      expect(result).toEqual({
        file: mockSavedFile,
        uploadPolicy: mockUploadPolicy
      });
    });

    it('应该处理文件创建错误', async () => {
      const mockFileData = {
        name: 'test.txt',
        uploadUserId: 1
      };

      const mockGenerateUploadPolicy = vi.fn()
        .mockRejectedValue(new Error('MinIO error'));

      vi.mocked(MinioService).mockImplementation(() => ({
        generateUploadPolicy: mockGenerateUploadPolicy
      } as unknown as MinioService));

      await expect(fileService.createFile(mockFileData))
        .rejects.toThrow('MinIO error');
    });
  });
});

2. Schema验证测试

import { describe, it, expect } from 'vitest';
import { CreatePlatformSchema, UpdatePlatformSchema } from '../../src/schemas/platform.schema';

describe('平台Schema验证测试', () => {
  describe('CreatePlatformSchema', () => {
    it('应该验证有效的平台数据', () => {
      const data = {
        platformName: '测试平台',
        contactEmail: 'test@example.com',
        contactPhone: '13800138000'
      };

      const result = CreatePlatformSchema.safeParse(data);

      expect(result.success).toBe(true);
      if (result.success) {
        expect(result.data.platformName).toBe('测试平台');
        expect(result.data.contactEmail).toBe('test@example.com');
      }
    });

    it('应该验证空字符串转换为undefined', () => {
      const data = {
        platformName: '测试平台',
        contactEmail: '' // 空字符串应该转换为undefined
      };

      const result = CreatePlatformSchema.safeParse(data);

      expect(result.success).toBe(true);
      if (result.success) {
        expect(result.data.contactEmail).toBeUndefined();
      }
    });

    it('应该拒绝无效的邮箱格式', () => {
      const data = {
        platformName: '测试平台',
        contactEmail: 'invalid-email'
      };

      const result = CreatePlatformSchema.safeParse(data);

      expect(result.success).toBe(false);
    });

    it('应该拒绝缺失必填字段', () => {
      const data = {
        contactEmail: 'test@example.com'
        // 缺少 platformName
      };

      const result = CreatePlatformSchema.safeParse(data);

      expect(result.success).toBe(false);
    });
  });
});

3. 工具函数测试

import { describe, it, expect } from 'vitest';
import { generateOrderNumber, calculateOrderAmount } from '../../src/utils/order.util';

describe('订单工具函数测试', () => {
  describe('generateOrderNumber', () => {
    it('应该生成唯一订单号', () => {
      const orderNumber1 = generateOrderNumber();
      const orderNumber2 = generateOrderNumber();

      expect(orderNumber1).not.toBe(orderNumber2);
      expect(orderNumber1).toMatch(/^ORD\d{13}$/); // ORD + 13位时间戳
    });

    it('应该生成带前缀的订单号', () => {
      const orderNumber = generateOrderNumber('TEST');
      expect(orderNumber).toMatch(/^TEST\d{13}$/);
    });
  });

  describe('calculateOrderAmount', () => {
    it('应该正确计算订单金额', () => {
      const items = [
        { quantity: 2, unitPrice: 100 },
        { quantity: 3, unitPrice: 50 }
      ];

      const total = calculateOrderAmount(items);

      expect(total).toBe(350); // 2*100 + 3*50
    });

    it('应该处理空数组', () => {
      const total = calculateOrderAmount([]);
      expect(total).toBe(0);
    });

    it('应该处理小数精度', () => {
      const items = [
        { quantity: 1, unitPrice: 99.99 },
        { quantity: 2, unitPrice: 50.005 }
      ];

      const total = calculateOrderAmount(items);

      expect(total).toBeCloseTo(200, 2);
    });
  });
});

共享测试工具使用

IntegrationTestDatabase

来自 @d8d/shared-test-util,用于管理集成测试数据库:

import {
  IntegrationTestDatabase,
  setupIntegrationDatabaseHooksWithEntities
} from '@d8d/shared-test-util';
import { UserEntity } from '../src/entities/user.entity';
import { Role } from '../src/entities/role.entity';

// 设置测试生命周期钩子
setupIntegrationDatabaseHooksWithEntities([UserEntity, Role])

describe('集成测试', () => {
  it('使用集成测试数据库', async () => {
    // 获取数据源
    const dataSource = await IntegrationTestDatabase.getDataSource();

    // 使用Repository
    const userRepo = dataSource.getRepository(UserEntity);
    const user = await userRepo.findOne({ where: { id: 1 } });

    expect(user).toBeDefined();
  });
});

自定义模块测试工具

每个模块可以定义自己的测试工具:

// tests/utils/integration-test-db.ts
import { DataSource } from 'typeorm';
import { IntegrationTestDatabase } from '@d8d/shared-test-util';
import { Order } from '../../src/entities/order.entity';
import { Talent } from '../../src/entities/talent.entity';

export class OrderTestDataFactory {
  /**
   * 创建测试订单数据
   */
  static async createTestOrder(
    dataSource: DataSource,
    overrides: Partial<Order> = {}
  ): Promise<Order> {
    const timestamp = Date.now();
    const orderData = {
      orderName: `测试订单_${timestamp}`,
      orderStatus: 'DRAFT',
      workStatus: 'NOT_WORKING',
      ...overrides
    };

    const orderRepo = dataSource.getRepository(Order);
    const order = orderRepo.create(orderData);
    return await orderRepo.save(order);
  }

  /**
   * 创建测试残疾人数据
   */
  static async createTestTalent(
    dataSource: DataSource,
    overrides: Partial<Talent> = {}
  ): Promise<Talent> {
    const timestamp = Date.now();
    const talentData = {
      name: `测试残疾人_${timestamp}`,
      idCard: `110101199001011${timestamp.toString().slice(-3)}`,
      phone: `138${timestamp.toString().slice(-8)}`,
      ...overrides
    };

    const talentRepo = dataSource.getRepository(Talent);
    const talent = talentRepo.create(talentData);
    return await talentRepo.save(talent);
  }
}

测试命名约定

文件命名

  • 集成测试:[feature].integration.test.ts
  • 单元测试:[component].test.ts
  • 测试工具:integration-test-db.tsintegration-test-utils.ts

测试描述

describe('[模块名]', () => {
  describe('[功能名]', () => {
    it('应该[预期行为]', async () => { });
    it('应该拒绝[错误情况]', async () => { });
  });
});

运行测试

在模块包中运行

# 进入模块目录
cd packages/core-module/user-module
# 或
cd allin-packages/order-module

# 运行所有测试
pnpm test

# 运行集成测试
pnpm test:integration

# 运行单元测试
pnpm test:unit

# 生成覆盖率报告
pnpm test:coverage

# 运行特定测试
pnpm test --testNamePattern="应该成功创建用户"

# 监听模式
pnpm test --watch

根目录运行

# 运行所有模块测试
pnpm test

# 运行特定目录的测试
pnpm test "packages/core-module/**/*.test.ts"
pnpm test "allin-packages/**/*.test.ts"

覆盖率标准

测试类型 最低要求 目标要求 关键模块要求
集成测试 60% 70% 80%
单元测试 50% 60% 70%

关键模块定义

  • 认证授权Service:70%单元测试覆盖率
  • 数据库操作Service:70%单元测试覆盖率
  • API路由:80%集成测试覆盖率
  • Schema验证:90%单元测试覆盖率

常见错误避免

❌ 不要在集成测试中mock数据库

// 错误:集成测试中mock数据库Repository
vi.mock('typeorm', () => ({
  getRepository: vi.fn(() => mockRepo)
}));

// 正确:使用真实的数据库和Repository
const dataSource = await IntegrationTestDatabase.getDataSource();
const userRepo = dataSource.getRepository(UserEntity);

❌ 不要硬编码测试数据

// 错误:硬编码用户名
it('应该创建用户', async () => {
  await createTestUser(dataSource, { username: 'testuser' });
});

// 正确:使用时间戳生成唯一数据
it('应该创建用户', async () => {
  await TestDataFactory.createTestUser(dataSource); // 自动生成唯一用户名
});

❌ 不要忽略认证测试

// 错误:不测试认证
it('应该创建用户', async () => {
  const response = await client.create.$post({ json: userData });
  expect(response.status).toBe(200);
});

// 正确:测试有认证和无认证两种情况
it('应该拒绝无认证的请求', async () => {
  const response = await client.create.$post({ json: userData });
  expect(response.status).toBe(401);
});

it('应该接受有效认证的请求', async () => {
  const response = await client.create.$post({
    json: userData
  }, {
    headers: { 'Authorization': `Bearer ${testToken}` }
  });
  expect(response.status).toBe(200);
});

❌ 不要忘记清理测试数据

// 错误:不使用setupIntegrationDatabaseHooksWithEntities
describe('测试', () => {
  beforeEach(async () => {
    await IntegrationTestDatabase.initializeWithEntities([UserEntity]);
  });
  // 没有afterEach清理
});

// 正确:使用setupIntegrationDatabaseHooksWithEntities自动管理
setupIntegrationDatabaseHooksWithEntities([UserEntity])

describe('测试', () => {
  // beforeEach和afterEach自动设置
});

调试技巧

1. 运行特定测试

# 运行特定测试文件
pnpm test user.routes.integration.test.ts

# 运行匹配名称的测试
pnpm test --testNamePattern="应该成功创建用户"

# 显示详细输出
pnpm test --reporter=verbose

2. 调试SQL查询

// 在测试中启用SQL日志
const dataSource = await IntegrationTestDatabase.getDataSource();
// 查看实际执行的SQL
dataSource.driver.createQueryRunner('debug').query('SELECT * FROM users');

3. 使用only专注某个测试

describe.only('专注这个测试套件', () => {
  it.only('只运行这个测试', async () => {
    // ...
  });
});

参考实现

核心模块

  • 用户模块:packages/core-module/user-module/tests/
  • 认证模块:packages/core-module/auth-module/tests/
  • 文件模块:packages/core-module/file-module/tests/

AllIn业务模块

  • 订单模块:allin-packages/order-module/tests/
  • 企业模块:allin-packages/company-module/tests/
  • 渠道模块:allin-packages/channel-module/tests/
  • 残疾人模块:allin-packages/disability-module/tests/

共享测试工具

  • 测试基础设施:packages/shared-test-util/src/

相关文档


文档状态: 正式版 适用范围: packages/core-module、allin-packages/ 基于实际测试实现: 2025-12-26